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:
Hyungi Ahn
2026-02-03 09:31:26 +09:00
parent 4b158de1eb
commit c42c9f4fa3
29 changed files with 7042 additions and 809 deletions

View File

@@ -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
};

View File

@@ -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');
});
};

View File

@@ -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(() => {});
});
};

View File

@@ -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]
);

View File

@@ -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;

View 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,
};

View File

@@ -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;

View File

@@ -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(`

View 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,
};