- 부적합 API 호출 형식 수정 (카테고리/아이템 추가 시) - 부적합 저장 시 내부 플래그 제거 후 백엔드 전송 - 기본 부적합 객체 구조 수정 (category_id, item_id 추가) - 날씨 API 시간대 수정 (UTC → KST 변환) - 신고 카테고리 관리 페이지 추가 (/pages/admin/issue-categories.html) - 부적합 입력 UI 개선 (대분류→소분류 캐스케이딩 선택) - 저장된 부적합 분리 표시 및 수정/삭제 기능 - 디버깅 로그 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
489 lines
14 KiB
JavaScript
489 lines
14 KiB
JavaScript
/**
|
|
* 작업 보고서 관리 서비스
|
|
*
|
|
* 작업 보고서 CRUD 및 조회 관련 비즈니스 로직 처리
|
|
*
|
|
* @author TK-FB-Project
|
|
* @since 2025-12-11
|
|
*/
|
|
|
|
const workReportModel = require('../models/workReportModel');
|
|
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
|
const logger = require('../utils/logger');
|
|
const { getDb } = require('../dbPool');
|
|
|
|
/**
|
|
* 작업 보고서 생성 (단일 또는 다중)
|
|
*/
|
|
const createWorkReportService = async (reportData) => {
|
|
const reports = Array.isArray(reportData) ? reportData : [reportData];
|
|
|
|
if (reports.length === 0) {
|
|
throw new ValidationError('보고서 데이터가 필요합니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 생성 요청', { count: reports.length });
|
|
|
|
const workReport_ids = [];
|
|
|
|
try {
|
|
for (const report of reports) {
|
|
const id = await new Promise((resolve, reject) => {
|
|
workReportModel.create(report, (err, insertId) => {
|
|
if (err) reject(err);
|
|
else resolve(insertId);
|
|
});
|
|
});
|
|
workReport_ids.push(id);
|
|
}
|
|
|
|
logger.info('작업 보고서 생성 성공', {
|
|
count: workReport_ids.length,
|
|
ids: workReport_ids
|
|
});
|
|
|
|
return { workReport_ids };
|
|
} catch (error) {
|
|
logger.error('작업 보고서 생성 실패', {
|
|
count: reports.length,
|
|
error: error.message
|
|
});
|
|
throw new DatabaseError('작업 보고서 생성 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 날짜별 작업 보고서 조회
|
|
*/
|
|
const getWorkReportsByDateService = async (date) => {
|
|
if (!date) {
|
|
throw new ValidationError('날짜가 필요합니다', {
|
|
required: ['date'],
|
|
received: { date }
|
|
});
|
|
}
|
|
|
|
logger.info('작업 보고서 날짜별 조회 요청', { date });
|
|
|
|
try {
|
|
const rows = await new Promise((resolve, reject) => {
|
|
workReportModel.getAllByDate(date, (err, data) => {
|
|
if (err) reject(err);
|
|
else resolve(data);
|
|
});
|
|
});
|
|
|
|
logger.info('작업 보고서 조회 성공', { date, count: rows.length });
|
|
|
|
return rows;
|
|
} catch (error) {
|
|
logger.error('작업 보고서 조회 실패', { date, error: error.message });
|
|
throw new DatabaseError('작업 보고서 조회 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 기간별 작업 보고서 조회
|
|
*/
|
|
const getWorkReportsInRangeService = async (start, end) => {
|
|
if (!start || !end) {
|
|
throw new ValidationError('시작일과 종료일이 필요합니다', {
|
|
required: ['start', 'end'],
|
|
received: { start, end }
|
|
});
|
|
}
|
|
|
|
logger.info('작업 보고서 기간별 조회 요청', { start, end });
|
|
|
|
try {
|
|
const rows = await new Promise((resolve, reject) => {
|
|
workReportModel.getByRange(start, end, (err, data) => {
|
|
if (err) reject(err);
|
|
else resolve(data);
|
|
});
|
|
});
|
|
|
|
logger.info('작업 보고서 조회 성공', { start, end, count: rows.length });
|
|
|
|
return rows;
|
|
} catch (error) {
|
|
logger.error('작업 보고서 조회 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('작업 보고서 조회 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 단일 작업 보고서 조회
|
|
*/
|
|
const getWorkReportByIdService = async (id) => {
|
|
if (!id) {
|
|
throw new ValidationError('보고서 ID가 필요합니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 조회 요청', { report_id: id });
|
|
|
|
try {
|
|
const row = await new Promise((resolve, reject) => {
|
|
workReportModel.getById(id, (err, data) => {
|
|
if (err) reject(err);
|
|
else resolve(data);
|
|
});
|
|
});
|
|
|
|
if (!row) {
|
|
logger.warn('작업 보고서를 찾을 수 없음', { report_id: id });
|
|
throw new NotFoundError('작업 보고서를 찾을 수 없습니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 조회 성공', { report_id: id });
|
|
|
|
return row;
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
|
|
logger.error('작업 보고서 조회 실패', { report_id: id, error: error.message });
|
|
throw new DatabaseError('작업 보고서 조회 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 작업 보고서 수정
|
|
*/
|
|
const updateWorkReportService = async (id, updateData) => {
|
|
if (!id) {
|
|
throw new ValidationError('보고서 ID가 필요합니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 수정 요청', { report_id: id });
|
|
|
|
try {
|
|
const changes = await new Promise((resolve, reject) => {
|
|
workReportModel.update(id, updateData, (err, affectedRows) => {
|
|
if (err) reject(err);
|
|
else resolve(affectedRows);
|
|
});
|
|
});
|
|
|
|
if (changes === 0) {
|
|
logger.warn('작업 보고서를 찾을 수 없거나 변경사항 없음', { report_id: id });
|
|
throw new NotFoundError('작업 보고서를 찾을 수 없습니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 수정 성공', { report_id: id, changes });
|
|
|
|
return { changes };
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
|
|
logger.error('작업 보고서 수정 실패', { report_id: id, error: error.message });
|
|
throw new DatabaseError('작업 보고서 수정 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 작업 보고서 삭제
|
|
*/
|
|
const removeWorkReportService = async (id) => {
|
|
if (!id) {
|
|
throw new ValidationError('보고서 ID가 필요합니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 삭제 요청', { report_id: id });
|
|
|
|
try {
|
|
const changes = await new Promise((resolve, reject) => {
|
|
workReportModel.remove(id, (err, affectedRows) => {
|
|
if (err) reject(err);
|
|
else resolve(affectedRows);
|
|
});
|
|
});
|
|
|
|
if (changes === 0) {
|
|
logger.warn('작업 보고서를 찾을 수 없음', { report_id: id });
|
|
throw new NotFoundError('작업 보고서를 찾을 수 없습니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 삭제 성공', { report_id: id, changes });
|
|
|
|
return { changes };
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
|
|
logger.error('작업 보고서 삭제 실패', { report_id: id, error: error.message });
|
|
throw new DatabaseError('작업 보고서 삭제 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 월간 요약 조회
|
|
*/
|
|
const getSummaryService = async (year, month) => {
|
|
if (!year || !month) {
|
|
throw new ValidationError('연도와 월이 필요합니다', {
|
|
required: ['year', 'month'],
|
|
received: { year, month }
|
|
});
|
|
}
|
|
|
|
const start = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-01`;
|
|
const end = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-31`;
|
|
|
|
logger.info('작업 보고서 월간 요약 조회 요청', { year, month, start, end });
|
|
|
|
try {
|
|
const rows = await new Promise((resolve, reject) => {
|
|
workReportModel.getByRange(start, end, (err, data) => {
|
|
if (err) reject(err);
|
|
else resolve(data);
|
|
});
|
|
});
|
|
|
|
if (!rows || rows.length === 0) {
|
|
logger.warn('월간 요약 데이터 없음', { year, month });
|
|
throw new NotFoundError('해당 기간의 작업 보고서가 없습니다');
|
|
}
|
|
|
|
logger.info('작업 보고서 월간 요약 조회 성공', {
|
|
year,
|
|
month,
|
|
count: rows.length
|
|
});
|
|
|
|
return rows;
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
|
|
logger.error('작업 보고서 월간 요약 조회 실패', {
|
|
year,
|
|
month,
|
|
error: error.message
|
|
});
|
|
throw new DatabaseError('월간 요약 조회 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
// ========== 부적합 원인 관리 서비스 ==========
|
|
|
|
/**
|
|
* 작업 보고서의 부적합 원인 목록 조회
|
|
* - error_type_id 또는 issue_report_id 중 하나로 연결
|
|
*/
|
|
const getReportDefectsService = async (reportId) => {
|
|
const db = await getDb();
|
|
try {
|
|
const [rows] = await db.execute(`
|
|
SELECT
|
|
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 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
|
|
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]);
|
|
|
|
return rows;
|
|
} catch (error) {
|
|
logger.error('부적합 원인 조회 실패', { reportId, error: error.message });
|
|
throw new DatabaseError('부적합 원인 조회 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 부적합 원인 저장 (전체 교체)
|
|
* - error_type_id 또는 issue_report_id 중 하나 사용 가능
|
|
* - issue_report_id: 신고된 이슈와 연결
|
|
* - error_type_id: 기존 오류 유형 (레거시)
|
|
*/
|
|
const saveReportDefectsService = async (reportId, defects) => {
|
|
const db = await getDb();
|
|
try {
|
|
await db.query('START TRANSACTION');
|
|
|
|
// 기존 부적합 원인 삭제
|
|
await db.execute('DELETE FROM work_report_defects WHERE report_id = ?', [reportId]);
|
|
|
|
// 새 부적합 원인 추가
|
|
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, 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
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 총 부적합 시간 계산 및 daily_work_reports 업데이트
|
|
const totalErrorHours = 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 = ?,
|
|
error_type_id = ?,
|
|
work_status_id = ?
|
|
WHERE id = ?
|
|
`, [
|
|
totalErrorHours,
|
|
firstErrorTypeId,
|
|
totalErrorHours > 0 ? 2 : 1,
|
|
reportId
|
|
]);
|
|
|
|
await db.query('COMMIT');
|
|
|
|
logger.info('부적합 원인 저장 성공', { reportId, count: defects?.length || 0 });
|
|
return { success: true, count: defects?.length || 0, totalErrorHours };
|
|
} catch (error) {
|
|
await db.query('ROLLBACK');
|
|
logger.error('부적합 원인 저장 실패', { reportId, error: error.message });
|
|
throw new DatabaseError('부적합 원인 저장 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 부적합 원인 추가 (단일)
|
|
* - 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, 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);
|
|
|
|
logger.info('부적합 원인 추가 성공', { reportId, defectId: result.insertId });
|
|
return { success: true, defect_id: result.insertId };
|
|
} catch (error) {
|
|
logger.error('부적합 원인 추가 실패', { reportId, error: error.message });
|
|
throw new DatabaseError('부적합 원인 추가 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 부적합 원인 삭제
|
|
*/
|
|
const removeReportDefectService = async (defectId) => {
|
|
const db = await getDb();
|
|
try {
|
|
// report_id 먼저 조회
|
|
const [defect] = await db.execute('SELECT report_id FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
|
if (defect.length === 0) {
|
|
throw new NotFoundError('부적합 원인을 찾을 수 없습니다');
|
|
}
|
|
|
|
const reportId = defect[0].report_id;
|
|
|
|
// 삭제
|
|
await db.execute('DELETE FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
|
|
|
// 총 부적합 시간 업데이트
|
|
await updateTotalErrorHours(reportId);
|
|
|
|
logger.info('부적합 원인 삭제 성공', { defectId, reportId });
|
|
return { success: true };
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
logger.error('부적합 원인 삭제 실패', { defectId, error: error.message });
|
|
throw new DatabaseError('부적합 원인 삭제 중 오류가 발생했습니다');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 총 부적합 시간 업데이트 헬퍼
|
|
* - issue_report_id가 있는 경우도 고려
|
|
*/
|
|
const updateTotalErrorHours = async (reportId) => {
|
|
const db = await getDb();
|
|
const [result] = await db.execute(`
|
|
SELECT COALESCE(SUM(defect_hours), 0) as total
|
|
FROM work_report_defects
|
|
WHERE report_id = ?
|
|
`, [reportId]);
|
|
|
|
const totalErrorHours = result[0].total || 0;
|
|
|
|
// 첫 번째 부적합 원인의 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 = ? AND error_type_id IS NOT NULL
|
|
ORDER BY created_at LIMIT 1
|
|
`, [reportId]);
|
|
|
|
await db.execute(`
|
|
UPDATE daily_work_reports
|
|
SET error_hours = ?,
|
|
error_type_id = ?,
|
|
work_status_id = ?
|
|
WHERE id = ?
|
|
`, [
|
|
totalErrorHours,
|
|
firstDefect.length > 0 ? firstDefect[0].error_type_id : null,
|
|
totalErrorHours > 0 ? 2 : 1,
|
|
reportId
|
|
]);
|
|
};
|
|
|
|
module.exports = {
|
|
createWorkReportService,
|
|
getWorkReportsByDateService,
|
|
getWorkReportsInRangeService,
|
|
getWorkReportByIdService,
|
|
updateWorkReportService,
|
|
removeWorkReportService,
|
|
getSummaryService,
|
|
// 부적합 원인 관리
|
|
getReportDefectsService,
|
|
saveReportDefectsService,
|
|
addReportDefectService,
|
|
removeReportDefectService
|
|
};
|