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