feat(tkfb): 연차 차감/복원을 sp_vacation_balances 정본으로 전환

- deductByPriority/restoreByPriority: vacation_balance_details → sp_vacation_balances
- 트랜잭션 + SELECT FOR UPDATE 적용 (tksupport/tkuser 동시 쓰기 안전)
- balance_type 우선순위: CARRY_OVER → AUTO → MANUAL → LONG_SERVICE → COMPANY_GRANT
- deductDays/restoreDays/getAvailableVacationDays도 sp_vacation_balances로 전환
- DB: sp_vacation_balances DECIMAL(4,1) → DECIMAL(5,2) ALTER 완료 (반반차 0.25 지원)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-31 07:49:00 +09:00
parent 666f0f2df4
commit 3c611daa29

View File

@@ -105,7 +105,7 @@ const vacationBalanceModel = {
async deductDays(userId, vacationTypeId, year, daysToDeduct) { async deductDays(userId, vacationTypeId, year, daysToDeduct) {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(` const [result] = await db.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = used_days + ?, SET used_days = used_days + ?,
updated_at = NOW() updated_at = NOW()
WHERE user_id = ? WHERE user_id = ?
@@ -121,7 +121,7 @@ const vacationBalanceModel = {
async restoreDays(userId, vacationTypeId, year, daysToRestore) { async restoreDays(userId, vacationTypeId, year, daysToRestore) {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(` const [result] = await db.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = GREATEST(0, used_days - ?), SET used_days = GREATEST(0, used_days - ?),
updated_at = NOW() updated_at = NOW()
WHERE user_id = ? WHERE user_id = ?
@@ -138,20 +138,21 @@ const vacationBalanceModel = {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
vbd.id, svb.id,
vbd.vacation_type_id, svb.vacation_type_id,
vt.type_name, vt.type_name,
vt.type_code, vt.type_code,
vt.priority, vt.priority,
vbd.total_days, svb.total_days,
vbd.used_days, svb.used_days,
vbd.remaining_days (svb.total_days - svb.used_days) AS remaining_days,
FROM vacation_balance_details vbd svb.balance_type
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id FROM sp_vacation_balances svb
WHERE vbd.user_id = ? INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
AND vbd.year = ? WHERE svb.user_id = ?
AND vbd.remaining_days > 0 AND svb.year = ?
ORDER BY vt.priority ASC AND (svb.total_days - svb.used_days) > 0
ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
`, [userId, year]); `, [userId, year]);
return rows; return rows;
}, },
@@ -208,19 +209,25 @@ const vacationBalanceModel = {
*/ */
async deductByPriority(userId, year, daysToDeduct) { async deductByPriority(userId, year, daysToDeduct) {
const db = await getDb(); const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [balances] = await db.query(` const [balances] = await conn.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.total_days, vbd.used_days, SELECT svb.id, svb.vacation_type_id, svb.total_days, svb.used_days,
(vbd.total_days - vbd.used_days) as remaining_days, (svb.total_days - svb.used_days) AS remaining_days,
svb.balance_type,
vt.type_code, vt.type_name, vt.priority vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd FROM sp_vacation_balances svb
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE vbd.user_id = ? AND vbd.year = ? WHERE svb.user_id = ? AND svb.year = ?
AND (vbd.total_days - vbd.used_days) > 0 AND (svb.total_days - svb.used_days) > 0
ORDER BY vt.priority ASC ORDER BY vt.priority ASC, FIELD(svb.balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
FOR UPDATE
`, [userId, year]); `, [userId, year]);
if (balances.length === 0) { if (balances.length === 0) {
await conn.rollback();
console.warn(`[VacationBalance] 작업자 ${userId}${year}년 휴가 잔액이 없습니다`); console.warn(`[VacationBalance] 작업자 ${userId}${year}년 휴가 잔액이 없습니다`);
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 }; return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
} }
@@ -230,30 +237,28 @@ const vacationBalanceModel = {
for (const balance of balances) { for (const balance of balances) {
if (remaining <= 0) break; if (remaining <= 0) break;
const available = parseFloat(balance.remaining_days); const available = parseFloat(balance.remaining_days);
const toDeduct = Math.min(remaining, available); const toDeduct = Math.min(remaining, available);
if (toDeduct > 0) { if (toDeduct > 0) {
await db.query(` await conn.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = used_days + ?, updated_at = NOW() SET used_days = used_days + ?, updated_at = NOW()
WHERE id = ? WHERE id = ?
`, [toDeduct, balance.id]); `, [toDeduct, balance.id]);
deductions.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, deducted: toDeduct });
deductions.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
deducted: toDeduct
});
remaining -= toDeduct; remaining -= toDeduct;
} }
} }
await conn.commit();
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions); console.log(`[VacationBalance] 작업자 ${userId}: ${daysToDeduct}일 차감 완료`, deductions);
return { success: true, deductions, totalDeducted: daysToDeduct - remaining }; return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}, },
/** /**
@@ -261,15 +266,20 @@ const vacationBalanceModel = {
*/ */
async restoreByPriority(userId, year, daysToRestore) { async restoreByPriority(userId, year, daysToRestore) {
const db = await getDb(); const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [balances] = await db.query(` const [balances] = await conn.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.used_days, SELECT svb.id, svb.vacation_type_id, svb.used_days,
svb.balance_type,
vt.type_code, vt.type_name, vt.priority vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd FROM sp_vacation_balances svb
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE vbd.user_id = ? AND vbd.year = ? WHERE svb.user_id = ? AND svb.year = ?
AND vbd.used_days > 0 AND svb.used_days > 0
ORDER BY vt.priority DESC ORDER BY vt.priority DESC, FIELD(svb.balance_type, 'COMPANY_GRANT', 'LONG_SERVICE', 'MANUAL', 'AUTO', 'CARRY_OVER')
FOR UPDATE
`, [userId, year]); `, [userId, year]);
let remaining = daysToRestore; let remaining = daysToRestore;
@@ -277,30 +287,28 @@ const vacationBalanceModel = {
for (const balance of balances) { for (const balance of balances) {
if (remaining <= 0) break; if (remaining <= 0) break;
const usedDays = parseFloat(balance.used_days); const usedDays = parseFloat(balance.used_days);
const toRestore = Math.min(remaining, usedDays); const toRestore = Math.min(remaining, usedDays);
if (toRestore > 0) { if (toRestore > 0) {
await db.query(` await conn.query(`
UPDATE vacation_balance_details UPDATE sp_vacation_balances
SET used_days = used_days - ?, updated_at = NOW() SET used_days = used_days - ?, updated_at = NOW()
WHERE id = ? WHERE id = ?
`, [toRestore, balance.id]); `, [toRestore, balance.id]);
restorations.push({ balance_id: balance.id, type_code: balance.type_code, type_name: balance.type_name, balance_type: balance.balance_type, restored: toRestore });
restorations.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
restored: toRestore
});
remaining -= toRestore; remaining -= toRestore;
} }
} }
await conn.commit();
console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations); console.log(`[VacationBalance] 작업자 ${userId}: ${daysToRestore}일 복구 완료`, restorations);
return { success: true, restorations, totalRestored: daysToRestore - remaining }; return { success: true, restorations, totalRestored: daysToRestore - remaining };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}, },
/** /**