// models/monthlyComparisonModel.js — 월간 비교·확인·정산 const { getDb } = require('../dbPool'); const MonthlyComparisonModel = { // 0. 해당 월의 회사 휴무일 조회 async getCompanyHolidays(year, month) { const db = await getDb(); const [rows] = await db.query( `SELECT holiday_date, holiday_name FROM company_holidays WHERE YEAR(holiday_date) = ? AND MONTH(holiday_date) = ?`, [year, month] ); const dateSet = new Set(); const nameMap = {}; rows.forEach(r => { const d = r.holiday_date instanceof Date ? r.holiday_date.toISOString().split('T')[0] : String(r.holiday_date).split('T')[0]; dateSet.add(d); nameMap[d] = r.holiday_name; }); return { dateSet, nameMap }; }, // 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, vt.deduct_days AS vacation_days 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] ); const currentStatus = existing.length > 0 ? existing[0].status : null; if (currentStatus === 'confirmed') { await conn.rollback(); return { error: '이미 확인된 내역은 변경할 수 없습니다.' }; } // 작업자 확인: review_sent 또는 rejected 상태에서만 가능 if (data.status === 'confirmed' && currentStatus && currentStatus !== 'review_sent' && currentStatus !== 'rejected') { await conn.rollback(); return { error: '관리자 확인요청 후에 확인할 수 있습니다.' }; } // 작업자 수정요청: review_sent 상태에서만 가능 if (data.status === 'change_request' && currentStatus !== 'review_sent') { 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, change_details) 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), change_details = VALUES(change_details) `, [ 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, data.change_details || null ]); const confirmationId = result.insertId || (existing.length > 0 ? existing[0].id : null); // 알림 생성 (반려 또는 수정요청) if (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(); } }, // 관리자: 확인요청 발송 (pending → review_sent) async bulkReviewSend(year, month, userIds, reviewedBy) { const db = await getDb(); const conn = await db.getConnection(); try { await conn.beginTransaction(); // 대상 작업자 결정 (userIds 있으면 단건, 없으면 pending 전체) let targetIds = userIds || []; if (!targetIds.length) { const [pendingRows] = await conn.query( `SELECT DISTINCT w.user_id FROM workers w LEFT JOIN monthly_work_confirmations mwc ON w.user_id = mwc.user_id AND mwc.year = ? AND mwc.month = ? WHERE w.status = 'active' AND w.user_id IS NOT NULL AND (mwc.status IS NULL OR mwc.status = 'pending')`, [year, month] ); targetIds = pendingRows.map(r => r.user_id); } if (!targetIds.length) { await conn.rollback(); return { count: 0, message: '대상 작업자가 없습니다.' }; } // 상태 전환 + 알림 생성 for (const uid of targetIds) { await conn.query( `INSERT INTO monthly_work_confirmations (user_id, year, month, status, reviewed_by, reviewed_at) VALUES (?, ?, ?, 'review_sent', ?, NOW()) ON DUPLICATE KEY UPDATE status = 'review_sent', reviewed_by = ?, reviewed_at = NOW()`, [uid, year, month, reviewedBy, reviewedBy] ); await conn.query( `INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, is_read, created_by) VALUES (?, 'system', '월간 근무 확인 요청', ?, '/pages/attendance/my-monthly-confirm.html?year=${year}&month=${month}', 'monthly_work_confirmation', 0, ?)`, [uid, `${year}년 ${month}월 근무 내역을 확인해주세요.`, reviewedBy] ); } await conn.commit(); return { count: targetIds.length }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }, // 관리자: 수정요청 응답 (change_request → review_sent 또는 rejected) async reviewRespond(userId, year, month, action, rejectReason, respondedBy) { 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 = ?', [userId, year, month] ); if (!existing.length || existing[0].status !== 'change_request') { await conn.rollback(); return { error: '수정요청 상태가 아닙니다.' }; } var newStatus = action === 'approve' ? 'review_sent' : 'rejected'; await conn.query( `UPDATE monthly_work_confirmations SET status = ?, reviewed_by = ?, reviewed_at = NOW(), reject_reason = ?, change_details = NULL WHERE id = ?`, [newStatus, respondedBy, action === 'reject' ? rejectReason : null, existing[0].id] ); // 작업자에게 알림 var title = action === 'approve' ? '수정요청 승인' : '수정요청 거부'; var message = action === 'approve' ? `${year}년 ${month}월 근무 수정이 반영되었습니다. 다시 확인해주세요.` : `${year}년 ${month}월 근무 수정요청이 거부되었습니다. 사유: ${rejectReason || '-'}`; 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, ?)`, [userId, title, message, '/pages/attendance/my-monthly-confirm.html?year=' + year + '&month=' + month, existing[0].id, respondedBy] ); await conn.commit(); return { status: newStatus }; } 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.change_details, COALESCE(mwc.admin_checked, 0) AS admin_checked, COALESCE(att.work_days, 0) AS total_work_days, COALESCE(att.work_hours, 0) AS total_work_hours, COALESCE(att.overtime_hours, 0) AS total_overtime_hours, COALESCE(att.vac_days, 0) AS vacation_days 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 = ? LEFT JOIN ( SELECT dar.user_id, COUNT(CASE WHEN dar.total_work_hours > 0 THEN 1 END) AS work_days, COALESCE(SUM(dar.total_work_hours), 0) AS work_hours, COALESCE(SUM(CASE WHEN dar.total_work_hours > 8 THEN dar.total_work_hours - 8 ELSE 0 END), 0) AS overtime_hours, COALESCE(SUM(CASE WHEN vt.deduct_days IS NOT NULL THEN vt.deduct_days ELSE 0 END), 0) AS vac_days FROM daily_attendance_records dar LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id WHERE YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ? GROUP BY dar.user_id ) att ON w.user_id = att.user_id WHERE w.status = 'active' AND w.user_id IS NOT NULL `; const params = [year, month, 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; }, // 5b. 관리자 개별 검토 태깅 async adminCheck(userId, year, month, checked, checkedBy) { const db = await getDb(); await db.query(` INSERT INTO monthly_work_confirmations (user_id, year, month, status, admin_checked) VALUES (?, ?, ?, 'pending', ?) ON DUPLICATE KEY UPDATE admin_checked = ? `, [userId, year, month, checked ? 1 : 0, checked ? 1 : 0]); return { admin_checked: checked }; }, // 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 getExportData(year, month, departmentId) { const db = await getDb(); // (a) 해당 부서 활성 작업자 (worker_id 순) let workerSql = ` SELECT w.user_id, w.worker_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.status = 'active' `; const workerParams = []; if (departmentId) { workerSql += ' AND w.department_id = ?'; workerParams.push(departmentId); } workerSql += ' ORDER BY w.worker_id'; const [workers] = await db.query(workerSql, workerParams); if (workers.length === 0) return { workers: [], attendance: [], vacations: [] }; const userIds = workers.map(w => w.user_id); const placeholders = userIds.map(() => '?').join(','); // (b) 일별 근태 기록 const [attendance] = await db.query(` SELECT dar.user_id, dar.record_date, dar.total_work_hours, dar.attendance_type_id, dar.vacation_type_id, vt.type_code AS vacation_type_code, vt.type_name AS vacation_type_name, vt.deduct_days FROM daily_attendance_records dar LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id WHERE dar.user_id IN (${placeholders}) AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ? ORDER BY dar.user_id, dar.record_date `, [...userIds, year, month]); // (c) 연차 잔액 (sp_vacation_balances) const [vacations] = await db.query(` SELECT svb.user_id, SUM(svb.total_days) AS total_days, SUM(svb.used_days) AS used_days, SUM(svb.total_days - svb.used_days) AS remaining_days FROM sp_vacation_balances svb WHERE svb.user_id IN (${placeholders}) AND svb.year = ? GROUP BY svb.user_id `, [...userIds, year]); return { workers, attendance, vacations }; }, // 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;