fix(vacation): 배정일수 음수 허용 + 특별휴가 우선 차감

- vbTotalDays min="0" 제거 (보정용 음수 입력 허용)
- deductDays 2단계 차감:
  1단계: vacation_type_id 정확 매칭 잔액 우선 (배우자출산 등)
  2단계: 나머지를 이월→기본→추가→장기→회사 순서로

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-31 09:32:51 +09:00
parent 8016237038
commit 9cbf4c98a5
2 changed files with 33 additions and 9 deletions

View File

@@ -61,7 +61,9 @@ const vacationBalanceModel = {
return result; return result;
}, },
// 우선순위: 이월 → 기본연차 → 추가부여 → 장기근속 → 회사부여 // 차감 우선순위:
// 1. 특별휴가(배우자출산 등) — vacation_type_id가 정확히 일치하는 잔액 먼저
// 2. 이월 → 기본연차 → 추가부여 → 장기근속 → 회사부여 순서
async deductDays(userId, vacationTypeId, year, daysToDeduct, conn) { async deductDays(userId, vacationTypeId, year, daysToDeduct, conn) {
const db = conn || getPool(); const db = conn || getPool();
const needRelease = !conn; const needRelease = !conn;
@@ -69,16 +71,16 @@ const vacationBalanceModel = {
try { try {
if (needRelease) await c.beginTransaction(); if (needRelease) await c.beginTransaction();
const [balances] = await c.query(` // 1단계: 해당 vacation_type_id와 정확히 매칭되는 잔액 우선 차감 (특별휴가)
const [exactMatch] = await c.query(`
SELECT id, total_days, used_days, (total_days - used_days) AS remaining_days, balance_type SELECT id, total_days, used_days, (total_days - used_days) AS remaining_days, balance_type
FROM sp_vacation_balances FROM sp_vacation_balances
WHERE user_id = ? AND year = ? AND (total_days - used_days) > 0 WHERE user_id = ? AND year = ? AND vacation_type_id = ? AND (total_days - used_days) > 0
ORDER BY FIELD(balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
FOR UPDATE FOR UPDATE
`, [userId, year]); `, [userId, year, vacationTypeId]);
let remaining = daysToDeduct; let remaining = daysToDeduct;
for (const b of balances) { for (const b of exactMatch) {
if (remaining <= 0) break; if (remaining <= 0) break;
const toDeduct = Math.min(remaining, parseFloat(b.remaining_days)); const toDeduct = Math.min(remaining, parseFloat(b.remaining_days));
if (toDeduct > 0) { if (toDeduct > 0) {
@@ -87,8 +89,30 @@ const vacationBalanceModel = {
} }
} }
// 2단계: 남은 차감분을 우선순위 순서로 (이미 차감한 행 제외)
if (remaining > 0) {
const deductedIds = exactMatch.map(b => b.id);
const excludeClause = deductedIds.length > 0 ? `AND id NOT IN (${deductedIds.join(',')})` : '';
const [balances] = await c.query(`
SELECT id, total_days, used_days, (total_days - used_days) AS remaining_days, balance_type
FROM sp_vacation_balances
WHERE user_id = ? AND year = ? AND (total_days - used_days) > 0 ${excludeClause}
ORDER BY FIELD(balance_type, 'CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT')
FOR UPDATE
`, [userId, year]);
for (const b of balances) {
if (remaining <= 0) break;
const toDeduct = Math.min(remaining, parseFloat(b.remaining_days));
if (toDeduct > 0) {
await c.query('UPDATE sp_vacation_balances SET used_days = used_days + ?, updated_at = NOW() WHERE id = ?', [toDeduct, b.id]);
remaining -= toDeduct;
}
}
}
if (needRelease) await c.commit(); if (needRelease) await c.commit();
return { affectedRows: balances.length }; return { affectedRows: exactMatch.length };
} catch (err) { } catch (err) {
if (needRelease) await c.rollback(); if (needRelease) await c.rollback();
throw err; throw err;

View File

@@ -918,7 +918,7 @@
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">배정 일수</label> <label class="block text-xs font-medium text-gray-600 mb-1">배정 일수</label>
<input type="number" id="vbTotalDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" step="0.25" min="0"> <input type="number" id="vbTotalDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" step="0.25">
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">사용 일수</label> <label class="block text-xs font-medium text-gray-600 mb-1">사용 일수</label>
@@ -2424,7 +2424,7 @@
<script src="/static/js/tkuser-issue-types.js?v=2026031401"></script> <script src="/static/js/tkuser-issue-types.js?v=2026031401"></script>
<script src="/static/js/tkuser-workplaces.js?v=2026031401"></script> <script src="/static/js/tkuser-workplaces.js?v=2026031401"></script>
<script src="/static/js/tkuser-tasks.js?v=2026031401"></script> <script src="/static/js/tkuser-tasks.js?v=2026031401"></script>
<script src="/static/js/tkuser-vacations.js?v=2026033104"></script> <script src="/static/js/tkuser-vacations.js?v=2026033105"></script>
<script src="/static/js/tkuser-vacation-settings.js?v=2026032501"></script> <script src="/static/js/tkuser-vacation-settings.js?v=2026032501"></script>
<script src="/static/js/tkuser-layout-map.js?v=2026031401"></script> <script src="/static/js/tkuser-layout-map.js?v=2026031401"></script>
<script src="/static/js/tkuser-partners.js?v=2026031601"></script> <script src="/static/js/tkuser-partners.js?v=2026031601"></script>