From c158da7832aabaf08d38b64bf629064869de685c Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 23 Mar 2026 08:13:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(tkuser):=20Sprint=20001=20Section=20A=20?= =?UTF-8?q?=E2=80=94=20=EC=97=B0=EC=B0=A8/=ED=9C=B4=EA=B0=80=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=A0=84=ED=99=98=20(DB=20+=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workers/vacation_balance_details → sso_users/sp_vacation_balances 기반 전환. 부서장 설정, 승인권한 CRUD, vacation_settings 테이블, 장기근속 자동부여, startup migration 추가. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/controllers/departmentController.js | 49 ++++- .../api/controllers/vacationController.js | 39 +++- .../controllers/vacationSettingsController.js | 27 +++ user-management/api/index.js | 22 ++- user-management/api/models/departmentModel.js | 66 ++++++- user-management/api/models/vacationModel.js | 186 +++++++++++++----- .../api/models/vacationSettingsModel.js | 62 ++++++ .../api/routes/departmentRoutes.js | 5 + user-management/api/routes/vacationRoutes.js | 8 +- .../api/routes/vacationSettingsRoutes.js | 15 ++ .../20260323_sprint001_vacation_overhaul.sql | 92 +++++++++ 11 files changed, 501 insertions(+), 70 deletions(-) create mode 100644 user-management/api/controllers/vacationSettingsController.js create mode 100644 user-management/api/models/vacationSettingsModel.js create mode 100644 user-management/api/routes/vacationSettingsRoutes.js create mode 100644 user-management/migrations/20260323_sprint001_vacation_overhaul.sql diff --git a/user-management/api/controllers/departmentController.js b/user-management/api/controllers/departmentController.js index 4471acb..4f9024b 100644 --- a/user-management/api/controllers/departmentController.js +++ b/user-management/api/controllers/departmentController.js @@ -1,7 +1,7 @@ /** * Department Controller * - * 부서 CRUD + * 부서 CRUD + 승인권한 관리 */ const departmentModel = require('../models/departmentModel'); @@ -63,4 +63,49 @@ async function remove(req, res, next) { } } -module.exports = { getAll, getById, create, update, remove }; +/* ===== 승인권한 (Approval Authority) ===== */ + +async function getApprovalAuthorities(req, res, next) { + try { + const departmentId = parseInt(req.params.id); + const data = await departmentModel.getApprovalAuthorities(departmentId); + res.json({ success: true, data }); + } catch (err) { + next(err); + } +} + +async function createApprovalAuthority(req, res, next) { + try { + const departmentId = parseInt(req.params.id); + const { approval_type, approver_user_id } = req.body; + if (!approval_type || !approver_user_id) { + return res.status(400).json({ success: false, error: '승인유형과 승인자는 필수입니다' }); + } + const data = await departmentModel.createApprovalAuthority({ + department_id: departmentId, + ...req.body + }); + res.status(201).json({ success: true, data }); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ success: false, error: '이미 등록된 승인권한입니다' }); + } + next(err); + } +} + +async function deleteApprovalAuthority(req, res, next) { + try { + const authId = parseInt(req.params.authId); + await departmentModel.deleteApprovalAuthority(authId); + res.json({ success: true, message: '승인권한이 삭제되었습니다' }); + } catch (err) { + next(err); + } +} + +module.exports = { + getAll, getById, create, update, remove, + getApprovalAuthorities, createApprovalAuthority, deleteApprovalAuthority +}; diff --git a/user-management/api/controllers/vacationController.js b/user-management/api/controllers/vacationController.js index 590b5a5..b262b43 100644 --- a/user-management/api/controllers/vacationController.js +++ b/user-management/api/controllers/vacationController.js @@ -2,6 +2,7 @@ * Vacation Controller * * 휴가 유형 + 연차 배정 관리 + * Sprint 001: sso_users 기반 전환 + 장기근속 */ const vacationModel = require('../models/vacationModel'); @@ -65,20 +66,20 @@ async function getBalancesByYear(req, res, next) { } catch (err) { next(err); } } -async function getBalancesByWorkerYear(req, res, next) { +async function getBalancesByUserYear(req, res, next) { try { - const workerId = parseInt(req.params.workerId); + const userId = parseInt(req.params.userId); const year = parseInt(req.params.year); - const data = await vacationModel.getBalancesByWorkerYear(workerId, year); + const data = await vacationModel.getBalancesByUserYear(userId, year); res.json({ success: true, data }); } catch (err) { next(err); } } async function createBalance(req, res, next) { try { - const { worker_id, vacation_type_id, year } = req.body; - if (!worker_id || !vacation_type_id || !year) { - return res.status(400).json({ success: false, error: '작업자, 휴가유형, 연도는 필수입니다' }); + const { user_id, vacation_type_id, year } = req.body; + if (!user_id || !vacation_type_id || !year) { + return res.status(400).json({ success: false, error: '사용자, 휴가유형, 연도는 필수입니다' }); } const data = await vacationModel.createBalance({ ...req.body, created_by: req.user.user_id }); res.status(201).json({ success: true, data }); @@ -112,15 +113,33 @@ async function bulkUpsertBalances(req, res, next) { async function autoCalculate(req, res, next) { try { - const { year } = req.body; + const { year, departmentId } = req.body; if (!year) return res.status(400).json({ success: false, error: '연도는 필수입니다' }); - const result = await vacationModel.autoCalculateForAllWorkers(year, req.user.user_id); + const result = await vacationModel.autoCalculateForAllUsers(year, req.user.user_id, departmentId); res.json({ success: true, data: result, message: `${result.count}명 자동 배정 완료` }); } catch (err) { next(err); } } +/* ===== 장기근속 제외 설정 ===== */ + +async function setLongServiceExclusion(req, res, next) { + try { + const { user_id, excluded } = req.body; + if (!user_id || excluded === undefined) { + return res.status(400).json({ success: false, error: 'user_id와 excluded는 필수입니다' }); + } + const { getPool } = require('../models/userModel'); + const db = getPool(); + await db.query( + 'UPDATE sso_users SET long_service_excluded = ? WHERE user_id = ?', + [excluded ? 1 : 0, user_id] + ); + res.json({ success: true, message: `장기근속 제외 설정이 ${excluded ? '활성화' : '해제'}되었습니다` }); + } catch (err) { next(err); } +} + module.exports = { getVacationTypes, createVacationType, updateVacationType, deleteVacationType, updatePriorities, - getBalancesByYear, getBalancesByWorkerYear, createBalance, updateBalance, deleteBalance, - bulkUpsertBalances, autoCalculate + getBalancesByYear, getBalancesByUserYear, createBalance, updateBalance, deleteBalance, + bulkUpsertBalances, autoCalculate, setLongServiceExclusion }; diff --git a/user-management/api/controllers/vacationSettingsController.js b/user-management/api/controllers/vacationSettingsController.js new file mode 100644 index 0000000..b569cd7 --- /dev/null +++ b/user-management/api/controllers/vacationSettingsController.js @@ -0,0 +1,27 @@ +/** + * Vacation Settings Controller + * + * 연차 계산 규칙 설정 GET/PUT + */ + +const vacationSettingsModel = require('../models/vacationSettingsModel'); + +async function getSettings(req, res, next) { + try { + const data = await vacationSettingsModel.getAll(); + res.json({ success: true, data }); + } catch (err) { next(err); } +} + +async function updateSettings(req, res, next) { + try { + const { settings } = req.body; + if (!settings || !Array.isArray(settings)) { + return res.status(400).json({ success: false, error: 'settings 배열이 필요합니다' }); + } + const data = await vacationSettingsModel.updateSettings(settings, req.user.user_id); + res.json({ success: true, data, message: '설정이 업데이트되었습니다' }); + } catch (err) { next(err); } +} + +module.exports = { getSettings, updateSettings }; diff --git a/user-management/api/index.js b/user-management/api/index.js index e88af8c..a56c161 100644 --- a/user-management/api/index.js +++ b/user-management/api/index.js @@ -17,6 +17,7 @@ const workplaceRoutes = require('./routes/workplaceRoutes'); const equipmentRoutes = require('./routes/equipmentRoutes'); const taskRoutes = require('./routes/taskRoutes'); const vacationRoutes = require('./routes/vacationRoutes'); +const vacationSettingsRoutes = require('./routes/vacationSettingsRoutes'); const partnerRoutes = require('./routes/partnerRoutes'); const vendorRoutes = require('./routes/vendorRoutes'); const consumableItemRoutes = require('./routes/consumableItemRoutes'); @@ -64,6 +65,7 @@ app.use('/api/workplaces', workplaceRoutes); app.use('/api/equipments', equipmentRoutes); app.use('/api/tasks', taskRoutes); app.use('/api/vacations', vacationRoutes); +app.use('/api/vacation-settings', vacationSettingsRoutes); app.use('/api/partners', partnerRoutes); app.use('/api/vendors', vendorRoutes); app.use('/api/consumable-items', consumableItemRoutes); @@ -85,9 +87,23 @@ app.use((err, req, res, next) => { }); }); -app.listen(PORT, () => { - console.log(`tkuser-api running on port ${PORT}`); -}); +// Startup: 마이그레이션 완료 후 서버 시작 +async function start() { + try { + const { runMigration } = require('./models/vacationSettingsModel'); + await runMigration(); + } catch (err) { + if (!['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) { + console.error('Fatal migration error:', err.message); + process.exit(1); + } + console.error('Migration warning (ignored):', err.message); + } + app.listen(PORT, () => { + console.log(`tkuser-api running on port ${PORT}`); + }); +} +start(); // 오래된 알림 정리 cron (매일 03:00 KST) (function scheduleNotificationCleanup() { diff --git a/user-management/api/models/departmentModel.js b/user-management/api/models/departmentModel.js index 32e3486..cc1d3c2 100644 --- a/user-management/api/models/departmentModel.js +++ b/user-management/api/models/departmentModel.js @@ -1,7 +1,7 @@ /** * Department Model * - * departments 테이블 CRUD (MariaDB) + * departments 테이블 CRUD + 승인권한 관리 (MariaDB) */ const { getPool } = require('./userModel'); @@ -9,8 +9,9 @@ const { getPool } = require('./userModel'); async function getAll() { const db = getPool(); const [rows] = await db.query( - `SELECT d.* + `SELECT d.*, su.name AS leader_name FROM departments d + LEFT JOIN sso_users su ON d.leader_user_id = su.user_id ORDER BY d.display_order ASC, d.department_id ASC` ); return rows; @@ -19,8 +20,9 @@ async function getAll() { async function getById(id) { const db = getPool(); const [rows] = await db.query( - `SELECT d.* + `SELECT d.*, su.name AS leader_name FROM departments d + LEFT JOIN sso_users su ON d.leader_user_id = su.user_id WHERE d.department_id = ?`, [id] ); @@ -45,6 +47,7 @@ async function update(id, data) { if (data.department_name !== undefined) { fields.push('department_name = ?'); values.push(data.department_name); } if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); } if (data.display_order !== undefined) { fields.push('display_order = ?'); values.push(data.display_order); } + if (data.leader_user_id !== undefined) { fields.push('leader_user_id = ?'); values.push(data.leader_user_id || null); } if (fields.length === 0) return getById(id); @@ -61,6 +64,8 @@ async function remove(id) { const conn = await db.getConnection(); try { await conn.beginTransaction(); + // CASCADE가 있지만 방어적으로 명시 삭제 + await conn.query('DELETE FROM dept_approval_authority WHERE department_id = ?', [id]); await conn.query('UPDATE users SET department_id = NULL WHERE department_id = ?', [id]); await conn.query('DELETE FROM departments WHERE department_id = ?', [id]); await conn.commit(); @@ -72,4 +77,57 @@ async function remove(id) { } } -module.exports = { getAll, getById, create, update, remove }; +/* ===== 승인권한 (Approval Authority) ===== */ + +async function getApprovalAuthorities(departmentId) { + const db = getPool(); + const [rows] = await db.query( + `SELECT aa.*, su.name AS approver_name + FROM dept_approval_authority aa + JOIN sso_users su ON aa.approver_user_id = su.user_id + WHERE aa.department_id = ? AND aa.is_active = TRUE + ORDER BY aa.approval_type ASC, aa.priority ASC`, + [departmentId] + ); + return rows; +} + +async function createApprovalAuthority({ department_id, approval_type, approver_user_id, priority }) { + const db = getPool(); + const [result] = await db.query( + `INSERT INTO dept_approval_authority (department_id, approval_type, approver_user_id, priority) + VALUES (?, ?, ?, ?)`, + [department_id, approval_type, approver_user_id, priority || 1] + ); + const [rows] = await db.query( + `SELECT aa.*, su.name AS approver_name + FROM dept_approval_authority aa + JOIN sso_users su ON aa.approver_user_id = su.user_id + WHERE aa.id = ?`, + [result.insertId] + ); + return rows[0] || null; +} + +async function deleteApprovalAuthority(id) { + const db = getPool(); + await db.query('DELETE FROM dept_approval_authority WHERE id = ?', [id]); +} + +async function getApproversByType(departmentId, approvalType) { + const db = getPool(); + const [rows] = await db.query( + `SELECT aa.*, su.name AS approver_name + FROM dept_approval_authority aa + JOIN sso_users su ON aa.approver_user_id = su.user_id + WHERE aa.department_id = ? AND aa.approval_type = ? AND aa.is_active = TRUE + ORDER BY aa.priority ASC`, + [departmentId, approvalType] + ); + return rows; +} + +module.exports = { + getAll, getById, create, update, remove, + getApprovalAuthorities, createApprovalAuthority, deleteApprovalAuthority, getApproversByType +}; diff --git a/user-management/api/models/vacationModel.js b/user-management/api/models/vacationModel.js index dcdc47e..53cb05c 100644 --- a/user-management/api/models/vacationModel.js +++ b/user-management/api/models/vacationModel.js @@ -1,8 +1,8 @@ /** * Vacation Model * - * vacation_types + vacation_balance_details CRUD (MariaDB) - * System 1과 같은 DB를 공유 + * vacation_types + sp_vacation_balances CRUD (MariaDB) + * Sprint 001: workers → sso_users 기반 전환 */ const { getPool } = require('./userModel'); @@ -76,34 +76,43 @@ async function updatePriorities(items) { } } -/* ===== Vacation Balances (연차 배정) ===== */ +/* ===== Vacation Balances (연차 배정) — sp_vacation_balances + sso_users ===== */ async function getBalancesByYear(year) { const db = getPool(); const [rows] = await db.query( - `SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority, - w.worker_name, w.hire_date, w.department_id, + `SELECT vb.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority, + su.name AS user_name, su.hire_date, su.department_id, COALESCE(d.department_name, '미배정') AS department_name - FROM vacation_balance_details vbd - JOIN vacation_types vt ON vbd.vacation_type_id = vt.id - JOIN workers w ON vbd.worker_id = w.worker_id - LEFT JOIN departments d ON w.department_id = d.department_id - WHERE vbd.year = ? - ORDER BY d.department_name ASC, w.worker_name ASC, vt.priority ASC`, + 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 getBalancesByWorkerYear(workerId, year) { +async function getBalancesByUserYear(userId, year) { const db = getPool(); const [rows] = await db.query( - `SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority - FROM vacation_balance_details vbd - JOIN vacation_types vt ON vbd.vacation_type_id = vt.id - WHERE vbd.worker_id = ? AND vbd.year = ? + `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`, - [workerId, year] + [userId, year] ); return rows; } @@ -111,22 +120,22 @@ async function getBalancesByWorkerYear(workerId, year) { async function getBalanceById(id) { const db = getPool(); const [rows] = await db.query( - `SELECT vbd.*, vt.type_code, vt.type_name - FROM vacation_balance_details vbd - JOIN vacation_types vt ON vbd.vacation_type_id = vt.id - WHERE vbd.id = ?`, + `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({ worker_id, vacation_type_id, year, total_days, used_days, notes, created_by }) { +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 vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?) + `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)`, - [worker_id, vacation_type_id, year, total_days ?? 0, used_days ?? 0, notes || null, created_by] + [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; } @@ -140,13 +149,13 @@ async function updateBalance(id, data) { 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 vacation_balance_details SET ${fields.join(', ')} WHERE id = ?`, values); + 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 vacation_balance_details WHERE id = ?', [id]); + await db.query('DELETE FROM sp_vacation_balances WHERE id = ?', [id]); } async function bulkUpsertBalances(balances) { @@ -154,41 +163,59 @@ async function bulkUpsertBalances(balances) { let count = 0; for (const b of balances) { await db.query( - `INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?) + `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.worker_id, b.vacation_type_id, b.year, b.total_days ?? 0, b.used_days ?? 0, b.notes || null, b.created_by] + [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) { +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년 미만: 근무 개월 수 + // 1년 미만 + if (firstYearRule === 'NONE') return 0; return Math.max(0, Math.floor(monthsDiff)); } - // 1년 이상: 15일 + 2년마다 1일 추가 (최대 25일) + // 1년 이상: baseDays + incrementPerYears마다 1일 추가 (최대 maxDays) const yearsWorked = Math.floor(monthsDiff / 12); - const additional = Math.floor((yearsWorked - 1) / 2); - return Math.min(15 + additional, 25); + const additional = incrementPerYears > 0 ? Math.floor((yearsWorked - 1) / incrementPerYears) : 0; + return Math.min(baseDays + additional, maxDays); } -async function autoCalculateForAllWorkers(year, createdBy) { +async function autoCalculateForAllUsers(year, createdBy, departmentId) { const db = getPool(); - const [workers] = await db.query( - 'SELECT worker_id, worker_name, hire_date FROM workers WHERE status != ? ORDER BY worker_name', - ['inactive'] - ); + + // 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" @@ -197,25 +224,86 @@ async function autoCalculateForAllWorkers(year, createdBy) { const annualTypeId = types[0].id; const results = []; - for (const w of workers) { - const days = calculateAnnualDays(w.hire_date, year); + for (const u of users) { + const days = calculateAnnualDays(u.hire_date, year, settings); if (days > 0) { await db.query( - `INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by) - VALUES (?, ?, ?, ?, 0, ?, ?) + `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)`, - [w.worker_id, annualTypeId, year, days, `자동계산 (입사: ${w.hire_date ? w.hire_date.toISOString().substring(0,10) : ''})`, createdBy] + [u.user_id, annualTypeId, year, days, `자동계산 (입사: ${u.hire_date ? new Date(u.hire_date).toISOString().substring(0, 10) : ''})`, createdBy] ); - results.push({ worker_id: w.worker_id, worker_name: w.worker_name, days, hire_date: w.hire_date }); + 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, getBalancesByWorkerYear, getBalanceById, + getBalancesByYear, getBalancesByUserYear, getBalanceById, createBalance, updateBalance, deleteBalance, bulkUpsertBalances, - calculateAnnualDays, autoCalculateForAllWorkers + calculateAnnualDays, autoCalculateForAllUsers, autoGrantLongServiceLeave }; diff --git a/user-management/api/models/vacationSettingsModel.js b/user-management/api/models/vacationSettingsModel.js new file mode 100644 index 0000000..e1ccd78 --- /dev/null +++ b/user-management/api/models/vacationSettingsModel.js @@ -0,0 +1,62 @@ +/** + * Vacation Settings Model + * + * vacation_settings 테이블 CRUD + startup migration + */ + +const { getPool } = require('./userModel'); + +async function getAll() { + const db = getPool(); + const [rows] = await db.query('SELECT * FROM vacation_settings ORDER BY id ASC'); + return rows; +} + +async function getByKey(key) { + const db = getPool(); + const [rows] = await db.query('SELECT * FROM vacation_settings WHERE setting_key = ?', [key]); + return rows[0] || null; +} + +async function updateSettings(settings, updatedBy) { + const db = getPool(); + for (const { setting_key, setting_value } of settings) { + await db.query( + 'UPDATE vacation_settings SET setting_value = ?, updated_by = ? WHERE setting_key = ?', + [setting_value, updatedBy, setting_key] + ); + } + return getAll(); +} + +async function loadAsObject() { + const rows = await getAll(); + const obj = {}; + for (const row of rows) { + obj[row.setting_key] = row.setting_value; + } + return obj; +} + +async function runMigration() { + const db = getPool(); + const fs = require('fs'); + const path = require('path'); + const sqlFile = path.join(__dirname, '..', '..', 'migrations', '20260323_sprint001_vacation_overhaul.sql'); + const sql = fs.readFileSync(sqlFile, 'utf8'); + const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0); + for (const stmt of statements) { + try { + await db.query(stmt); + } catch (err) { + if (['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) { + // Already migrated — safe to ignore + } else { + throw err; + } + } + } + console.log('[tkuser] Sprint 001 migration completed'); +} + +module.exports = { getAll, getByKey, updateSettings, loadAsObject, runMigration }; diff --git a/user-management/api/routes/departmentRoutes.js b/user-management/api/routes/departmentRoutes.js index 46073bb..87f87a9 100644 --- a/user-management/api/routes/departmentRoutes.js +++ b/user-management/api/routes/departmentRoutes.js @@ -13,4 +13,9 @@ router.post('/', requireAdmin, departmentController.create); router.put('/:id', requireAdmin, departmentController.update); router.delete('/:id', requireAdmin, departmentController.remove); +// 승인권한 (Approval Authority) +router.get('/:id/approval-authorities', requireAuth, departmentController.getApprovalAuthorities); +router.post('/:id/approval-authorities', requireAdmin, departmentController.createApprovalAuthority); +router.delete('/:id/approval-authorities/:authId', requireAdmin, departmentController.deleteApprovalAuthority); + module.exports = router; diff --git a/user-management/api/routes/vacationRoutes.js b/user-management/api/routes/vacationRoutes.js index 94b6d76..d8c58bc 100644 --- a/user-management/api/routes/vacationRoutes.js +++ b/user-management/api/routes/vacationRoutes.js @@ -2,12 +2,13 @@ * Vacation Routes * * 휴가 유형 + 연차 배정 라우팅 + * Sprint 001: worker → user 전환 + 장기근속 */ const express = require('express'); const router = express.Router(); const vc = require('../controllers/vacationController'); -const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { requireAuth, requireAdmin, requireMinLevel } = require('../middleware/auth'); // Vacation Types (휴가 유형) router.get('/types', requireAuth, vc.getVacationTypes); @@ -18,11 +19,14 @@ router.delete('/types/:id', requireAdmin, vc.deleteVacationType); // Vacation Balances (연차 배정) router.get('/balances/year/:year', requireAdmin, vc.getBalancesByYear); -router.get('/balances/worker/:workerId/year/:year', requireAuth, vc.getBalancesByWorkerYear); +router.get('/balances/user/:userId/year/:year', requireAuth, vc.getBalancesByUserYear); router.post('/balances', requireAdmin, vc.createBalance); router.post('/balances/bulk-upsert', requireAdmin, vc.bulkUpsertBalances); router.post('/balances/auto-calculate', requireAdmin, vc.autoCalculate); router.put('/balances/:id', requireAdmin, vc.updateBalance); router.delete('/balances/:id', requireAdmin, vc.deleteBalance); +// 장기근속 제외 설정 +router.put('/long-service-exclusion', requireMinLevel('support_team'), vc.setLongServiceExclusion); + module.exports = router; diff --git a/user-management/api/routes/vacationSettingsRoutes.js b/user-management/api/routes/vacationSettingsRoutes.js new file mode 100644 index 0000000..d8fcd6f --- /dev/null +++ b/user-management/api/routes/vacationSettingsRoutes.js @@ -0,0 +1,15 @@ +/** + * Vacation Settings Routes + * + * 연차 계산 규칙 설정 + */ + +const express = require('express'); +const router = express.Router(); +const vsc = require('../controllers/vacationSettingsController'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +router.get('/', requireAuth, vsc.getSettings); +router.put('/', requireAdmin, vsc.updateSettings); + +module.exports = router; diff --git a/user-management/migrations/20260323_sprint001_vacation_overhaul.sql b/user-management/migrations/20260323_sprint001_vacation_overhaul.sql new file mode 100644 index 0000000..eeddb82 --- /dev/null +++ b/user-management/migrations/20260323_sprint001_vacation_overhaul.sql @@ -0,0 +1,92 @@ +-- Sprint 001: 연차/휴가 관리 시스템 개선 마이그레이션 +-- 비파괴적(additive) 변경만 수행 +-- 실행: 수동 또는 서버 시작 시 + +-- ============================================================ +-- 1. departments에 팀장 컬럼 추가 +-- ============================================================ +ALTER TABLE departments + ADD COLUMN IF NOT EXISTS leader_user_id INT NULL + COMMENT '팀장(부서장) sso_users.user_id'; + +-- ============================================================ +-- 2. dept_approval_authority (부서별 승인권한) 신규 +-- ============================================================ +CREATE TABLE IF NOT EXISTS dept_approval_authority ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL COMMENT '대상 부서', + approval_type VARCHAR(50) NOT NULL COMMENT '승인유형 (VACATION, PURCHASE 등)', + approver_user_id INT NOT NULL COMMENT '승인자 sso_users.user_id', + priority INT DEFAULT 1 COMMENT '승인 순서 (다단계 승인 시)', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (department_id) REFERENCES departments(department_id) ON DELETE CASCADE, + FOREIGN KEY (approver_user_id) REFERENCES sso_users(user_id), + UNIQUE KEY uq_dept_type_approver (department_id, approval_type, approver_user_id) +) COMMENT '부서별 행정 승인권한 (시스템 접근권한과 별개)'; + +-- ============================================================ +-- 3. vacation_settings (연차 계산 규칙 설정) 신규 +-- ============================================================ +CREATE TABLE IF NOT EXISTS vacation_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(100) NOT NULL UNIQUE, + setting_value TEXT NOT NULL, + description TEXT, + updated_by INT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (updated_by) REFERENCES sso_users(user_id) +) COMMENT '연차 계산 규칙 설정'; + +INSERT IGNORE INTO vacation_settings (setting_key, setting_value, description) VALUES + ('annual_leave_first_year_rule', 'MONTHLY', '1년 미만: MONTHLY(월 1일) / NONE(없음)'), + ('annual_leave_base_days', '15', '1년차 기본 연차일수'), + ('annual_leave_increment_per_years', '2', '추가 1일 부여 간격 (년)'), + ('annual_leave_max_days', '25', '연차 최대 일수'), + ('long_service_threshold_years', '5', '장기근속 연차 부여 기준 (년)'), + ('long_service_bonus_days', '3', '장기근속 추가 연차 일수'), + ('long_service_expiry', 'UNTIL_RESIGN', '장기근속 연차 만료: UNTIL_RESIGN(퇴사까지) / YEARLY(매년)'), + ('long_service_auto_grant', 'true', '장기근속 연차 자동 부여 여부'), + ('carry_over_enabled', 'true', '이월연차 허용 여부'), + ('carry_over_max_days', '0', '이월 최대 일수 (0=무제한)'), + ('carry_over_expiry_month', '2', '이월연차 사용 기한 (월)'); + +-- ============================================================ +-- 4. sp_vacation_balances 확장 (balance_type, expires_at 추가) +-- ============================================================ +ALTER TABLE sp_vacation_balances + ADD COLUMN IF NOT EXISTS balance_type ENUM('AUTO','MANUAL','CARRY_OVER','LONG_SERVICE','COMPANY_GRANT') + DEFAULT 'AUTO' COMMENT '부여 유형', + ADD COLUMN IF NOT EXISTS expires_at DATE NULL COMMENT '만료일 (NULL=해당연도말)'; + +-- UNIQUE KEY 변경: balance_type을 포함하여 같은 user/type/year에 AUTO + LONG_SERVICE 공존 허용 +-- 기존 UNIQUE KEY 삭제 후 재생성 +ALTER TABLE sp_vacation_balances + DROP INDEX IF EXISTS unique_user_type_year; + +ALTER TABLE sp_vacation_balances + ADD UNIQUE KEY unique_user_type_year_btype (user_id, vacation_type_id, year, balance_type); + +-- ============================================================ +-- 5. sso_users에 장기근속 제외 플래그 +-- ============================================================ +ALTER TABLE sso_users + ADD COLUMN IF NOT EXISTS long_service_excluded BOOLEAN DEFAULT FALSE + COMMENT '장기근속 연차 제외 대상'; + +-- ============================================================ +-- 6. company_holidays (전사 휴가일 관리) 신규 +-- ============================================================ +CREATE TABLE IF NOT EXISTS company_holidays ( + id INT AUTO_INCREMENT PRIMARY KEY, + holiday_date DATE NOT NULL, + holiday_name VARCHAR(100) NOT NULL COMMENT '휴가명', + holiday_type ENUM('PAID','ANNUAL_DEDUCT') NOT NULL + COMMENT 'PAID=유급휴가(회사부여), ANNUAL_DEDUCT=연차차감', + description TEXT, + created_by INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES sso_users(user_id), + UNIQUE KEY uq_holiday_date (holiday_date) +) COMMENT '전사 휴가일 관리';