Files
tk-factory-services/system1-factory/api/controllers/monthlyComparisonController.js
Hyungi Ahn 1fd6253fbc feat(sprint-004): 월간 비교·확인·정산 백엔드 (Section A) + Mock 해제
Backend:
- monthly_work_confirmations 테이블 마이그레이션
- monthlyComparisonModel: 비교 쿼리 8개 (보고서/근태/확인 병렬 조회)
- monthlyComparisonController: 5 API (my-records/records/confirm/all-status/export)
- 일별 7상태 판정 (match/mismatch/report_only/attend_only/vacation/holiday/none)
- 확인/반려 UPSERT + 반려 시 알림 (단일 트랜잭션)
- 엑셀 2시트 (exceljs) + 헤더 스타일 + 불일치/휴가 행 색상
- support_team+ 권한 체크 (all-status, export)
- exceljs 의존성 추가

Frontend:
- monthly-comparison.js MOCK_ENABLED = false (API 연결)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:26:25 +09:00

360 lines
15 KiB
JavaScript

// controllers/monthlyComparisonController.js — 월간 비교·확인·정산
const Model = require('../models/monthlyComparisonModel');
const logger = require('../utils/logger');
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
// 일별 비교 상태 판정
function determineStatus(report, attendance, isHoliday) {
const hasReport = report && report.total_hours > 0;
const hasAttendance = attendance && attendance.total_work_hours > 0;
const isVacation = attendance && attendance.vacation_type_id;
if (isHoliday && !hasReport && !hasAttendance) return 'holiday';
if (isVacation) return 'vacation';
if (!hasReport && !hasAttendance) return 'none';
if (hasReport && !hasAttendance) return 'report_only';
if (!hasReport && hasAttendance) return 'attend_only';
const diff = Math.abs(report.total_hours - attendance.total_work_hours);
return diff <= 0.5 ? 'match' : 'mismatch';
}
// 날짜별 비교 데이터 생성
async function buildComparisonData(userId, year, month) {
const [reports, attendances, confirmation] = await Promise.all([
Model.getWorkReports(userId, year, month),
Model.getAttendanceRecords(userId, year, month),
Model.getConfirmation(userId, year, month)
]);
// 날짜 맵 생성
const reportMap = {};
reports.forEach(r => {
const key = r.report_date instanceof Date
? r.report_date.toISOString().split('T')[0]
: String(r.report_date).split('T')[0];
reportMap[key] = r;
});
const attendMap = {};
attendances.forEach(a => {
const key = a.record_date instanceof Date
? a.record_date.toISOString().split('T')[0]
: String(a.record_date).split('T')[0];
attendMap[key] = a;
});
// 해당 월의 모든 날짜 생성
const daysInMonth = new Date(year, month, 0).getDate();
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
const dailyRecords = [];
let totalWorkDays = 0, totalWorkHours = 0, totalOvertimeHours = 0;
let vacationDays = 0, mismatchCount = 0;
const mismatchDetails = { hours_diff: 0, missing_report: 0, missing_attendance: 0 };
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month - 1, day);
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayOfWeek = date.getDay();
const isHoliday = dayOfWeek === 0 || dayOfWeek === 6;
const report = reportMap[dateStr] || null;
const attend = attendMap[dateStr] || null;
const status = determineStatus(report, attend, isHoliday);
let hoursDiff = 0;
if (report && attend && report.total_hours && attend.total_work_hours) {
hoursDiff = parseFloat((report.total_hours - attend.total_work_hours).toFixed(2));
}
// 통계
if (status === 'match' || status === 'mismatch') {
totalWorkDays++;
totalWorkHours += parseFloat(attend?.total_work_hours || report?.total_hours || 0);
}
if (status === 'report_only') { totalWorkDays++; totalWorkHours += parseFloat(report.total_hours || 0); }
if (status === 'attend_only') { totalWorkDays++; totalWorkHours += parseFloat(attend.total_work_hours || 0); }
if (status === 'vacation') { vacationDays++; }
if (status === 'mismatch') { mismatchCount++; mismatchDetails.hours_diff++; }
if (status === 'report_only') { mismatchCount++; mismatchDetails.missing_attendance++; }
if (status === 'attend_only') { mismatchCount++; mismatchDetails.missing_report++; }
// 연장근로: 8h 초과분
if (attend && attend.total_work_hours > 8) {
totalOvertimeHours += parseFloat(attend.total_work_hours) - 8;
}
dailyRecords.push({
date: dateStr,
day_of_week: DAYS_KR[dayOfWeek],
is_holiday: isHoliday,
work_report: report ? {
total_hours: parseFloat(report.total_hours),
entries: [{ project_name: report.project_names || '', work_type: report.work_type_names || '', hours: parseFloat(report.total_hours) }]
} : null,
attendance: attend ? {
total_work_hours: parseFloat(attend.total_work_hours),
attendance_type: attend.attendance_type_name || '',
vacation_type: attend.vacation_type_name || null
} : null,
status,
hours_diff: hoursDiff
});
}
return {
summary: {
total_work_days: totalWorkDays,
total_work_hours: parseFloat(totalWorkHours.toFixed(2)),
total_overtime_hours: parseFloat(totalOvertimeHours.toFixed(2)),
vacation_days: vacationDays,
mismatch_count: mismatchCount,
mismatch_details: mismatchDetails
},
confirmation: confirmation ? {
status: confirmation.status,
confirmed_at: confirmation.confirmed_at,
rejected_at: confirmation.rejected_at,
reject_reason: confirmation.reject_reason
} : { status: 'pending', confirmed_at: null, reject_reason: null },
daily_records: dailyRecords
};
}
const MonthlyComparisonController = {
// GET /my-records
getMyRecords: async (req, res) => {
try {
const userId = req.user.user_id || req.user.id;
const { year, month } = req.query;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const worker = await Model.getWorkerInfo(userId);
const data = await buildComparisonData(userId, parseInt(year), parseInt(month));
res.json({
success: true,
data: {
user: worker || { user_id: userId },
period: { year: parseInt(year), month: parseInt(month) },
...data
}
});
} catch (err) {
logger.error('monthlyComparison getMyRecords error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /records (관리자용)
getRecords: async (req, res) => {
try {
const { year, month, user_id } = req.query;
if (!year || !month || !user_id) return res.status(400).json({ success: false, message: 'year, month, user_id 필수' });
const reqUserId = req.user.user_id || req.user.id;
const targetUserId = parseInt(user_id);
// 본인 아니면 support_team 이상 필요
if (targetUserId !== reqUserId && !ADMIN_ROLES.includes(req.user.role)) {
return res.status(403).json({ success: false, message: '접근 권한이 없습니다.' });
}
const worker = await Model.getWorkerInfo(targetUserId);
const data = await buildComparisonData(targetUserId, parseInt(year), parseInt(month));
res.json({
success: true,
data: {
user: worker || { user_id: targetUserId },
period: { year: parseInt(year), month: parseInt(month) },
...data
}
});
} catch (err) {
logger.error('monthlyComparison getRecords error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// POST /confirm
confirm: async (req, res) => {
try {
const userId = req.user.user_id || req.user.id;
const { year, month, status, reject_reason } = req.body;
if (!year || !month || !status) return res.status(400).json({ success: false, message: 'year, month, status 필수' });
if (!['confirmed', 'rejected'].includes(status)) return res.status(400).json({ success: false, message: "status는 'confirmed' 또는 'rejected'만 허용" });
if (status === 'rejected' && (!reject_reason || !reject_reason.trim())) {
return res.status(400).json({ success: false, message: '반려 사유를 입력해주세요.' });
}
// 요약 통계 계산
const compData = await buildComparisonData(userId, parseInt(year), parseInt(month));
let notificationData = null;
if (status === 'rejected') {
const worker = await Model.getWorkerInfo(userId);
const recipients = await Model.getSupportTeamUsers();
notificationData = {
recipients,
title: '월간 근무 내역 이의 제기',
message: `${worker?.worker_name || '작업자'}(${worker?.department_name || ''})님이 ${year}${month}월 근무 내역에 이의를 제기했습니다. 사유: ${reject_reason}`,
linkUrl: `/pages/attendance/monthly-comparison.html?user_id=${userId}&year=${year}&month=${month}`,
createdBy: userId
};
}
const result = await Model.upsertConfirmation({
user_id: userId, year: parseInt(year), month: parseInt(month), status,
reject_reason: reject_reason || null,
...compData.summary
}, notificationData);
if (result.error) return res.status(409).json({ success: false, message: result.error });
const msg = status === 'confirmed' ? '확인이 완료되었습니다.' : '이의가 접수되었습니다. 지원팀에 알림이 전달됩니다.';
res.json({ success: true, data: result, message: msg });
} catch (err) {
logger.error('monthlyComparison confirm error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /all-status (support_team+)
getAllStatus: async (req, res) => {
try {
const { year, month, department_id } = req.query;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const workers = await Model.getAllStatus(parseInt(year), parseInt(month), department_id ? parseInt(department_id) : null);
let confirmed = 0, pending = 0, rejected = 0;
workers.forEach(w => {
if (w.status === 'confirmed') confirmed++;
else if (w.status === 'rejected') rejected++;
else pending++;
});
res.json({
success: true,
data: {
period: { year: parseInt(year), month: parseInt(month) },
summary: { total_workers: workers.length, confirmed, pending, rejected },
workers: workers.map(w => ({
user_id: w.user_id, worker_name: w.worker_name, job_type: w.job_type,
department_name: w.department_name, status: w.status || 'pending',
confirmed_at: w.confirmed_at, reject_reason: w.reject_reason,
total_work_days: w.total_work_days || 0,
total_work_hours: parseFloat(w.total_work_hours || 0),
total_overtime_hours: parseFloat(w.total_overtime_hours || 0),
mismatch_count: w.mismatch_count || 0
}))
}
});
} catch (err) {
logger.error('monthlyComparison getAllStatus error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// GET /export (support_team+, pending 0명일 때만)
exportExcel: async (req, res) => {
try {
const { year, month } = req.query;
if (!year || !month) return res.status(400).json({ success: false, message: 'year, month 필수' });
const y = parseInt(year), m = parseInt(month);
// pending 체크
const workers = await Model.getAllStatus(y, m);
const pendingCount = workers.filter(w => !w.status || w.status === 'pending').length;
if (pendingCount > 0) {
return res.status(403).json({ success: false, message: `${pendingCount}명이 미확인 상태입니다. 전원 확인 후 다운로드 가능합니다.` });
}
const ExcelJS = require('exceljs');
const workbook = new ExcelJS.Workbook();
// Sheet 1: 월간 근무 현황
const sheet1 = workbook.addWorksheet('월간 근무 현황');
const headerStyle = { fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } }, font: { color: { argb: 'FFFFFFFF' }, bold: true }, alignment: { horizontal: 'center' } };
sheet1.columns = [
{ header: 'No', key: 'no', width: 6 },
{ header: '이름', key: 'name', width: 12 },
{ header: '부서', key: 'dept', width: 12 },
{ header: '직종', key: 'job', width: 10 },
{ header: '총근무일', key: 'days', width: 10 },
{ header: '총근무시간', key: 'hours', width: 12 },
{ header: '연장근로(h)', key: 'overtime', width: 12 },
{ header: '휴가(일)', key: 'vacation', width: 10 },
{ header: '확인상태', key: 'status', width: 10 },
{ header: '확인일시', key: 'confirmed', width: 18 },
];
sheet1.getRow(1).eachCell(cell => { Object.assign(cell, headerStyle); });
workers.forEach((w, i) => {
sheet1.addRow({
no: i + 1, name: w.worker_name, dept: w.department_name, job: w.job_type,
days: w.total_work_days || 0, hours: parseFloat(w.total_work_hours || 0),
overtime: parseFloat(w.total_overtime_hours || 0),
vacation: parseFloat(w.vacation_days || 0),
status: { confirmed: '확인', rejected: '반려', pending: '대기' }[w.status || 'pending'],
confirmed: w.confirmed_at ? new Date(w.confirmed_at).toLocaleString('ko') : '-'
});
});
// Sheet 2: 일별 상세
const sheet2 = workbook.addWorksheet('일별 상세');
sheet2.columns = [
{ header: '이름', key: 'name', width: 12 },
{ header: '날짜', key: 'date', width: 12 },
{ header: '요일', key: 'dow', width: 6 },
{ header: '작업보고서(h)', key: 'report', width: 14 },
{ header: '근태관리(h)', key: 'attend', width: 12 },
{ header: '근태유형', key: 'atype', width: 12 },
{ header: '휴가유형', key: 'vtype', width: 10 },
{ header: '시간차이', key: 'diff', width: 10 },
{ header: '상태', key: 'status', width: 10 },
];
sheet2.getRow(1).eachCell(cell => { Object.assign(cell, headerStyle); });
const detailData = await Model.getExcelData(y, m);
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
detailData.forEach(row => {
if (!row.record_date) return;
const d = new Date(row.record_date);
const diff = (parseFloat(row.report_hours || 0) - parseFloat(row.attendance_hours || 0)).toFixed(2);
const r = sheet2.addRow({
name: row.worker_name, date: row.record_date instanceof Date ? row.record_date.toISOString().split('T')[0] : String(row.record_date).split('T')[0],
dow: DAYS_KR[d.getDay()],
report: parseFloat(row.report_hours || 0),
attend: parseFloat(row.attendance_hours || 0),
atype: row.attendance_type_name || '', vtype: row.vacation_type_name || '',
diff: parseFloat(diff),
status: Math.abs(parseFloat(diff)) > 0.5 ? '불일치' : '일치'
});
if (Math.abs(parseFloat(diff)) > 0.5) {
r.eachCell(cell => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF2CC' } }; });
}
if (row.vacation_type_name) {
r.eachCell(cell => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2EFDA' } }; });
}
});
const filename = encodeURIComponent(`생산팀_월간근무현황_${year}${String(month).padStart(2, '0')}월.xlsx`);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await workbook.xlsx.write(res);
res.end();
} catch (err) {
logger.error('monthlyComparison exportExcel error:', err);
res.status(500).json({ success: false, message: '엑셀 생성 실패' });
}
}
};
module.exports = MonthlyComparisonController;