/** * 근태 관리 서비스 * * 근태 기록 관련 비즈니스 로직 처리 * * @author TK-FB-Project * @since 2025-12-11 */ const AttendanceModel = require('../models/attendanceModel'); const vacationBalanceModel = require('../models/vacationBalanceModel'); const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors'); const logger = require('../utils/logger'); /** * 휴가 사용 유형 ID를 차감 일수로 변환 * vacation_type_id: 1=연차(1일), 2=반차(0.5일), 3=반반차(0.25일) */ const getVacationDays = (vacationTypeId) => { const daysMap = { 1: 1, 2: 0.5, 3: 0.25 }; return daysMap[vacationTypeId] || 0; }; /** * 일일 근태 현황 조회 */ const getDailyAttendanceStatusService = async (date) => { if (!date) { throw new ValidationError('날짜가 필요합니다', { required: ['date'], received: { date } }); } logger.info('일일 근태 현황 조회 요청', { date }); try { // 조회 전 초기화 수행 (Lazy Initialization) // 생성자는 시스템(1) 또는 요청자가 될 수 있으나, 여기서는 안전하게 1(System/Admin) 사용 // 혹은 req.user가 없으므로 서비스 레벨에서는 1로 가정하거나 파라미터로 받아야 함. // 서비스 인터페이스 변경 최소화를 위해 하드코딩 또는 안전장치. await AttendanceModel.initializeDailyRecords(date, 1); const attendanceStatus = await AttendanceModel.getWorkerAttendanceStatus(date); logger.info('일일 근태 현황 조회 성공', { date, count: attendanceStatus.length }); return attendanceStatus; } catch (error) { logger.error('일일 근태 현황 조회 실패', { date, error: error.message }); throw new DatabaseError('근태 현황 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 일일 근태 기록 조회 */ const getDailyAttendanceRecordsService = async (date, workerId = null) => { if (!date) { throw new ValidationError('날짜가 필요합니다', { required: ['date'], received: { date } }); } logger.info('일일 근태 기록 조회 요청', { date, workerId }); try { const records = await AttendanceModel.getDailyAttendanceRecords(date, workerId); logger.info('일일 근태 기록 조회 성공', { date, count: records.length }); return records; } catch (error) { logger.error('일일 근태 기록 조회 실패', { date, error: error.message }); throw new DatabaseError('근태 기록 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 기간별 근태 기록 조회 (월별 조회용) */ const getAttendanceRecordsByRangeService = async (startDate, endDate, workerId = null) => { if (!startDate || !endDate) { throw new ValidationError('시작 날짜와 종료 날짜가 필요합니다', { required: ['start_date', 'end_date'], received: { startDate, endDate } }); } logger.info('기간별 근태 기록 조회 요청', { startDate, endDate, workerId }); try { const records = await AttendanceModel.getDailyRecords(startDate, endDate, workerId); logger.info('기간별 근태 기록 조회 성공', { startDate, endDate, count: records.length }); return records; } catch (error) { logger.error('기간별 근태 기록 조회 실패', { startDate, endDate, error: error.message }); throw new DatabaseError('근태 기록 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 근태 기록 생성/업데이트 * - 휴가 기록 시 vacation_balance_details의 used_days 자동 연동 */ const upsertAttendanceRecordService = async (recordData) => { const { record_date, worker_id, total_work_hours, attendance_type_id, vacation_type_id, is_vacation_processed, overtime_approved, status, notes, created_by } = recordData; // 필수 필드 검증 if (!record_date || !worker_id) { throw new ValidationError('필수 필드가 누락되었습니다', { required: ['record_date', 'worker_id'], received: { record_date, worker_id } }); } logger.info('근태 기록 저장 요청', { record_date, worker_id, vacation_type_id }); try { // 1. 기존 기록 조회 (휴가 연동을 위해) const existingRecords = await AttendanceModel.getDailyAttendanceRecords(record_date, worker_id); const existingRecord = existingRecords.find(r => r.worker_id === worker_id); const previousVacationTypeId = existingRecord?.vacation_type_id || null; // 2. 근태 기록 저장 const result = await AttendanceModel.upsertAttendanceRecord({ record_date, worker_id, total_work_hours, work_attendance_type_id: attendance_type_id, vacation_type_id, is_overtime_approved: overtime_approved, created_by }); // 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트) const year = new Date(record_date).getFullYear(); const previousDays = getVacationDays(previousVacationTypeId); const newDays = getVacationDays(vacation_type_id); // 이전 휴가가 있었고 변경된 경우 → 복구 후 차감 if (previousDays !== newDays) { // 이전 휴가 복구 if (previousDays > 0) { await vacationBalanceModel.restoreByPriority(worker_id, year, previousDays); logger.info('휴가 잔액 복구', { worker_id, year, restored: previousDays }); } // 새 휴가 차감 if (newDays > 0) { await vacationBalanceModel.deductByPriority(worker_id, year, newDays); logger.info('휴가 잔액 차감', { worker_id, year, deducted: newDays }); } } logger.info('근태 기록 저장 성공', { record_date, worker_id }); return result; } catch (error) { logger.error('근태 기록 저장 실패', { record_date, worker_id, error: error.message }); throw new DatabaseError('근태 기록 저장 중 데이터베이스 오류가 발생했습니다'); } }; /** * 휴가 처리 */ const processVacationService = async (vacationData) => { const { record_date, worker_id, vacation_type_id } = vacationData; if (!record_date || !worker_id || !vacation_type_id) { throw new ValidationError('필수 필드가 누락되었습니다', { required: ['record_date', 'worker_id', 'vacation_type_id'], received: { record_date, worker_id, vacation_type_id } }); } logger.info('휴가 처리 요청', { record_date, worker_id, vacation_type_id }); try { const result = await AttendanceModel.processVacation({ record_date, worker_id, vacation_type_id }); logger.info('휴가 처리 성공', { record_date, worker_id }); return result; } catch (error) { logger.error('휴가 처리 실패', { record_date, worker_id, error: error.message }); throw new DatabaseError('휴가 처리 중 데이터베이스 오류가 발생했습니다'); } }; /** * 초과 근무 승인 */ const approveOvertimeService = async (overtimeData) => { const { record_date, worker_id, overtime_approved } = overtimeData; if (!record_date || !worker_id || overtime_approved === undefined) { throw new ValidationError('필수 필드가 누락되었습니다', { required: ['record_date', 'worker_id', 'overtime_approved'], received: { record_date, worker_id, overtime_approved } }); } logger.info('초과 근무 승인 요청', { record_date, worker_id, overtime_approved }); try { const result = await AttendanceModel.approveOvertime({ record_date, worker_id, overtime_approved }); logger.info('초과 근무 승인 처리 성공', { record_date, worker_id }); return result; } catch (error) { logger.error('초과 근무 승인 실패', { record_date, worker_id, error: error.message }); throw new DatabaseError('초과 근무 승인 중 데이터베이스 오류가 발생했습니다'); } }; /** * 근태 유형 목록 조회 */ const getAttendanceTypesService = async () => { logger.info('근태 유형 목록 조회 요청'); try { const types = await AttendanceModel.getAttendanceTypes(); logger.info('근태 유형 목록 조회 성공', { count: types.length }); return types; } catch (error) { logger.error('근태 유형 목록 조회 실패', { error: error.message }); throw new DatabaseError('근태 유형 목록 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 휴가 유형 목록 조회 */ const getVacationTypesService = async () => { logger.info('휴가 유형 목록 조회 요청'); try { const types = await AttendanceModel.getVacationTypes(); logger.info('휴가 유형 목록 조회 성공', { count: types.length }); return types; } catch (error) { logger.error('휴가 유형 목록 조회 실패', { error: error.message }); throw new DatabaseError('휴가 유형 목록 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 작업자 휴가 잔여 일수 조회 */ const getWorkerVacationBalanceService = async (workerId) => { if (!workerId) { throw new ValidationError('작업자 ID가 필요합니다', { required: ['worker_id'], received: { workerId } }); } logger.info('휴가 잔여 일수 조회 요청', { workerId }); try { const balance = await AttendanceModel.getWorkerVacationBalance(workerId); logger.info('휴가 잔여 일수 조회 성공', { workerId }); return balance; } catch (error) { logger.error('휴가 잔여 일수 조회 실패', { workerId, error: error.message }); throw new DatabaseError('휴가 잔여 일수 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 월별 근태 통계 조회 */ const getMonthlyAttendanceStatsService = async (year, month, workerId = null) => { if (!year || !month) { throw new ValidationError('연도와 월이 필요합니다', { required: ['year', 'month'], received: { year, month } }); } logger.info('월별 근태 통계 조회 요청', { year, month, workerId }); try { const stats = await AttendanceModel.getMonthlyAttendanceStats(year, month, workerId); logger.info('월별 근태 통계 조회 성공', { year, month }); return stats; } catch (error) { logger.error('월별 근태 통계 조회 실패', { year, month, error: error.message }); throw new DatabaseError('월별 근태 통계 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 출근 체크 목록 조회 (휴가 정보 포함) */ const getCheckinListService = async (date) => { if (!date) { throw new ValidationError('날짜가 필요합니다', { required: ['date'], received: { date } }); } logger.info('출근 체크 목록 조회 요청', { date }); try { const checkinList = await AttendanceModel.getCheckinList(date); logger.info('출근 체크 목록 조회 성공', { date, count: checkinList.length }); return checkinList; } catch (error) { logger.error('출근 체크 목록 조회 실패', { date, error: error.message }); throw new DatabaseError('출근 체크 목록 조회 중 데이터베이스 오류가 발생했습니다'); } }; /** * 출근 체크 일괄 저장 */ const saveCheckinsService = async (date, checkins) => { if (!date || !checkins || !Array.isArray(checkins)) { throw new ValidationError('날짜와 출근 체크 목록이 필요합니다', { required: ['date', 'checkins'], received: { date, checkins: checkins ? `Array[${checkins.length}]` : null } }); } logger.info('출근 체크 일괄 저장 요청', { date, count: checkins.length }); try { const results = []; for (const checkin of checkins) { const { worker_id, is_present } = checkin; if (!worker_id || is_present === undefined) { logger.warn('출근 체크 데이터 누락', { checkin }); continue; } const result = await AttendanceModel.upsertCheckin({ worker_id, record_date: date, is_present }); results.push({ worker_id, record_id: result, is_present }); } logger.info('출근 체크 일괄 저장 성공', { date, saved: results.length }); return { saved_count: results.length, results }; } catch (error) { logger.error('출근 체크 일괄 저장 실패', { date, error: error.message }); throw new DatabaseError('출근 체크 저장 중 데이터베이스 오류가 발생했습니다'); } }; module.exports = { getDailyAttendanceStatusService, getDailyAttendanceRecordsService, getAttendanceRecordsByRangeService, upsertAttendanceRecordService, processVacationService, approveOvertimeService, getAttendanceTypesService, getVacationTypesService, getWorkerVacationBalanceService, getMonthlyAttendanceStatsService, getCheckinListService, saveCheckinsService };