Files
tk-factory-services/system1-factory/api/models/vacationBalanceModel.js
Hyungi Ahn ba2e3481e9 feat(vacation): 이월연차 만료 시스템 + 대시보드 합산 개선
- createBalance/bulkUpsert에서 CARRY_OVER expires_at 자동 설정
  (carry_over_expiry_month 설정 기반, 기본값 2월말)
- deductByPriority/deductDays 쿼리에 만료 필터 추가
  (expires_at >= CURDATE() 조건, 만료된 이월 차감 제외)
- 대시보드 합산에서 만료된 잔액 제외
- DB 보정 완료: CARRY_OVER 8건 expires_at 설정,
  황인용/최광욱 소급 재계산 (이월 우선 차감 반영)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:27:17 +09:00

337 lines
11 KiB
JavaScript

/**
* vacationBalanceModel.js
* 휴가 잔액 관련 데이터베이스 쿼리 모델
*/
const { getDb } = require('../dbPool');
const vacationBalanceModel = {
/**
* 특정 작업자의 모든 휴가 잔액 조회 (특정 연도)
*/
async getByWorkerAndYear(userId, year) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
svb.total_days, svb.used_days,
(svb.total_days - svb.used_days) AS remaining_days,
svb.balance_type, svb.expires_at, svb.notes,
svb.created_by, svb.created_at, svb.updated_at,
vt.type_name, vt.type_code, vt.priority, vt.is_special
FROM sp_vacation_balances svb
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE svb.user_id = ? AND svb.year = ?
ORDER BY vt.priority ASC, vt.type_name ASC
`, [userId, year]);
return rows;
},
/**
* 특정 작업자의 특정 휴가 유형 잔액 조회
*/
async getByWorkerTypeYear(userId, vacationTypeId, year) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
svb.total_days, svb.used_days,
(svb.total_days - svb.used_days) AS remaining_days,
svb.balance_type, svb.expires_at,
vt.type_name, vt.type_code
FROM sp_vacation_balances svb
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE svb.user_id = ?
AND svb.vacation_type_id = ?
AND svb.year = ?
`, [userId, vacationTypeId, year]);
return rows;
},
/**
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
*/
async getAllByYear(year) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
svb.id, svb.user_id, svb.vacation_type_id, svb.year,
svb.total_days, svb.used_days,
(svb.total_days - svb.used_days) AS remaining_days,
svb.balance_type, svb.expires_at, svb.notes,
svb.created_by, svb.created_at, svb.updated_at,
w.worker_name, w.employment_status,
vt.type_name, vt.type_code, vt.priority
FROM sp_vacation_balances svb
INNER JOIN workers w ON svb.user_id = w.user_id
INNER JOIN vacation_types vt ON svb.vacation_type_id = vt.id
WHERE svb.year = ?
AND w.employment_status = 'employed'
ORDER BY w.worker_name ASC, vt.priority ASC
`, [year]);
return rows;
},
/**
* 휴가 잔액 생성
*/
async create(balanceData) {
const db = await getDb();
const [result] = await db.query(`INSERT INTO sp_vacation_balances SET ?`, balanceData);
return result;
},
/**
* 휴가 잔액 수정
*/
async update(id, updateData) {
const db = await getDb();
const [result] = await db.query(`UPDATE sp_vacation_balances SET ? WHERE id = ?`, [updateData, id]);
return result;
},
/**
* 휴가 잔액 삭제
*/
async delete(id) {
const db = await getDb();
const [result] = await db.query(`DELETE FROM sp_vacation_balances WHERE id = ?`, [id]);
return result;
},
/**
* 작업자의 휴가 사용 일수 업데이트 (차감)
*/
async deductDays(userId, vacationTypeId, year, daysToDeduct) {
const db = await getDb();
const [result] = await db.query(`
UPDATE sp_vacation_balances
SET used_days = used_days + ?,
updated_at = NOW()
WHERE user_id = ?
AND vacation_type_id = ?
AND year = ?
`, [daysToDeduct, userId, vacationTypeId, year]);
return result;
},
/**
* 작업자의 휴가 사용 일수 복구 (취소)
*/
async restoreDays(userId, vacationTypeId, year, daysToRestore) {
const db = await getDb();
const [result] = await db.query(`
UPDATE sp_vacation_balances
SET used_days = GREATEST(0, used_days - ?),
updated_at = NOW()
WHERE user_id = ?
AND vacation_type_id = ?
AND year = ?
`, [daysToRestore, userId, vacationTypeId, year]);
return result;
},
/**
* 특정 작업자의 사용 가능한 휴가 일수 확인
*/
async getAvailableVacationDays(userId, year) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
svb.id,
svb.vacation_type_id,
vt.type_name,
vt.type_code,
vt.priority,
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;
},
/**
* 작업자별 휴가 잔액 일괄 생성 (연도별)
*/
async bulkCreate(balances) {
if (!balances || balances.length === 0) {
throw new Error('생성할 휴가 잔액 데이터가 없습니다');
}
const db = await getDb();
const query = `INSERT INTO sp_vacation_balances
(user_id, vacation_type_id, year, total_days, used_days, notes, created_by, balance_type)
VALUES ?`;
const values = balances.map(b => [
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'
]);
const [result] = await db.query(query, [values]);
return result;
},
/**
* 근속년수 기반 연차 일수 계산 (한국 근로기준법)
*/
calculateAnnualLeaveDays(hireDate, targetYear) {
const hire = new Date(hireDate);
const targetDate = new Date(targetYear, 0, 1);
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
+ (targetDate.getMonth() - hire.getMonth());
if (monthsDiff < 12) {
return Math.floor(monthsDiff);
}
const yearsWorked = Math.floor(monthsDiff / 12);
const additionalDays = Math.floor((yearsWorked - 1) / 2);
return Math.min(15 + additionalDays, 25);
},
/**
* 휴가 사용 시 우선순위에 따라 잔액에서 차감
*/
async deductByPriority(userId, year, daysToDeduct) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
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
AND (svb.expires_at IS NULL OR svb.expires_at >= CURDATE())
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) {
await conn.rollback();
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 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();
}
},
/**
* 휴가 취소 시 우선순위 역순으로 복구
*/
async restoreByPriority(userId, year, daysToRestore) {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
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 = [];
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;
}
}
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();
}
},
/**
* 특정 ID로 휴가 잔액 조회
*/
async getById(id) {
const db = await getDb();
const [rows] = await db.query(`
SELECT
vbd.*,
w.worker_name,
vt.type_name,
vt.type_code
FROM vacation_balance_details vbd
INNER JOIN workers w ON vbd.user_id = w.user_id
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.id = ?
`, [id]);
return rows;
}
};
module.exports = vacationBalanceModel;