- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳) - SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩 - SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지) - SEC-39: Python Dockerfile 4개 non-root user + chown - SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함) - SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized - QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑 - SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by - SEC-33: files.py 17개 미인증 엔드포인트 인증 추가 - SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자) - SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함 - SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리 - SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가 - SEC-63: proxyInputController 에러 메시지 노출 제거 - QA-103: pageAccessRoutes error→message 통일 - SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation - QA-99: tbm-mobile/create 캐시 버스팅 갱신 - QA-100,101: ESC 키 리스너 cleanup 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
388 lines
15 KiB
JavaScript
388 lines
15 KiB
JavaScript
// 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 = ? FOR UPDATE',
|
|
[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;
|