/** * 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(); 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(); let count = 0; for (const b of balances) { 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', b.expires_at || null] ); 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); const yearsWorked = year - hire.getFullYear(); // 해당 연도 내 threshold 도래 건만 (소급 없음) if (yearsWorked !== thresholdYears) continue; // 기념일이 해당 연도인지 확인 const anniversaryDate = new Date(hire); anniversaryDate.setFullYear(hire.getFullYear() + thresholdYears); if (anniversaryDate.getFullYear() !== year) 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 };