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>
This commit is contained in:
Hyungi Ahn
2026-03-30 13:26:25 +09:00
parent 295928c725
commit 1fd6253fbc
8 changed files with 625 additions and 2 deletions

View File

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

View File

@@ -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='월간 근무 확인 (승인/반려)'

View File

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

View File

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

View File

@@ -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",

View File

@@ -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); // 부서 관리

View File

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

View File

@@ -4,7 +4,7 @@
*/
// ===== Mock =====
const MOCK_ENABLED = true;
const MOCK_ENABLED = false;
const MOCK_MY_RECORDS = {
success: true,