Files
tk-factory-services/user-management/api/models/vacationModel.js
Hyungi Ahn ba2e3481e9 feat(vacation): 이월연차 만료 시스템 + 대시보드 합산 개선
- createBalance/bulkUpsert에서 CARRY_OVER expires_at 자동 설정
  (carry_over_expiry_month 설정 기반, 기본값 2월말)
- deductByPriority/deductDays 쿼리에 만료 필터 추가
  (expires_at >= CURDATE() 조건, 만료된 이월 차감 제외)
- 대시보드 합산에서 만료된 잔액 제외
- DB 보정 완료: CARRY_OVER 8건 expires_at 설정,
  황인용/최광욱 소급 재계산 (이월 우선 차감 반영)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:27:17 +09:00

328 lines
13 KiB
JavaScript

/**
* Vacation Model
*
* vacation_types + sp_vacation_balances CRUD (MariaDB)
* Sprint 001: workers → sso_users 기반 전환
*/
const { getPool } = require('./userModel');
/* ===== Vacation Types (휴가 유형) ===== */
async function getVacationTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM vacation_types WHERE is_active = TRUE ORDER BY priority ASC, id ASC'
);
return rows;
}
async function getAllVacationTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM vacation_types ORDER BY priority ASC, id ASC'
);
return rows;
}
async function getVacationTypeById(id) {
const db = getPool();
const [rows] = await db.query('SELECT * FROM vacation_types WHERE id = ?', [id]);
return rows[0] || null;
}
async function createVacationType({ type_code, type_name, deduct_days, is_special, priority, description }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO vacation_types (type_code, type_name, deduct_days, is_special, priority, description, is_system)
VALUES (?, ?, ?, ?, ?, ?, FALSE)`,
[type_code, type_name, deduct_days ?? 1.0, is_special ? 1 : 0, priority ?? 99, description || null]
);
return getVacationTypeById(result.insertId);
}
async function updateVacationType(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.type_code !== undefined) { fields.push('type_code = ?'); values.push(data.type_code); }
if (data.type_name !== undefined) { fields.push('type_name = ?'); values.push(data.type_name); }
if (data.deduct_days !== undefined) { fields.push('deduct_days = ?'); values.push(data.deduct_days); }
if (data.is_special !== undefined) { fields.push('is_special = ?'); values.push(data.is_special ? 1 : 0); }
if (data.priority !== undefined) { fields.push('priority = ?'); values.push(data.priority); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active ? 1 : 0); }
if (fields.length === 0) return getVacationTypeById(id);
values.push(id);
await db.query(`UPDATE vacation_types SET ${fields.join(', ')} WHERE id = ?`, values);
return getVacationTypeById(id);
}
async function deleteVacationType(id) {
const db = getPool();
// 시스템 유형은 비활성화만
const type = await getVacationTypeById(id);
if (type && type.is_system) {
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
} else {
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
}
}
async function updatePriorities(items) {
const db = getPool();
for (const { id, priority } of items) {
await db.query('UPDATE vacation_types SET priority = ? WHERE id = ?', [priority, id]);
}
}
/* ===== Vacation Balances (연차 배정) — sp_vacation_balances + sso_users ===== */
async function getBalancesByYear(year) {
const db = getPool();
const [rows] = await db.query(
`SELECT vb.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority,
su.name AS user_name, su.hire_date, su.department_id, su.long_service_excluded,
COALESCE(d.department_name, '미배정') AS department_name
FROM sp_vacation_balances vb
JOIN vacation_types vt ON vb.vacation_type_id = vt.id
JOIN sso_users su ON vb.user_id = su.user_id
LEFT JOIN departments d ON su.department_id = d.department_id
WHERE su.is_active = 1
AND (
vb.year = ?
OR (vb.balance_type = 'LONG_SERVICE'
AND (vb.expires_at IS NULL OR vb.expires_at >= CURDATE()))
)
ORDER BY d.department_name ASC, su.name ASC, vt.priority ASC`,
[year]
);
return rows;
}
async function getBalancesByUserYear(userId, year) {
const db = getPool();
const [rows] = await db.query(
`SELECT vb.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority
FROM sp_vacation_balances vb
JOIN vacation_types vt ON vb.vacation_type_id = vt.id
WHERE vb.user_id = ? AND (
vb.year = ?
OR (vb.balance_type = 'LONG_SERVICE'
AND (vb.expires_at IS NULL OR vb.expires_at >= CURDATE()))
)
ORDER BY vt.priority ASC`,
[userId, year]
);
return rows;
}
async function getBalanceById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT vb.*, vt.type_code, vt.type_name
FROM sp_vacation_balances vb
JOIN vacation_types vt ON vb.vacation_type_id = vt.id
WHERE vb.id = ?`,
[id]
);
return rows[0] || null;
}
async function createBalance({ user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at }) {
const db = getPool();
// CARRY_OVER일 때 expires_at 미설정이면 자동 계산
if (balance_type === 'CARRY_OVER' && !expires_at) {
const vacationSettingsModel = require('./vacationSettingsModel');
const settings = await vacationSettingsModel.loadAsObject();
const expiryMonth = parseInt(settings.carry_over_expiry_month) || 2;
const lastDay = new Date(year, expiryMonth, 0); // 해당 월 말일
expires_at = lastDay.toISOString().substring(0, 10);
}
const [result] = await db.query(
`INSERT INTO sp_vacation_balances (user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), used_days = VALUES(used_days), notes = VALUES(notes)`,
[user_id, vacation_type_id, year, total_days ?? 0, used_days ?? 0, notes || null, created_by, balance_type || 'AUTO', expires_at || null]
);
return result.insertId ? getBalanceById(result.insertId) : null;
}
async function updateBalance(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.total_days !== undefined) { fields.push('total_days = ?'); values.push(data.total_days); }
if (data.used_days !== undefined) { fields.push('used_days = ?'); values.push(data.used_days); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (fields.length === 0) return getBalanceById(id);
values.push(id);
await db.query(`UPDATE sp_vacation_balances SET ${fields.join(', ')} WHERE id = ?`, values);
return getBalanceById(id);
}
async function deleteBalance(id) {
const db = getPool();
await db.query('DELETE FROM sp_vacation_balances WHERE id = ?', [id]);
}
async function bulkUpsertBalances(balances) {
const db = getPool();
const vacationSettingsModel = require('./vacationSettingsModel');
const settings = await vacationSettingsModel.loadAsObject();
const expiryMonth = parseInt(settings.carry_over_expiry_month) || 2;
let count = 0;
for (const b of balances) {
let expiresAt = b.expires_at || null;
if (b.balance_type === 'CARRY_OVER' && !expiresAt) {
const lastDay = new Date(b.year, expiryMonth, 0);
expiresAt = lastDay.toISOString().substring(0, 10);
}
await db.query(
`INSERT INTO sp_vacation_balances (user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
[b.user_id, b.vacation_type_id, b.year, b.total_days ?? 0, b.used_days ?? 0, b.notes || null, b.created_by, b.balance_type || 'AUTO', expiresAt]
);
count++;
}
return count;
}
/* ===== 연차 자동 계산 (근로기준법 + vacation_settings) ===== */
function calculateAnnualDays(hireDate, targetYear, settings = {}) {
if (!hireDate) return 0;
const hire = new Date(hireDate);
const yearStart = new Date(targetYear, 0, 1);
const monthsDiff = (yearStart.getFullYear() - hire.getFullYear()) * 12 + (yearStart.getMonth() - hire.getMonth());
if (monthsDiff < 0) return 0;
const firstYearRule = settings.annual_leave_first_year_rule || 'MONTHLY';
const baseDays = parseInt(settings.annual_leave_base_days) || 15;
const incrementPerYears = parseInt(settings.annual_leave_increment_per_years) || 2;
const maxDays = parseInt(settings.annual_leave_max_days) || 25;
if (monthsDiff < 12) {
// 1년 미만
if (firstYearRule === 'NONE') return 0;
return Math.max(0, Math.floor(monthsDiff));
}
// 1년 이상: baseDays + incrementPerYears마다 1일 추가 (최대 maxDays)
const yearsWorked = Math.floor(monthsDiff / 12);
const additional = incrementPerYears > 0 ? Math.floor((yearsWorked - 1) / incrementPerYears) : 0;
return Math.min(baseDays + additional, maxDays);
}
async function autoCalculateForAllUsers(year, createdBy, departmentId) {
const db = getPool();
// vacation_settings 로드
const vacationSettingsModel = require('./vacationSettingsModel');
const settings = await vacationSettingsModel.loadAsObject();
// sso_users 조회 (부서 필터 선택)
let userQuery = 'SELECT user_id, name, hire_date FROM sso_users WHERE is_active = 1 AND partner_company_id IS NULL';
const params = [];
if (departmentId) {
userQuery += ' AND department_id = ?';
params.push(departmentId);
}
userQuery += ' ORDER BY name';
const [users] = await db.query(userQuery, params);
// 연차 유형 (ANNUAL_FULL) 찾기
const [types] = await db.query(
"SELECT id FROM vacation_types WHERE type_code = 'ANNUAL_FULL' AND is_active = TRUE LIMIT 1"
);
if (!types.length) return { count: 0, results: [] };
const annualTypeId = types[0].id;
const results = [];
for (const u of users) {
const days = calculateAnnualDays(u.hire_date, year, settings);
if (days > 0) {
await db.query(
`INSERT INTO sp_vacation_balances (user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type)
VALUES (?, ?, ?, ?, 0, ?, ?, 'AUTO')
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
[u.user_id, annualTypeId, year, days, `자동계산 (입사: ${u.hire_date ? new Date(u.hire_date).toISOString().substring(0, 10) : ''})`, createdBy]
);
results.push({ user_id: u.user_id, user_name: u.name, days, hire_date: u.hire_date });
}
}
// 장기근속 자동 부여
if (settings.long_service_auto_grant === 'true') {
const longServiceResults = await autoGrantLongServiceLeave(users, year, createdBy, settings);
results.push(...longServiceResults);
}
return { count: results.length, results };
}
async function autoGrantLongServiceLeave(users, year, createdBy, settings) {
const db = getPool();
const thresholdYears = parseInt(settings.long_service_threshold_years) || 5;
const bonusDays = parseInt(settings.long_service_bonus_days) || 3;
// 연차 유형 (ANNUAL_FULL) 찾기
const [types] = await db.query(
"SELECT id FROM vacation_types WHERE type_code = 'ANNUAL_FULL' AND is_active = TRUE LIMIT 1"
);
if (!types.length) return [];
const annualTypeId = types[0].id;
const results = [];
for (const u of users) {
if (!u.hire_date) continue;
const hire = new Date(u.hire_date);
// 정확한 5년 기념일 계산
const anniversaryDate = new Date(hire);
anniversaryDate.setFullYear(hire.getFullYear() + thresholdYears);
// 기념일이 해당 연도가 아니면 스킵
if (anniversaryDate.getFullYear() !== year) continue;
// 기념일이 아직 도래하지 않았으면 스킵 (정확히 5년 경과 필요)
const today = new Date();
if (today < anniversaryDate) continue;
// long_service_excluded 체크
const [userRows] = await db.query(
'SELECT long_service_excluded FROM sso_users WHERE user_id = ?',
[u.user_id]
);
if (userRows[0] && userRows[0].long_service_excluded) continue;
// 이미 LONG_SERVICE 부여 여부 체크
const [existing] = await db.query(
`SELECT id FROM sp_vacation_balances
WHERE user_id = ? AND balance_type = 'LONG_SERVICE' LIMIT 1`,
[u.user_id]
);
if (existing.length > 0) continue;
await db.query(
`INSERT INTO sp_vacation_balances (user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type, expires_at)
VALUES (?, ?, ?, ?, 0, ?, ?, 'LONG_SERVICE', NULL)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
[u.user_id, annualTypeId, year, bonusDays, `장기근속 ${thresholdYears}년 자동부여`, createdBy]
);
results.push({ user_id: u.user_id, user_name: u.name, days: bonusDays, type: 'LONG_SERVICE' });
}
return results;
}
module.exports = {
getVacationTypes, getAllVacationTypes, getVacationTypeById,
createVacationType, updateVacationType, deleteVacationType, updatePriorities,
getBalancesByYear, getBalancesByUserYear, getBalanceById,
createBalance, updateBalance, deleteBalance, bulkUpsertBalances,
calculateAnnualDays, autoCalculateForAllUsers, autoGrantLongServiceLeave
};