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:
359
system1-factory/api/controllers/monthlyComparisonController.js
Normal file
359
system1-factory/api/controllers/monthlyComparisonController.js
Normal 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;
|
||||
@@ -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='월간 근무 확인 (승인/반려)'
|
||||
@@ -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;
|
||||
|
||||
209
system1-factory/api/models/monthlyComparisonModel.js
Normal file
209
system1-factory/api/models/monthlyComparisonModel.js
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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); // 부서 관리
|
||||
|
||||
32
system1-factory/api/routes/monthlyComparisonRoutes.js
Normal file
32
system1-factory/api/routes/monthlyComparisonRoutes.js
Normal 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;
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
// ===== Mock =====
|
||||
const MOCK_ENABLED = true;
|
||||
const MOCK_ENABLED = false;
|
||||
|
||||
const MOCK_MY_RECORDS = {
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user