fix: 부적합 제출 버그 수정 및 UI 개선
- 부적합 API 호출 형식 수정 (카테고리/아이템 추가 시) - 부적합 저장 시 내부 플래그 제거 후 백엔드 전송 - 기본 부적합 객체 구조 수정 (category_id, item_id 추가) - 날씨 API 시간대 수정 (UTC → KST 변환) - 신고 카테고리 관리 페이지 추가 (/pages/admin/issue-categories.html) - 부적합 입력 UI 개선 (대분류→소분류 캐스케이딩 선택) - 저장된 부적합 분리 표시 및 수정/삭제 기능 - 디버깅 로그 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,6 +206,7 @@ exports.createReport = async (req, res) => {
|
||||
visit_request_id,
|
||||
issue_category_id,
|
||||
issue_item_id,
|
||||
custom_item_name, // 직접 입력한 항목명
|
||||
additional_description,
|
||||
photos = []
|
||||
} = req.body;
|
||||
@@ -221,6 +222,36 @@ exports.createReport = async (req, res) => {
|
||||
return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' });
|
||||
}
|
||||
|
||||
// 항목 검증 (기존 항목 또는 직접 입력)
|
||||
if (!issue_item_id && !custom_item_name) {
|
||||
return res.status(400).json({ success: false, error: '신고 항목은 필수입니다.' });
|
||||
}
|
||||
|
||||
// 직접 입력한 항목이 있으면 DB에 저장
|
||||
let finalItemId = issue_item_id;
|
||||
if (custom_item_name && !issue_item_id) {
|
||||
try {
|
||||
finalItemId = await new Promise((resolve, reject) => {
|
||||
workIssueModel.createItem(
|
||||
{
|
||||
category_id: issue_category_id,
|
||||
item_name: custom_item_name,
|
||||
description: '사용자 직접 입력',
|
||||
severity: 'medium',
|
||||
display_order: 999 // 마지막에 표시
|
||||
},
|
||||
(err, itemId) => {
|
||||
if (err) reject(err);
|
||||
else resolve(itemId);
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (itemErr) {
|
||||
console.error('커스텀 항목 생성 실패:', itemErr);
|
||||
return res.status(500).json({ success: false, error: '항목 저장 실패' });
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 저장 (최대 5장)
|
||||
const photoPaths = {
|
||||
photo_path1: null,
|
||||
@@ -247,7 +278,7 @@ exports.createReport = async (req, res) => {
|
||||
tbm_session_id: tbm_session_id || null,
|
||||
visit_request_id: visit_request_id || null,
|
||||
issue_category_id,
|
||||
issue_item_id: issue_item_id || null,
|
||||
issue_item_id: finalItemId || null,
|
||||
additional_description: additional_description || null,
|
||||
...photoPaths
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 작업보고서 부적합에 카테고리/아이템 컬럼 추가
|
||||
*
|
||||
* 변경사항:
|
||||
* 1. work_report_defects 테이블에 category_id, item_id 컬럼 추가
|
||||
* 2. issue_report_categories, issue_report_items 테이블 참조
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
.alterTable('work_report_defects', function(table) {
|
||||
// 카테고리 ID 추가
|
||||
table.integer('category_id').unsigned().nullable()
|
||||
.comment('issue_report_categories의 category_id (직접 입력 시)')
|
||||
.after('issue_report_id');
|
||||
|
||||
// 아이템 ID 추가
|
||||
table.integer('item_id').unsigned().nullable()
|
||||
.comment('issue_report_items의 item_id (직접 입력 시)')
|
||||
.after('category_id');
|
||||
|
||||
// 외래키 추가
|
||||
table.foreign('category_id')
|
||||
.references('category_id')
|
||||
.inTable('issue_report_categories')
|
||||
.onDelete('SET NULL');
|
||||
|
||||
table.foreign('item_id')
|
||||
.references('item_id')
|
||||
.inTable('issue_report_items')
|
||||
.onDelete('SET NULL');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.alterTable('work_report_defects', function(table) {
|
||||
table.dropForeign('category_id');
|
||||
table.dropForeign('item_id');
|
||||
table.dropColumn('category_id');
|
||||
table.dropColumn('item_id');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 작업보고서 부적합을 신고 시스템과 연동
|
||||
*
|
||||
* 변경사항:
|
||||
* 1. work_report_defects 테이블에 issue_report_id 컬럼 추가
|
||||
* 2. error_type_id를 NULL 허용으로 변경 (신고 연동 시 불필요)
|
||||
* 3. work_issue_reports.report_id (unsigned int)와 타입 일치 필요
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
.alterTable('work_report_defects', function(table) {
|
||||
// 1. issue_report_id 컬럼 추가 (unsigned int로 work_issue_reports.report_id와 타입 일치)
|
||||
table.integer('issue_report_id').unsigned().nullable()
|
||||
.comment('work_issue_reports의 report_id (신고된 이슈 연결)')
|
||||
.after('error_type_id');
|
||||
|
||||
// 2. 외래키 추가 (work_issue_reports.report_id 참조)
|
||||
table.foreign('issue_report_id')
|
||||
.references('report_id')
|
||||
.inTable('work_issue_reports')
|
||||
.onDelete('SET NULL');
|
||||
|
||||
// 3. 인덱스 추가
|
||||
table.index('issue_report_id');
|
||||
})
|
||||
// 4. error_type_id를 NULL 허용으로 변경
|
||||
.then(function() {
|
||||
return knex.raw(`
|
||||
ALTER TABLE work_report_defects
|
||||
MODIFY COLUMN error_type_id INT NULL
|
||||
COMMENT 'error_types의 id (부적합 원인) - 레거시, issue_report_id 사용 권장'
|
||||
`);
|
||||
})
|
||||
// 5. 유니크 제약 수정 (issue_report_id도 고려)
|
||||
.then(function() {
|
||||
// 기존 유니크 제약 삭제
|
||||
return knex.raw(`
|
||||
ALTER TABLE work_report_defects
|
||||
DROP INDEX work_report_defects_report_id_error_type_id_unique
|
||||
`).catch(() => {
|
||||
// 인덱스가 없을 수 있음 - 무시
|
||||
});
|
||||
})
|
||||
.then(function() {
|
||||
// 새 유니크 제약 추가 (report_id + issue_report_id 조합)
|
||||
return knex.raw(`
|
||||
ALTER TABLE work_report_defects
|
||||
ADD UNIQUE INDEX work_report_defects_report_issue_unique (report_id, issue_report_id)
|
||||
`).catch(() => {
|
||||
// 이미 존재할 수 있음 - 무시
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.alterTable('work_report_defects', function(table) {
|
||||
// 외래키 및 인덱스 삭제
|
||||
table.dropForeign('issue_report_id');
|
||||
table.dropIndex('issue_report_id');
|
||||
table.dropColumn('issue_report_id');
|
||||
})
|
||||
// error_type_id를 다시 NOT NULL로 변경
|
||||
.then(function() {
|
||||
return knex.raw(`
|
||||
ALTER TABLE work_report_defects
|
||||
MODIFY COLUMN error_type_id INT NOT NULL
|
||||
COMMENT 'error_types의 id (부적합 원인)'
|
||||
`);
|
||||
})
|
||||
// 기존 유니크 제약 복원
|
||||
.then(function() {
|
||||
return knex.raw(`
|
||||
ALTER TABLE work_report_defects
|
||||
DROP INDEX IF EXISTS work_report_defects_report_issue_unique
|
||||
`).catch(() => {});
|
||||
})
|
||||
.then(function() {
|
||||
return knex.raw(`
|
||||
ALTER TABLE work_report_defects
|
||||
ADD UNIQUE INDEX work_report_defects_report_id_error_type_id_unique (report_id, error_type_id)
|
||||
`).catch(() => {});
|
||||
});
|
||||
};
|
||||
@@ -200,6 +200,9 @@ const deleteItem = async (itemId, callback) => {
|
||||
|
||||
// ==================== 문제 신고 관리 ====================
|
||||
|
||||
// 한국 시간 유틸리티 import
|
||||
const { getKoreaDatetime } = require('../utils/dateUtils');
|
||||
|
||||
/**
|
||||
* 신고 생성
|
||||
*/
|
||||
@@ -223,13 +226,16 @@ const createReport = async (reportData, callback) => {
|
||||
photo_path5 = null
|
||||
} = reportData;
|
||||
|
||||
// 한국 시간 기준으로 신고 일시 설정
|
||||
const reportDate = getKoreaDatetime();
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO work_issue_reports
|
||||
(reporter_id, factory_category_id, workplace_id, custom_location,
|
||||
(reporter_id, report_date, factory_category_id, workplace_id, custom_location,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
|
||||
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[reporter_id, factory_category_id, workplace_id, custom_location,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[reporter_id, reportDate, factory_category_id, workplace_id, custom_location,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
|
||||
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const dailyWorkReportController = require('../controllers/dailyWorkReportController');
|
||||
const workReportController = require('../controllers/workReportController');
|
||||
|
||||
// 📋 마스터 데이터 조회 라우트들 (모든 인증된 사용자)
|
||||
router.get('/work-types', dailyWorkReportController.getWorkTypes);
|
||||
@@ -88,4 +89,9 @@ router.delete('/date/:date/worker/:worker_id', dailyWorkReportController.removeD
|
||||
// 🗑️ 특정 작업보고서 삭제 (항상 가장 마지막에 정의)
|
||||
router.delete('/:id', dailyWorkReportController.removeDailyWorkReport);
|
||||
|
||||
// 📋 부적합 관리 (workReportController 사용)
|
||||
router.get('/:reportId/defects', workReportController.getReportDefects);
|
||||
router.put('/:reportId/defects', workReportController.saveReportDefects);
|
||||
router.post('/:reportId/defects', workReportController.addReportDefect);
|
||||
|
||||
module.exports = router;
|
||||
383
api.hyungi.net/services/mProjectService.js
Normal file
383
api.hyungi.net/services/mProjectService.js
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* M-Project API 연동 서비스
|
||||
*
|
||||
* TK-FB-Project의 부적합 신고를 M-Project로 전송하는 서비스
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-03
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// M-Project API 설정
|
||||
const M_PROJECT_CONFIG = {
|
||||
baseUrl: process.env.M_PROJECT_API_URL || 'http://localhost:16080',
|
||||
username: process.env.M_PROJECT_USERNAME || 'api_service',
|
||||
password: process.env.M_PROJECT_PASSWORD || '',
|
||||
defaultProjectId: parseInt(process.env.M_PROJECT_DEFAULT_PROJECT_ID) || 1,
|
||||
};
|
||||
|
||||
// 캐시된 인증 토큰
|
||||
let cachedToken = null;
|
||||
let tokenExpiry = null;
|
||||
|
||||
/**
|
||||
* TK-FB 카테고리를 M-Project 카테고리로 매핑
|
||||
* @param {string} tkCategory - TK-FB-Project 카테고리
|
||||
* @returns {string} M-Project 카테고리
|
||||
*/
|
||||
function mapCategoryToMProject(tkCategory) {
|
||||
const categoryMap = {
|
||||
// TK-FB 카테고리 → M-Project 카테고리
|
||||
'자재 누락': 'material_missing',
|
||||
'자재누락': 'material_missing',
|
||||
'자재 관련': 'material_missing',
|
||||
'설계 오류': 'design_error',
|
||||
'설계미스': 'design_error',
|
||||
'설계 관련': 'design_error',
|
||||
'입고 불량': 'incoming_defect',
|
||||
'입고자재 불량': 'incoming_defect',
|
||||
'입고 관련': 'incoming_defect',
|
||||
'검사 미스': 'inspection_miss',
|
||||
'검사미스': 'inspection_miss',
|
||||
'검사 관련': 'inspection_miss',
|
||||
'기타': 'etc',
|
||||
};
|
||||
|
||||
// 정확히 매칭되는 경우
|
||||
if (categoryMap[tkCategory]) {
|
||||
return categoryMap[tkCategory];
|
||||
}
|
||||
|
||||
// 부분 매칭 시도
|
||||
const lowerCategory = tkCategory?.toLowerCase() || '';
|
||||
if (lowerCategory.includes('자재') || lowerCategory.includes('material')) {
|
||||
return 'material_missing';
|
||||
}
|
||||
if (lowerCategory.includes('설계') || lowerCategory.includes('design')) {
|
||||
return 'design_error';
|
||||
}
|
||||
if (lowerCategory.includes('입고') || lowerCategory.includes('incoming')) {
|
||||
return 'incoming_defect';
|
||||
}
|
||||
if (lowerCategory.includes('검사') || lowerCategory.includes('inspection')) {
|
||||
return 'inspection_miss';
|
||||
}
|
||||
|
||||
// 기본값
|
||||
return 'etc';
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project API 인증 토큰 획득
|
||||
* @returns {Promise<string|null>} JWT 토큰
|
||||
*/
|
||||
async function getAuthToken() {
|
||||
// 캐시된 토큰이 유효하면 재사용
|
||||
if (cachedToken && tokenExpiry && new Date() < tokenExpiry) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', M_PROJECT_CONFIG.username);
|
||||
formData.append('password', M_PROJECT_CONFIG.password);
|
||||
|
||||
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('M-Project 인증 실패', {
|
||||
status: response.status,
|
||||
error: errorText
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
cachedToken = data.access_token;
|
||||
|
||||
// 토큰 만료 시간 설정 (기본 6일, M-Project는 7일 유효)
|
||||
tokenExpiry = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000);
|
||||
|
||||
logger.info('M-Project 인증 성공');
|
||||
return cachedToken;
|
||||
} catch (error) {
|
||||
logger.error('M-Project 인증 요청 오류', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 URL을 Base64로 변환
|
||||
* @param {string} imageUrl - 이미지 URL 또는 경로
|
||||
* @returns {Promise<string|null>} Base64 인코딩된 이미지
|
||||
*/
|
||||
async function convertImageToBase64(imageUrl) {
|
||||
if (!imageUrl) return null;
|
||||
|
||||
try {
|
||||
// 이미 Base64인 경우 그대로 반환
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// URL인 경우 fetch
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(buffer).toString('base64');
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
return `data:${contentType};base64,${base64}`;
|
||||
}
|
||||
|
||||
// 로컬 파일 경로인 경우
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const filePath = path.join(process.cwd(), 'uploads', imageUrl);
|
||||
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
const base64 = fileBuffer.toString('base64');
|
||||
const ext = path.extname(imageUrl).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
const contentType = mimeTypes[ext] || 'image/jpeg';
|
||||
return `data:${contentType};base64,${base64}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('이미지 Base64 변환 실패', { imageUrl, error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TK-FB-Project 부적합 신고를 M-Project로 전송
|
||||
*
|
||||
* @param {Object} issueData - 부적합 신고 데이터
|
||||
* @param {string} issueData.category - 카테고리 이름
|
||||
* @param {string} issueData.description - 신고 내용
|
||||
* @param {string} issueData.reporter_name - 신고자 이름
|
||||
* @param {string} issueData.project_name - 프로젝트 이름 (옵션)
|
||||
* @param {number} issueData.tk_issue_id - TK-FB-Project 이슈 ID
|
||||
* @param {string[]} issueData.photos - 사진 URL 배열 (최대 5개)
|
||||
* @returns {Promise<{success: boolean, mProjectId?: number, error?: string}>}
|
||||
*/
|
||||
async function sendToMProject(issueData) {
|
||||
const {
|
||||
category,
|
||||
description,
|
||||
reporter_name,
|
||||
project_name,
|
||||
tk_issue_id,
|
||||
photos = [],
|
||||
} = issueData;
|
||||
|
||||
logger.info('M-Project 연동 시작', { tk_issue_id, category });
|
||||
|
||||
// 인증 토큰 획득
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { success: false, error: 'M-Project 인증 실패' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 카테고리 매핑
|
||||
const mProjectCategory = mapCategoryToMProject(category);
|
||||
|
||||
// 설명에 TK-FB 정보 추가
|
||||
const enhancedDescription = [
|
||||
description,
|
||||
'',
|
||||
'---',
|
||||
`[TK-FB-Project 연동]`,
|
||||
`- 원본 이슈 ID: ${tk_issue_id}`,
|
||||
`- 신고자: ${reporter_name || '미상'}`,
|
||||
project_name ? `- 프로젝트: ${project_name}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
// 사진 변환 (최대 5개)
|
||||
const photoPromises = photos.slice(0, 5).map(convertImageToBase64);
|
||||
const base64Photos = await Promise.all(photoPromises);
|
||||
|
||||
// 요청 본문 구성
|
||||
const requestBody = {
|
||||
category: mProjectCategory,
|
||||
description: enhancedDescription,
|
||||
project_id: M_PROJECT_CONFIG.defaultProjectId,
|
||||
};
|
||||
|
||||
// 사진 추가
|
||||
base64Photos.forEach((photo, index) => {
|
||||
if (photo) {
|
||||
const fieldName = index === 0 ? 'photo' : `photo${index + 1}`;
|
||||
requestBody[fieldName] = photo;
|
||||
}
|
||||
});
|
||||
|
||||
// M-Project API 호출
|
||||
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/issues`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('M-Project 이슈 생성 실패', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
tk_issue_id
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `M-Project API 오류: ${response.status}`
|
||||
};
|
||||
}
|
||||
|
||||
const createdIssue = await response.json();
|
||||
|
||||
logger.info('M-Project 이슈 생성 성공', {
|
||||
tk_issue_id,
|
||||
m_project_id: createdIssue.id
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mProjectId: createdIssue.id
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('M-Project 연동 오류', {
|
||||
tk_issue_id,
|
||||
error: error.message
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project에서 이슈 상태 조회
|
||||
*
|
||||
* @param {number} mProjectId - M-Project 이슈 ID
|
||||
* @returns {Promise<{success: boolean, status?: string, data?: Object, error?: string}>}
|
||||
*/
|
||||
async function getIssueStatus(mProjectId) {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { success: false, error: 'M-Project 인증 실패' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${M_PROJECT_CONFIG.baseUrl}/api/issues/${mProjectId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `M-Project API 오류: ${response.status}`
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: data.review_status || data.status,
|
||||
data
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project 상태를 TK-FB 상태로 매핑
|
||||
* @param {string} mProjectStatus - M-Project 상태
|
||||
* @returns {string} TK-FB-Project 상태
|
||||
*/
|
||||
function mapStatusFromMProject(mProjectStatus) {
|
||||
const statusMap = {
|
||||
'pending_review': 'received',
|
||||
'in_progress': 'in_progress',
|
||||
'completed': 'completed',
|
||||
'disposed': 'closed',
|
||||
};
|
||||
return statusMap[mProjectStatus] || 'received';
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project 연결 테스트
|
||||
* @returns {Promise<{success: boolean, message: string}>}
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { success: false, message: 'M-Project 인증 실패' };
|
||||
}
|
||||
|
||||
// /api/auth/me로 연결 테스트
|
||||
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
message: `M-Project 연결 성공 (사용자: ${user.username})`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `M-Project 연결 실패: ${response.status}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `M-Project 연결 오류: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendToMProject,
|
||||
getIssueStatus,
|
||||
mapStatusFromMProject,
|
||||
testConnection,
|
||||
mapCategoryToMProject,
|
||||
};
|
||||
@@ -58,10 +58,8 @@ async function getCurrentWeather(nx = WEATHER_API.defaultLocation.nx, ny = WEATH
|
||||
}
|
||||
|
||||
try {
|
||||
// 현재 시간 기준으로 base_date, base_time 계산
|
||||
const now = new Date();
|
||||
const baseDate = formatDate(now);
|
||||
const baseTime = getBaseTime(now);
|
||||
// 한국 시간 기준으로 base_date, base_time 계산
|
||||
const { baseDate, baseTime } = getBaseDateTime();
|
||||
|
||||
logger.info('날씨 API 호출', { baseDate, baseTime, nx, ny });
|
||||
|
||||
@@ -322,7 +320,45 @@ function getDefaultWeatherData() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷 (YYYYMMDD)
|
||||
* 기상청 API용 기준 날짜/시간 계산
|
||||
* 한국 시간(KST, UTC+9) 기준으로 계산
|
||||
* 매시간 정각에 생성되고 10분 후에 제공됨
|
||||
* @returns {{ baseDate: string, baseTime: string }}
|
||||
*/
|
||||
function getBaseDateTime() {
|
||||
// 한국 시간으로 변환 (UTC + 9시간)
|
||||
const now = new Date();
|
||||
const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로
|
||||
const kstDate = new Date(now.getTime() + kstOffset);
|
||||
|
||||
let year = kstDate.getUTCFullYear();
|
||||
let month = kstDate.getUTCMonth() + 1;
|
||||
let day = kstDate.getUTCDate();
|
||||
let hours = kstDate.getUTCHours();
|
||||
let minutes = kstDate.getUTCMinutes();
|
||||
|
||||
// 10분 이전이면 이전 시간 데이터 사용
|
||||
if (minutes < 10) {
|
||||
hours = hours - 1;
|
||||
if (hours < 0) {
|
||||
hours = 23;
|
||||
// 전날로 변경
|
||||
const prevDay = new Date(kstDate.getTime() - 24 * 60 * 60 * 1000);
|
||||
year = prevDay.getUTCFullYear();
|
||||
month = prevDay.getUTCMonth() + 1;
|
||||
day = prevDay.getUTCDate();
|
||||
}
|
||||
}
|
||||
|
||||
const baseDate = `${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}`;
|
||||
const baseTime = String(hours).padStart(2, '0') + '00';
|
||||
|
||||
return { baseDate, baseTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷 (YYYYMMDD) - 호환성 유지용
|
||||
* @deprecated getBaseDateTime() 사용 권장
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
@@ -332,14 +368,12 @@ function formatDate(date) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 초단기실황 API용 기준시간 계산
|
||||
* 매시간 정각에 생성되고 10분 후에 제공됨
|
||||
* @deprecated getBaseDateTime() 사용 권장
|
||||
*/
|
||||
function getBaseTime(date) {
|
||||
let hours = date.getHours();
|
||||
let minutes = date.getMinutes();
|
||||
|
||||
// 10분 이전이면 이전 시간 데이터 사용
|
||||
if (minutes < 10) {
|
||||
hours = hours - 1;
|
||||
if (hours < 0) hours = 23;
|
||||
|
||||
@@ -274,6 +274,7 @@ const getSummaryService = async (year, month) => {
|
||||
|
||||
/**
|
||||
* 작업 보고서의 부적합 원인 목록 조회
|
||||
* - error_type_id 또는 issue_report_id 중 하나로 연결
|
||||
*/
|
||||
const getReportDefectsService = async (reportId) => {
|
||||
const db = await getDb();
|
||||
@@ -283,13 +284,22 @@ const getReportDefectsService = async (reportId) => {
|
||||
d.defect_id,
|
||||
d.report_id,
|
||||
d.error_type_id,
|
||||
d.issue_report_id,
|
||||
d.defect_hours,
|
||||
d.note,
|
||||
d.created_at,
|
||||
et.name as error_type_name,
|
||||
et.severity
|
||||
et.severity as error_severity,
|
||||
ir.content as issue_content,
|
||||
ir.status as issue_status,
|
||||
irc.category_name as issue_category_name,
|
||||
irc.severity as issue_severity,
|
||||
iri.item_name as issue_item_name
|
||||
FROM work_report_defects d
|
||||
JOIN error_types et ON d.error_type_id = et.id
|
||||
LEFT JOIN error_types et ON d.error_type_id = et.id
|
||||
LEFT JOIN work_issue_reports ir ON d.issue_report_id = ir.report_id
|
||||
LEFT JOIN issue_report_categories irc ON ir.category_id = irc.category_id
|
||||
LEFT JOIN issue_report_items iri ON ir.item_id = iri.item_id
|
||||
WHERE d.report_id = ?
|
||||
ORDER BY d.created_at
|
||||
`, [reportId]);
|
||||
@@ -303,6 +313,9 @@ const getReportDefectsService = async (reportId) => {
|
||||
|
||||
/**
|
||||
* 부적합 원인 저장 (전체 교체)
|
||||
* - error_type_id 또는 issue_report_id 중 하나 사용 가능
|
||||
* - issue_report_id: 신고된 이슈와 연결
|
||||
* - error_type_id: 기존 오류 유형 (레거시)
|
||||
*/
|
||||
const saveReportDefectsService = async (reportId, defects) => {
|
||||
const db = await getDb();
|
||||
@@ -315,10 +328,19 @@ const saveReportDefectsService = async (reportId, defects) => {
|
||||
// 새 부적합 원인 추가
|
||||
if (defects && defects.length > 0) {
|
||||
for (const defect of defects) {
|
||||
// issue_report_id > category_id/item_id > error_type_id 순으로 우선
|
||||
await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [reportId, defect.error_type_id, defect.defect_hours || 0, defect.note || null]);
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, issue_report_id, category_id, item_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
reportId,
|
||||
(defect.issue_report_id || defect.category_id) ? null : (defect.error_type_id || null),
|
||||
defect.issue_report_id || null,
|
||||
defect.category_id || null,
|
||||
defect.item_id || null,
|
||||
defect.defect_hours || 0,
|
||||
defect.note || null
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +349,11 @@ const saveReportDefectsService = async (reportId, defects) => {
|
||||
? defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0)
|
||||
: 0;
|
||||
|
||||
// 첫 번째 defect의 error_type_id를 대표값으로 (레거시 호환)
|
||||
const firstErrorTypeId = defects && defects.length > 0
|
||||
? (defects.find(d => d.error_type_id)?.error_type_id || null)
|
||||
: null;
|
||||
|
||||
await db.execute(`
|
||||
UPDATE daily_work_reports
|
||||
SET error_hours = ?,
|
||||
@@ -335,7 +362,7 @@ const saveReportDefectsService = async (reportId, defects) => {
|
||||
WHERE id = ?
|
||||
`, [
|
||||
totalErrorHours,
|
||||
defects && defects.length > 0 ? defects[0].error_type_id : null,
|
||||
firstErrorTypeId,
|
||||
totalErrorHours > 0 ? 2 : 1,
|
||||
reportId
|
||||
]);
|
||||
@@ -353,14 +380,21 @@ const saveReportDefectsService = async (reportId, defects) => {
|
||||
|
||||
/**
|
||||
* 부적합 원인 추가 (단일)
|
||||
* - issue_report_id 또는 error_type_id 중 하나 사용
|
||||
*/
|
||||
const addReportDefectService = async (reportId, defectData) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [result] = await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [reportId, defectData.error_type_id, defectData.defect_hours || 0, defectData.note || null]);
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, issue_report_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
reportId,
|
||||
defectData.issue_report_id ? null : (defectData.error_type_id || null),
|
||||
defectData.issue_report_id || null,
|
||||
defectData.defect_hours || 0,
|
||||
defectData.note || null
|
||||
]);
|
||||
|
||||
// 총 부적합 시간 업데이트
|
||||
await updateTotalErrorHours(reportId);
|
||||
@@ -404,6 +438,7 @@ const removeReportDefectService = async (defectId) => {
|
||||
|
||||
/**
|
||||
* 총 부적합 시간 업데이트 헬퍼
|
||||
* - issue_report_id가 있는 경우도 고려
|
||||
*/
|
||||
const updateTotalErrorHours = async (reportId) => {
|
||||
const db = await getDb();
|
||||
@@ -415,9 +450,12 @@ const updateTotalErrorHours = async (reportId) => {
|
||||
|
||||
const totalErrorHours = result[0].total || 0;
|
||||
|
||||
// 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용
|
||||
// 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용 (레거시 호환)
|
||||
// issue_report_id만 있는 경우 error_type_id는 null
|
||||
const [firstDefect] = await db.execute(`
|
||||
SELECT error_type_id FROM work_report_defects WHERE report_id = ? ORDER BY created_at LIMIT 1
|
||||
SELECT error_type_id FROM work_report_defects
|
||||
WHERE report_id = ? AND error_type_id IS NOT NULL
|
||||
ORDER BY created_at LIMIT 1
|
||||
`, [reportId]);
|
||||
|
||||
await db.execute(`
|
||||
|
||||
131
api.hyungi.net/utils/dateUtils.js
Normal file
131
api.hyungi.net/utils/dateUtils.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 날짜/시간 유틸리티 함수
|
||||
*
|
||||
* 중요: 이 프로젝트는 한국(Asia/Seoul, UTC+9) 시간대 기준으로 운영됩니다.
|
||||
* DB에 저장되는 비즈니스 날짜(report_date, session_date 등)는 반드시 한국 시간 기준이어야 합니다.
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-03
|
||||
*/
|
||||
|
||||
const KOREA_TIMEZONE_OFFSET = 9; // UTC+9
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 Date 객체 반환
|
||||
* @returns {Date} 한국 시간 기준 Date 객체
|
||||
*/
|
||||
function getKoreaDate() {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 날짜 문자열 반환
|
||||
* @returns {string} 'YYYY-MM-DD' 형식
|
||||
*/
|
||||
function getKoreaDateString() {
|
||||
const koreaDate = getKoreaDate();
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 datetime 문자열 반환
|
||||
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식 (MySQL DATETIME 호환)
|
||||
*/
|
||||
function getKoreaDatetime() {
|
||||
const koreaDate = getKoreaDate();
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간(KST) 기준 현재 시간 문자열 반환
|
||||
* @returns {string} 'HH:mm:ss' 형식
|
||||
*/
|
||||
function getKoreaTimeString() {
|
||||
const koreaDate = getKoreaDate();
|
||||
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC Date를 한국 시간 datetime 문자열로 변환
|
||||
* @param {Date} date - UTC 기준 Date 객체
|
||||
* @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식
|
||||
*/
|
||||
function toKoreaDatetime(date) {
|
||||
if (!date) return null;
|
||||
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(koreaDate.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC Date를 한국 날짜 문자열로 변환
|
||||
* @param {Date} date - UTC 기준 Date 객체
|
||||
* @returns {string} 'YYYY-MM-DD' 형식
|
||||
*/
|
||||
function toKoreaDateString(date) {
|
||||
if (!date) return null;
|
||||
const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000));
|
||||
const year = koreaDate.getUTCFullYear();
|
||||
const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(koreaDate.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 시간 datetime 문자열을 Date 객체로 변환
|
||||
* @param {string} koreaDatetimeStr - 'YYYY-MM-DD HH:mm:ss' 형식
|
||||
* @returns {Date} UTC 기준 Date 객체
|
||||
*/
|
||||
function fromKoreaDatetime(koreaDatetimeStr) {
|
||||
if (!koreaDatetimeStr) return null;
|
||||
// 한국 시간 문자열을 UTC로 변환
|
||||
const date = new Date(koreaDatetimeStr + '+09:00');
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도 반환 (한국 시간 기준)
|
||||
* @returns {number} 현재 연도
|
||||
*/
|
||||
function getKoreaYear() {
|
||||
return getKoreaDate().getUTCFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 반환 (한국 시간 기준, 1-12)
|
||||
* @returns {number} 현재 월 (1-12)
|
||||
*/
|
||||
function getKoreaMonth() {
|
||||
return getKoreaDate().getUTCMonth() + 1;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KOREA_TIMEZONE_OFFSET,
|
||||
getKoreaDate,
|
||||
getKoreaDateString,
|
||||
getKoreaDatetime,
|
||||
getKoreaTimeString,
|
||||
toKoreaDatetime,
|
||||
toKoreaDateString,
|
||||
fromKoreaDatetime,
|
||||
getKoreaYear,
|
||||
getKoreaMonth,
|
||||
};
|
||||
Reference in New Issue
Block a user