- 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>
328 lines
13 KiB
JavaScript
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
|
|
};
|