From 1fd6253fbca577a7c8e917c084c4129c01c2b475 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 30 Mar 2026 13:26:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(sprint-004):=20=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=C2=B7=ED=99=95=EC=9D=B8=C2=B7=EC=A0=95?= =?UTF-8?q?=EC=82=B0=20=EB=B0=B1=EC=97=94=EB=93=9C=20(Section=20A)=20+=20M?= =?UTF-8?q?ock=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../monthlyComparisonController.js | 359 ++++++++++++++++++ ...0330_create_monthly_work_confirmations.sql | 21 + system1-factory/api/index.js | 2 +- .../api/models/monthlyComparisonModel.js | 209 ++++++++++ system1-factory/api/package.json | 1 + system1-factory/api/routes.js | 1 + .../api/routes/monthlyComparisonRoutes.js | 32 ++ system1-factory/web/js/monthly-comparison.js | 2 +- 8 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 system1-factory/api/controllers/monthlyComparisonController.js create mode 100644 system1-factory/api/db/migrations/20260330_create_monthly_work_confirmations.sql create mode 100644 system1-factory/api/models/monthlyComparisonModel.js create mode 100644 system1-factory/api/routes/monthlyComparisonRoutes.js diff --git a/system1-factory/api/controllers/monthlyComparisonController.js b/system1-factory/api/controllers/monthlyComparisonController.js new file mode 100644 index 0000000..adc3abf --- /dev/null +++ b/system1-factory/api/controllers/monthlyComparisonController.js @@ -0,0 +1,359 @@ +// 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; diff --git a/system1-factory/api/db/migrations/20260330_create_monthly_work_confirmations.sql b/system1-factory/api/db/migrations/20260330_create_monthly_work_confirmations.sql new file mode 100644 index 0000000..91f9b1a --- /dev/null +++ b/system1-factory/api/db/migrations/20260330_create_monthly_work_confirmations.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS monthly_work_confirmations ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '작업자 user_id (workers.user_id)', + year INT NOT NULL, + month INT NOT NULL, + status ENUM('pending', 'confirmed', 'rejected') NOT NULL DEFAULT 'pending', + total_work_days INT DEFAULT 0 COMMENT '총 근무일수', + total_work_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 근무시간', + total_overtime_hours DECIMAL(6,2) DEFAULT 0 COMMENT '총 연장근로시간', + vacation_days DECIMAL(4,2) DEFAULT 0 COMMENT '휴가 일수', + mismatch_count INT DEFAULT 0 COMMENT '불일치 건수', + reject_reason TEXT NULL COMMENT '반려 사유', + confirmed_at TIMESTAMP NULL COMMENT '확인 일시', + rejected_at TIMESTAMP NULL COMMENT '반려 일시', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_user_year_month (user_id, year, month), + KEY idx_year_month (year, month), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='월간 근무 확인 (승인/반려)' diff --git a/system1-factory/api/index.js b/system1-factory/api/index.js index 9f66fac..9c66a98 100644 --- a/system1-factory/api/index.js +++ b/system1-factory/api/index.js @@ -48,7 +48,7 @@ async function runStartupMigrations() { const fs = require('fs'); const path = require('path'); const db = await getDb(); - const migrationFiles = ['20260326_schedule_extensions.sql']; + const migrationFiles = ['20260326_schedule_extensions.sql', '20260330_create_monthly_work_confirmations.sql']; for (const file of migrationFiles) { const sqlPath = path.join(__dirname, 'db', 'migrations', file); if (!fs.existsSync(sqlPath)) continue; diff --git a/system1-factory/api/models/monthlyComparisonModel.js b/system1-factory/api/models/monthlyComparisonModel.js new file mode 100644 index 0000000..8168c60 --- /dev/null +++ b/system1-factory/api/models/monthlyComparisonModel.js @@ -0,0 +1,209 @@ +// models/monthlyComparisonModel.js — 월간 비교·확인·정산 +const { getDb } = require('../dbPool'); + +const MonthlyComparisonModel = { + // 1. 작업보고서 일별 합산 + async getWorkReports(userId, year, month) { + const db = await getDb(); + const [rows] = await db.query(` + SELECT + dwr.report_date, + SUM(dwr.work_hours) AS total_hours, + GROUP_CONCAT(DISTINCT p.project_name SEPARATOR ', ') AS project_names, + GROUP_CONCAT(DISTINCT wt.name SEPARATOR ', ') AS work_type_names + FROM daily_work_reports dwr + LEFT JOIN projects p ON dwr.project_id = p.project_id + LEFT JOIN work_types wt ON dwr.work_type_id = wt.id + WHERE dwr.user_id = ? + AND YEAR(dwr.report_date) = ? + AND MONTH(dwr.report_date) = ? + GROUP BY dwr.report_date + ORDER BY dwr.report_date + `, [userId, year, month]); + return rows; + }, + + // 2. 근태관리 일별 기록 + async getAttendanceRecords(userId, year, month) { + const db = await getDb(); + const [rows] = await db.query(` + SELECT + dar.record_date, + dar.total_work_hours, + dar.attendance_type_id, + dar.vacation_type_id, + dar.status, + dar.is_present, + dar.notes, + wat.type_name AS attendance_type_name, + vt.type_name AS vacation_type_name + FROM daily_attendance_records dar + LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id + LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id + WHERE dar.user_id = ? + AND YEAR(dar.record_date) = ? + AND MONTH(dar.record_date) = ? + ORDER BY dar.record_date + `, [userId, year, month]); + return rows; + }, + + // 3. 확인 상태 조회 + async getConfirmation(userId, year, month) { + const db = await getDb(); + const [rows] = await db.query( + 'SELECT * FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?', + [userId, year, month] + ); + return rows[0] || null; + }, + + // 4. 확인 UPSERT + 반려 시 알림 (단일 트랜잭션) + async upsertConfirmation(data, notificationData) { + const db = await getDb(); + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + + // 기존 상태 체크 + const [existing] = await conn.query( + 'SELECT id, status FROM monthly_work_confirmations WHERE user_id = ? AND year = ? AND month = ?', + [data.user_id, data.year, data.month] + ); + if (existing.length > 0 && existing[0].status === 'confirmed') { + await conn.rollback(); + return { error: '이미 확인된 내역은 변경할 수 없습니다.' }; + } + + // UPSERT + const [result] = await conn.query(` + INSERT INTO monthly_work_confirmations + (user_id, year, month, status, total_work_days, total_work_hours, + total_overtime_hours, vacation_days, mismatch_count, reject_reason, + confirmed_at, rejected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + total_work_days = VALUES(total_work_days), + total_work_hours = VALUES(total_work_hours), + total_overtime_hours = VALUES(total_overtime_hours), + vacation_days = VALUES(vacation_days), + mismatch_count = VALUES(mismatch_count), + reject_reason = VALUES(reject_reason), + confirmed_at = VALUES(confirmed_at), + rejected_at = VALUES(rejected_at) + `, [ + data.user_id, data.year, data.month, data.status, + data.total_work_days || 0, data.total_work_hours || 0, + data.total_overtime_hours || 0, data.vacation_days || 0, + data.mismatch_count || 0, data.reject_reason || null, + data.status === 'confirmed' ? new Date() : null, + data.status === 'rejected' ? new Date() : null + ]); + + const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null); + + // 반려 시 알림 생성 + if (data.status === 'rejected' && notificationData && confirmationId) { + const { recipients, title, message, linkUrl, createdBy } = notificationData; + for (const recipientId of recipients) { + await conn.query(` + INSERT INTO notifications + (user_id, type, title, message, link_url, reference_type, reference_id, is_read, created_by) + VALUES (?, 'system', ?, ?, ?, 'monthly_work_confirmation', ?, 0, ?) + `, [recipientId, title, message, linkUrl, confirmationId, createdBy]); + } + } + + await conn.commit(); + return { id: confirmationId, status: data.status }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }, + + // 5. 전체 작업자 확인 현황 + async getAllStatus(year, month, departmentId) { + const db = await getDb(); + let sql = ` + SELECT + w.user_id, w.worker_name, w.job_type, + COALESCE(d.department_name, '미배정') AS department_name, + COALESCE(mwc.status, 'pending') AS status, + mwc.confirmed_at, mwc.rejected_at, mwc.reject_reason, + mwc.total_work_days, mwc.total_work_hours, + mwc.total_overtime_hours, mwc.vacation_days, mwc.mismatch_count + FROM workers w + LEFT JOIN departments d ON w.department_id = d.department_id + LEFT JOIN monthly_work_confirmations mwc + ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ? + WHERE w.status = 'active' + `; + const params = [year, month]; + if (departmentId) { + sql += ' AND w.department_id = ?'; + params.push(departmentId); + } + sql += ' ORDER BY d.department_name, w.worker_name'; + + const [rows] = await db.query(sql, params); + return rows; + }, + + // 6. 지원팀 사용자 목록 (알림 수신자) + async getSupportTeamUsers() { + const db = await getDb(); + const [rows] = await db.query( + "SELECT user_id FROM sso_users WHERE role IN ('support_team', 'admin', 'system') AND is_active = 1" + ); + return rows.map(r => r.user_id); + }, + + // 7. 엑셀용 전체 일별 상세 + async getExcelData(year, month) { + const db = await getDb(); + const [rows] = await db.query(` + SELECT + w.worker_name, d.department_name, w.job_type, + dar.record_date, + dar.total_work_hours AS attendance_hours, + wat.type_name AS attendance_type_name, + vt.type_name AS vacation_type_name, + COALESCE(wr.total_hours, 0) AS report_hours + FROM workers w + LEFT JOIN departments d ON w.department_id = d.department_id + LEFT JOIN daily_attendance_records dar + ON w.user_id = dar.user_id + AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ? + LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id + LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id + LEFT JOIN ( + SELECT user_id, report_date, SUM(work_hours) AS total_hours + FROM daily_work_reports + WHERE YEAR(report_date) = ? AND MONTH(report_date) = ? + GROUP BY user_id, report_date + ) wr ON w.user_id = wr.user_id AND dar.record_date = wr.report_date + WHERE w.status = 'active' + ORDER BY d.department_name, w.worker_name, dar.record_date + `, [year, month, year, month]); + return rows; + }, + + // 8. 작업자 정보 + async getWorkerInfo(userId) { + const db = await getDb(); + const [rows] = await db.query(` + SELECT w.user_id, w.worker_name, w.job_type, + COALESCE(d.department_name, '미배정') AS department_name + FROM workers w + LEFT JOIN departments d ON w.department_id = d.department_id + WHERE w.user_id = ? + `, [userId]); + return rows[0] || null; + } +}; + +module.exports = MonthlyComparisonModel; diff --git a/system1-factory/api/package.json b/system1-factory/api/package.json index efe43f1..1f67ddf 100644 --- a/system1-factory/api/package.json +++ b/system1-factory/api/package.json @@ -26,6 +26,7 @@ "compression": "^1.8.1", "cors": "^2.8.5", "dotenv": "^16.4.5", + "exceljs": "^4.4.0", "express": "^4.18.2", "express-rate-limit": "^7.5.1", "express-validator": "^7.2.1", diff --git a/system1-factory/api/routes.js b/system1-factory/api/routes.js index 6dd9ac2..da4c546 100644 --- a/system1-factory/api/routes.js +++ b/system1-factory/api/routes.js @@ -154,6 +154,7 @@ function setupRoutes(app) { app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리 app.use('/api/tbm', tbmRoutes); // TBM 시스템 app.use('/api/proxy-input', require('./routes/proxyInputRoutes')); // 대리입력 + 일별현황 + app.use('/api/monthly-comparison', require('./routes/monthlyComparisonRoutes')); // 월간 비교·확인·정산 app.use('/api/dashboard', require('./routes/dashboardRoutes')); // 대시보드 개인 요약 app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/departments', departmentRoutes); // 부서 관리 diff --git a/system1-factory/api/routes/monthlyComparisonRoutes.js b/system1-factory/api/routes/monthlyComparisonRoutes.js new file mode 100644 index 0000000..4d50780 --- /dev/null +++ b/system1-factory/api/routes/monthlyComparisonRoutes.js @@ -0,0 +1,32 @@ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../controllers/monthlyComparisonController'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); + +const ADMIN_ROLES = ['support_team', 'admin', 'system']; +function requireSupportTeam(req, res, next) { + const role = (req.user?.role || '').toLowerCase(); + if (!ADMIN_ROLES.includes(role)) { + return res.status(403).json({ success: false, message: '지원팀 이상 권한이 필요합니다.' }); + } + next(); +} + +// 본인 월간 비교 +router.get('/my-records', ctrl.getMyRecords); + +// 특정 작업자 비교 (내부에서 권한 체크) +router.get('/records', ctrl.getRecords); + +// 확인/반려 +router.post('/confirm', ctrl.confirm); + +// 전체 현황 (support_team+) +router.get('/all-status', requireSupportTeam, ctrl.getAllStatus); + +// 엑셀 다운로드 (support_team+) +router.get('/export', requireSupportTeam, ctrl.exportExcel); + +module.exports = router; diff --git a/system1-factory/web/js/monthly-comparison.js b/system1-factory/web/js/monthly-comparison.js index 6775781..d39a932 100644 --- a/system1-factory/web/js/monthly-comparison.js +++ b/system1-factory/web/js/monthly-comparison.js @@ -4,7 +4,7 @@ */ // ===== Mock ===== -const MOCK_ENABLED = true; +const MOCK_ENABLED = false; const MOCK_MY_RECORDS = { success: true,