// 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, holidays] = await Promise.all([ Model.getWorkReports(userId, year, month), Model.getAttendanceRecords(userId, year, month), Model.getConfirmation(userId, year, month), Model.getCompanyHolidays(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 || holidays.dateSet.has(dateStr); 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' && attend) { vacationDays += parseFloat(attend.vacation_days) || 1; } 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, holiday_name: holidays.nameMap[dateStr] || (dayOfWeek === 0 || dayOfWeek === 6 ? '주말' : null), 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+, 전원 confirmed일 때만) // 출근부 양식 — 업로드된 템플릿 매칭 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); // 전원 confirmed 체크 const statusList = await Model.getAllStatus(y, m); const notConfirmed = statusList.filter(w => !w.status || w.status !== 'confirmed'); if (notConfirmed.length > 0) { const pendingCount = notConfirmed.filter(w => !w.status || w.status === 'pending').length; const rejectedCount = notConfirmed.filter(w => w.status === 'rejected').length; const parts = []; if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`); if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`); return res.status(403).json({ success: false, message: `${parts.join(', ')} 상태입니다. 전원 확인 완료 후 다운로드 가능합니다.` }); } // 데이터 조회 const { workers, attendance, vacations } = await Model.getExportData(y, m); if (workers.length === 0) { return res.status(404).json({ success: false, message: '해당 월 데이터가 없습니다.' }); } // 근태 맵 구성: { user_id -> { day -> record } } const attendMap = {}; attendance.forEach(a => { const uid = a.user_id; if (!attendMap[uid]) attendMap[uid] = {}; const d = a.record_date instanceof Date ? a.record_date : new Date(a.record_date); const day = d.getDate(); attendMap[uid][day] = a; }); // 연차 맵: { user_id -> { total, used, remaining } } const vacMap = {}; vacations.forEach(v => { vacMap[v.user_id] = v; }); // 월 정보 const daysInMonth = new Date(y, m, 0).getDate(); const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토']; const yy = String(y).slice(-2); const deptName = workers[0]?.department_name || '생산팀'; // 휴가 유형 → 출근부 텍스트 매핑 const VAC_TEXT = { 'ANNUAL': '연차', 'HALF_ANNUAL': '반차', 'ANNUAL_QUARTER': '반반차', 'EARLY_LEAVE': '조퇴', 'SICK': '병가', 'SPECIAL': '경조사' }; const ExcelJS = require('exceljs'); const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet(`${yy}.${m}월 출근부`); // ── 기본 스타일 ── const FONT = { name: '맑은 고딕', size: 12 }; const CENTER = { horizontal: 'center', vertical: 'middle', wrapText: true }; const THIN_BORDER = { top: { style: 'thin' }, bottom: { style: 'thin' }, left: { style: 'thin' }, right: { style: 'thin' } }; const RED_FONT = { ...FONT, color: { argb: 'FFFF0000' } }; // ── 열 폭 설정 ── // A=이름(10), B=담당(6), C~(daysInMonth)=날짜열(5), 총시간(8), 신규(8), 사용(8), 잔여(8), 비고(14) const dayColStart = 3; // C열 = col 3 const dayColEnd = dayColStart + daysInMonth - 1; const colTotal = dayColEnd + 1; // 총시간 const colNewVac = colTotal + 1; // N월 신규 const colUsedVac = colNewVac + 1; // N월 사용 const colRemVac = colUsedVac + 1; // N월 잔여 const colNote1 = colRemVac + 1; // 비고 (2열 병합) const colNote2 = colNote1 + 1; const lastCol = colNote2; ws.getColumn(1).width = 10; // A 이름 ws.getColumn(2).width = 6; // B 담당 for (let c = dayColStart; c <= dayColEnd; c++) ws.getColumn(c).width = 5; ws.getColumn(colTotal).width = 8; ws.getColumn(colNewVac).width = 8; ws.getColumn(colUsedVac).width = 8; ws.getColumn(colRemVac).width = 8; ws.getColumn(colNote1).width = 7; ws.getColumn(colNote2).width = 7; // ── Row 1: 빈 행 (여백) ── ws.getRow(1).height = 10; // ── Row 2: 부서명 ── ws.getRow(2).height = 30; ws.getCell(2, 1).value = '부서명'; ws.getCell(2, 1).font = { ...FONT, bold: true }; ws.getCell(2, 1).alignment = CENTER; ws.mergeCells(2, 2, 2, 5); ws.getCell(2, 2).value = deptName; ws.getCell(2, 2).font = FONT; ws.getCell(2, 2).alignment = { ...CENTER, horizontal: 'left' }; // ── Row 3: 근로기간 ── ws.getRow(3).height = 30; ws.getCell(3, 1).value = '근로기간'; ws.getCell(3, 1).font = { ...FONT, bold: true }; ws.getCell(3, 1).alignment = CENTER; ws.mergeCells(3, 2, 3, 5); ws.getCell(3, 2).value = `${y}년 ${m}월`; ws.getCell(3, 2).font = FONT; ws.getCell(3, 2).alignment = { ...CENTER, horizontal: 'left' }; // ── Row 4-5: 헤더 (병합) ── ws.getRow(4).height = 40; ws.getRow(5).height = 40; // A4:A5 이름 ws.mergeCells(4, 1, 5, 1); ws.getCell(4, 1).value = '이름'; ws.getCell(4, 1).font = { ...FONT, bold: true }; ws.getCell(4, 1).alignment = CENTER; ws.getCell(4, 1).border = THIN_BORDER; // B4:B5 담당 ws.mergeCells(4, 2, 5, 2); ws.getCell(4, 2).value = '담당'; ws.getCell(4, 2).font = { ...FONT, bold: true }; ws.getCell(4, 2).alignment = CENTER; ws.getCell(4, 2).border = THIN_BORDER; // 날짜 헤더: Row4=일자, Row5=요일 for (let day = 1; day <= daysInMonth; day++) { const col = dayColStart + day - 1; const date = new Date(y, m - 1, day); const dow = date.getDay(); const isWeekend = dow === 0 || dow === 6; // Row 4: 날짜 숫자 const cell4 = ws.getCell(4, col); cell4.value = day; cell4.font = isWeekend ? { ...FONT, bold: true, color: { argb: 'FFFF0000' } } : { ...FONT, bold: true }; cell4.alignment = CENTER; cell4.border = THIN_BORDER; // Row 5: 요일 const cell5 = ws.getCell(5, col); cell5.value = DAYS_KR[dow]; cell5.font = isWeekend ? RED_FONT : FONT; cell5.alignment = CENTER; cell5.border = THIN_BORDER; } // 총시간 헤더 (4:5 병합) ws.mergeCells(4, colTotal, 5, colTotal); ws.getCell(4, colTotal).value = '총시간'; ws.getCell(4, colTotal).font = { ...FONT, bold: true }; ws.getCell(4, colTotal).alignment = CENTER; ws.getCell(4, colTotal).border = THIN_BORDER; // 연차 헤더: Row4 = "N월" 병합, Row5 = 신규/사용/잔여 ws.mergeCells(4, colNewVac, 4, colRemVac); ws.getCell(4, colNewVac).value = `${m}월`; ws.getCell(4, colNewVac).font = { ...FONT, bold: true }; ws.getCell(4, colNewVac).alignment = CENTER; ws.getCell(4, colNewVac).border = THIN_BORDER; ws.getCell(5, colNewVac).value = '신규'; ws.getCell(5, colNewVac).font = { ...FONT, bold: true }; ws.getCell(5, colNewVac).alignment = CENTER; ws.getCell(5, colNewVac).border = THIN_BORDER; ws.getCell(5, colUsedVac).value = '사용'; ws.getCell(5, colUsedVac).font = { ...FONT, bold: true }; ws.getCell(5, colUsedVac).alignment = CENTER; ws.getCell(5, colUsedVac).border = THIN_BORDER; ws.getCell(5, colRemVac).value = '잔여'; ws.getCell(5, colRemVac).font = { ...FONT, bold: true }; ws.getCell(5, colRemVac).alignment = CENTER; ws.getCell(5, colRemVac).border = THIN_BORDER; // 비고 헤더 (4:5, 2열 병합) ws.mergeCells(4, colNote1, 5, colNote2); ws.getCell(4, colNote1).value = '비고'; ws.getCell(4, colNote1).font = { ...FONT, bold: true }; ws.getCell(4, colNote1).alignment = CENTER; ws.getCell(4, colNote1).border = THIN_BORDER; // ── 데이터 행 ── const dataStartRow = 6; workers.forEach((worker, idx) => { const row = dataStartRow + idx; ws.getRow(row).height = 60; const userAttend = attendMap[worker.user_id] || {}; const userVac = vacMap[worker.user_id] || { total_days: 0, used_days: 0, remaining_days: 0 }; // A: 이름 const cellName = ws.getCell(row, 1); cellName.value = worker.worker_name; cellName.font = FONT; cellName.alignment = CENTER; cellName.border = THIN_BORDER; // B: 직종 const cellJob = ws.getCell(row, 2); cellJob.value = worker.job_type || ''; cellJob.font = FONT; cellJob.alignment = CENTER; cellJob.border = THIN_BORDER; // C~: 일별 데이터 for (let day = 1; day <= daysInMonth; day++) { const col = dayColStart + day - 1; const cell = ws.getCell(row, col); const date = new Date(y, m - 1, day); const dow = date.getDay(); const isWeekend = dow === 0 || dow === 6; const rec = userAttend[day]; if (rec && rec.vacation_type_code) { // 휴가 cell.value = VAC_TEXT[rec.vacation_type_code] || rec.vacation_type_name || '휴가'; cell.font = FONT; } else if (isWeekend && (!rec || !rec.total_work_hours || parseFloat(rec.total_work_hours) === 0)) { // 주말 + 근무 없음 → 휴무 cell.value = '휴무'; cell.font = FONT; } else if (rec && parseFloat(rec.total_work_hours) > 0) { // 정상 출근 → 근무시간(숫자) cell.value = parseFloat(rec.total_work_hours); cell.numFmt = '0.00'; cell.font = FONT; } else { // 데이터 없음 (평일 미출근 등) cell.value = ''; cell.font = FONT; } cell.alignment = CENTER; cell.border = THIN_BORDER; } // 총시간 = SUM 수식 (C열~마지막 날짜열) const firstDayCol = ws.getColumn(dayColStart).letter; const lastDayCol = ws.getColumn(dayColEnd).letter; const cellTotal = ws.getCell(row, colTotal); cellTotal.value = { formula: `SUM(${firstDayCol}${row}:${lastDayCol}${row})` }; cellTotal.numFmt = '0.00'; cellTotal.font = { ...FONT, bold: true }; cellTotal.alignment = CENTER; cellTotal.border = THIN_BORDER; // 연차 신규 const cellNew = ws.getCell(row, colNewVac); cellNew.value = parseFloat(userVac.total_days) || 0; cellNew.numFmt = '0.0'; cellNew.font = FONT; cellNew.alignment = CENTER; cellNew.border = THIN_BORDER; // 연차 사용 const cellUsed = ws.getCell(row, colUsedVac); cellUsed.value = parseFloat(userVac.used_days) || 0; cellUsed.numFmt = '0.0'; cellUsed.font = FONT; cellUsed.alignment = CENTER; cellUsed.border = THIN_BORDER; // 연차 잔여 = 수식(신규 - 사용) const newCol = ws.getColumn(colNewVac).letter; const usedCol = ws.getColumn(colUsedVac).letter; const cellRem = ws.getCell(row, colRemVac); cellRem.value = { formula: `${newCol}${row}-${usedCol}${row}` }; cellRem.numFmt = '0.0'; cellRem.font = { ...FONT, bold: true }; cellRem.alignment = CENTER; cellRem.border = THIN_BORDER; // 비고 (병합) ws.mergeCells(row, colNote1, row, colNote2); const cellNote = ws.getCell(row, colNote1); cellNote.value = ''; cellNote.font = FONT; cellNote.alignment = CENTER; cellNote.border = THIN_BORDER; }); // ── 응답 ── const filename = encodeURIComponent(`출근부_${y}.${String(m).padStart(2, '0')}_${deptName}.xlsx`); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); await wb.xlsx.write(res); res.end(); } catch (err) { logger.error('monthlyComparison exportExcel error:', err); res.status(500).json({ success: false, message: '엑셀 생성 실패' }); } } }; module.exports = MonthlyComparisonController;