Files
tk-factory-services/system1-factory/api/models/monthlyComparisonModel.js
Hyungi Ahn f3b7f1a34f fix(sprint004): 코드 리뷰 반영 — vacation_days 소수 + 이중제출 방지 + deprecated 테이블 전환
- monthlyComparisonModel: vacation_types.deduct_days AS vacation_days 추가
- monthlyComparisonController: vacationDays++ → parseFloat(attend.vacation_days) 소수 지원
- monthly-comparison.js: confirmMonth/submitReject 이중 제출 방지 (isProcessing 플래그)
- vacationBalanceModel: create/update/delete/bulkCreate → sp_vacation_balances + balance_type 매핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:42:12 +09:00

233 lines
8.5 KiB
JavaScript

// 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,
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]
);
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 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;