Files
sam-manage/app/Services/HR/LeaveService.php
김보곤 511bfa3ec5 feat: [leave] 휴가 신청 시 결재선 선택 기능 추가
- 휴가 신청 모달에 결재선 드롭다운 + 미리보기 UI 추가
- 선택된 결재선으로 결재 생성 (미선택 시 기본결재선 fallback)
- 휴가 목록에 결재진행 컬럼 추가 (원형 아이콘: ✓승인/✗반려/숫자대기/파랑현재)
- approval.steps.approver eager load 추가
2026-03-03 22:36:30 +09:00

813 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\HR;
use App\Models\Approvals\Approval;
use App\Models\Approvals\ApprovalForm;
use App\Models\Approvals\ApprovalLine;
use App\Models\HR\Attendance;
use App\Models\HR\Employee;
use App\Models\HR\Leave;
use App\Models\HR\LeaveBalance;
use App\Models\HR\LeavePolicy;
use App\Models\Tenants\Department;
use App\Services\ApprovalService;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LeaveService
{
/**
* 휴가 목록 조회 (필터 + 페이지네이션)
*/
public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Leave::query()
->with([
'user',
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
'user.tenantProfiles.department',
'approver',
'approval.steps.approver',
])
->forTenant($tenantId);
if (! empty($filters['q'])) {
$search = $filters['q'];
$query->whereHas('user', fn ($q) => $q->where('name', 'like', "%{$search}%"));
}
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (! empty($filters['department_id'])) {
$deptId = $filters['department_id'];
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
});
}
if (! empty($filters['leave_type'])) {
$query->where('leave_type', $filters['leave_type']);
}
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
$query->betweenDates($filters['date_from'], $filters['date_to']);
} elseif (! empty($filters['date_from'])) {
$query->where('start_date', '>=', $filters['date_from']);
} elseif (! empty($filters['date_to'])) {
$query->where('end_date', '<=', $filters['date_to']);
}
return $query
->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected', 'cancelled')")
->orderBy('created_at', 'desc')
->paginate($perPage);
}
/**
* 휴가 신청 등록 → 결재 자동 생성 + 상신
*/
public function storeLeave(array $data): Leave
{
$tenantId = session('selected_tenant_id');
$days = $this->calculateDays($data['leave_type'], $data['start_date'], $data['end_date']);
// 연차 차감 대상이면 잔여일수 검증
if (in_array($data['leave_type'], Leave::DEDUCTIBLE_TYPES)) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($data['user_id'])
->forYear(now()->year)
->first();
if (! $balance || ! $balance->canUse($days)) {
throw new \RuntimeException('잔여 연차가 부족합니다.');
}
}
return DB::transaction(function () use ($data, $tenantId, $days) {
$leave = Leave::create([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'leave_type' => $data['leave_type'],
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'days' => $days,
'reason' => $data['reason'] ?? null,
'status' => 'pending',
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
// 결재 자동 생성 + 상신 (선택된 결재선 전달)
$approval = $this->createLeaveApproval($leave, $tenantId, $data['approval_line_id'] ?? null);
$leave->update(['approval_id' => $approval->id]);
return $leave;
});
}
/**
* 승인 → LeaveBalance 차감 → Attendance 자동 생성
*/
public function approve(int $id): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->withStatus('pending')
->find($id);
if (! $leave) {
return null;
}
return DB::transaction(function () use ($leave, $tenantId) {
$leave->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
'updated_by' => auth()->id(),
]);
// 연차 차감 대상이면 LeaveBalance 차감
if ($leave->is_deductible) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($leave->user_id)
->forYear($leave->start_date->year)
->first();
if ($balance) {
$balance->useLeave($leave->days);
}
}
// 기간 내 영업일마다 Attendance(vacation) 자동 생성
$this->createAttendanceRecords($leave, $tenantId);
return $leave->fresh(['user', 'approver']);
});
}
/**
* 반려
*/
public function reject(int $id, ?string $reason = null): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->withStatus('pending')
->find($id);
if (! $leave) {
return null;
}
$leave->update([
'status' => 'rejected',
'approved_by' => auth()->id(),
'approved_at' => now(),
'reject_reason' => $reason,
'updated_by' => auth()->id(),
]);
return $leave->fresh(['user', 'approver']);
}
/**
* 취소 → LeaveBalance 복원 → Attendance 삭제 → 결재 취소
*/
public function cancel(int $id): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->withStatus('approved')
->find($id);
if (! $leave) {
return null;
}
return DB::transaction(function () use ($leave, $tenantId) {
$leave->update([
'status' => 'cancelled',
'updated_by' => auth()->id(),
]);
// 연차 차감 대상이면 LeaveBalance 복원
if ($leave->is_deductible) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($leave->user_id)
->forYear($leave->start_date->year)
->first();
if ($balance) {
$balance->restoreLeave($leave->days);
}
}
// 해당 기간 vacation Attendance soft delete
$this->deleteAttendanceRecords($leave, $tenantId);
return $leave->fresh(['user']);
});
}
/**
* pending 상태 휴가 삭제 → 연결된 결재 취소
*/
public function deletePendingLeave(int $id): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->withStatus('pending')
->find($id);
if (! $leave) {
return null;
}
return DB::transaction(function () use ($leave) {
// 연결된 결재가 있고 아직 진행 중이면 취소
if ($leave->approval_id) {
$approval = Approval::find($leave->approval_id);
if ($approval && in_array($approval->status, ['draft', 'pending'])) {
try {
app(ApprovalService::class)->cancel($approval->id);
} catch (\Throwable $e) {
// 결재 취소 실패해도 휴가 삭제는 진행
report($e);
}
}
}
$leave->update(['deleted_by' => auth()->id()]);
$leave->delete();
return $leave;
});
}
/**
* 결재 승인에 의한 휴가 자동 승인
*/
public function approveByApproval(Leave $leave, Approval $approval): Leave
{
$tenantId = $leave->tenant_id;
// 최종 결재자 ID 찾기 (DB에서 fresh 조회, 기본 정렬 제거 후 역순)
$lastApprover = $approval->steps()
->where('status', 'approved')
->reorder('step_order', 'desc')
->first();
$leave->update([
'status' => 'approved',
'approved_by' => $lastApprover?->approver_id ?? auth()->id(),
'approved_at' => now(),
'updated_by' => auth()->id(),
]);
// 연차 차감
if ($leave->is_deductible) {
$balance = LeaveBalance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('year', $leave->start_date->year)
->first();
if ($balance) {
$balance->useLeave($leave->days);
}
}
// Attendance 생성
$this->createAttendanceRecords($leave, $tenantId);
return $leave->fresh(['user', 'approver']);
}
/**
* 결재 반려에 의한 휴가 자동 반려
*/
public function rejectByApproval(Leave $leave, string $comment, int $rejecterId): Leave
{
$leave->update([
'status' => 'rejected',
'reject_reason' => $comment,
'approved_by' => $rejecterId,
'approved_at' => now(),
'updated_by' => auth()->id(),
]);
return $leave->fresh(['user', 'approver']);
}
/**
* 전체 사원 잔여연차 요약
*
* 사원관리의 모든 재직/휴직 직원을 표시하며,
* balance 레코드가 없는 직원은 자동 생성한다.
*/
public function getBalanceSummary(?int $year = null, ?string $sort = null, ?string $direction = null): Collection
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
// (1) 테넌트 연차 정책 조회
$policy = LeavePolicy::forTenant($tenantId)->first();
// (2) 재직/휴직/퇴사 직원 전체 조회
$employees = Employee::query()
->with(['user:id,name', 'department:id,name'])
->forTenant($tenantId)
->whereIn('employee_status', ['active', 'leave', 'resigned'])
->get();
// (3) 기존 balance 일괄 조회
$existingBalances = LeaveBalance::query()
->forTenant($tenantId)
->forYear($year)
->get()
->keyBy('user_id');
// (4) balance 없는 직원 → insertOrIgnore로 자동 생성
$newRecords = [];
foreach ($employees as $employee) {
if (! $existingBalances->has($employee->user_id)) {
$newRecords[] = [
'tenant_id' => $tenantId,
'user_id' => $employee->user_id,
'year' => $year,
'total_days' => $this->calculateAnnualLeaveDays($employee, $year, $policy),
'used_days' => 0,
'created_at' => now(),
'updated_at' => now(),
];
}
}
if (! empty($newRecords)) {
LeaveBalance::insertOrIgnore($newRecords);
// 새로 생성된 레코드 포함하여 다시 조회
$existingBalances = LeaveBalance::query()
->forTenant($tenantId)
->forYear($year)
->get()
->keyBy('user_id');
}
// (5) Employee 정보를 balance에 연결
$employeesByUserId = $employees->keyBy('user_id');
// (6) 현재연도 + 1년 미만 직원은 매번 재계산 (월별 발생 방식)
// 퇴사자는 퇴사일 기준으로 확정되므로 재계산 대상에서 제외
if ($year === (int) now()->year) {
foreach ($existingBalances as $balance) {
$employee = $employeesByUserId->get($balance->user_id);
if (! $employee || ! $employee->hire_date) {
continue;
}
if ($employee->employee_status === 'resigned') {
continue;
}
$hire = Carbon::parse($employee->hire_date);
if ($hire->diffInMonths(today()) < 12) {
$recalculated = $this->calculateAnnualLeaveDays($employee, $year, $policy);
if (abs($balance->total_days - $recalculated) > 0.001) {
$balance->update(['total_days' => $recalculated]);
}
}
}
}
$result = $existingBalances
->filter(fn ($balance) => $employeesByUserId->has($balance->user_id))
->map(function ($balance) use ($employeesByUserId) {
$employee = $employeesByUserId->get($balance->user_id);
$balance->employee = $employee;
return $balance;
});
// 정렬 (기본: 입사일 오름차순)
$sortField = $sort ?? 'hire_date';
$isDesc = ($direction ?? 'asc') === 'desc';
$sortCallback = match ($sortField) {
'name' => fn ($b) => $b->employee?->display_name ?? '',
'department' => fn ($b) => $b->employee?->department?->name ?? '',
'hire_date' => fn ($b) => $b->employee?->hire_date ?? '9999-12-31',
'status' => fn ($b) => match ($b->employee?->employee_status) {
'active' => 1, 'leave' => 2, 'resigned' => 3, default => 9,
},
'resign_date' => fn ($b) => $b->employee?->resign_date ?? '9999-12-31',
'total_days' => fn ($b) => $b->total_days,
'used_days' => fn ($b) => $b->used_days,
'remaining' => fn ($b) => $b->total_days - $b->used_days,
'rate' => fn ($b) => $b->total_days > 0 ? $b->used_days / $b->total_days : 0,
default => fn ($b) => $b->employee?->hire_date ?? '9999-12-31',
};
return ($isDesc ? $result->sortByDesc($sortCallback) : $result->sortBy($sortCallback))->values();
}
/**
* 입사일 기반 연차일수 자동 산출 (근로기준법 제60조)
*
* - 입사일 없음 → default_annual_leave (기본 15일)
* - 1년 미만 → 입사일~기준일 완료 월수 (최대 11일, 월별 발생)
* - 1년 이상 → 15일 + 매 2년마다 +1일 (최대 max_annual_leave)
*/
private function calculateAnnualLeaveDays(Employee $employee, int $year, ?LeavePolicy $policy): float
{
$defaultDays = $policy->default_annual_leave ?? 15;
$maxDays = $policy->max_annual_leave ?? 25;
$hireDate = $employee->hire_date;
if (! $hireDate) {
return (float) $defaultDays;
}
$hire = Carbon::parse($hireDate);
// 기준일: 현재연도면 오늘, 과거연도면 연말
$referenceDate = $year === (int) now()->year
? today()
: Carbon::create($year, 12, 31);
// 퇴사자: 기준일을 퇴사일로 제한
$resignDate = $employee->resign_date;
if ($resignDate) {
$resign = Carbon::parse($resignDate);
if ($resign->lessThan($referenceDate)) {
$referenceDate = $resign;
}
}
// 아직 입사 전이면 0일
if ($hire->greaterThan($referenceDate)) {
return 0;
}
// 입사일~기준일 완료 월수
$totalMonthsWorked = (int) $hire->diffInMonths($referenceDate);
// 1년 미만: 완료된 월수 × 1일 (최대 11일)
if ($totalMonthsWorked < 12) {
return (float) min($totalMonthsWorked, 11);
}
// 1년 이상: 15일 + 매 2년마다 +1일
$yearsWorked = (int) $hire->diffInYears($referenceDate);
$additionalDays = (int) floor(($yearsWorked - 1) / 2);
$totalDays = $defaultDays + $additionalDays;
return (float) min($totalDays, $maxDays);
}
/**
* 개별 사원 잔여연차
*/
public function getUserBalance(int $userId, ?int $year = null): ?LeaveBalance
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
return LeaveBalance::query()
->forTenant($tenantId)
->forUser($userId)
->forYear($year)
->first();
}
/**
* 유형별/사원별 사용 통계
*/
public function getUsageStats(?int $year = null): array
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
// 유형별 집계
$byType = Leave::query()
->forTenant($tenantId)
->forYear($year)
->withStatus('approved')
->select('leave_type', DB::raw('COUNT(*) as count'), DB::raw('SUM(days) as total_days'))
->groupBy('leave_type')
->get()
->keyBy('leave_type');
// 사원별 유형 크로스 테이블
$byUser = Leave::query()
->forTenant($tenantId)
->forYear($year)
->withStatus('approved')
->with([
'user',
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
'user.tenantProfiles.department',
])
->select('user_id', 'leave_type', DB::raw('SUM(days) as total_days'))
->groupBy('user_id', 'leave_type')
->get()
->groupBy('user_id');
return [
'by_type' => $byType,
'by_user' => $byUser,
'year' => $year,
];
}
/**
* 일수 자동 계산 (반차 = 0.5, 연차/기타 = 영업일수)
*/
public function calculateDays(string $type, string $start, string $end): float
{
if (in_array($type, ['half_am', 'half_pm'])) {
return 0.5;
}
$period = CarbonPeriod::create($start, $end);
$businessDays = 0;
foreach ($period as $date) {
if (! $date->isWeekend()) {
$businessDays++;
}
}
return (float) $businessDays;
}
/**
* CSV 내보내기용 데이터
*/
public function getExportData(array $filters = []): Collection
{
$tenantId = session('selected_tenant_id');
$query = Leave::query()
->with([
'user',
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
'user.tenantProfiles.department',
'approver',
])
->forTenant($tenantId);
if (! empty($filters['status'])) {
$query->withStatus($filters['status']);
}
if (! empty($filters['leave_type'])) {
$query->where('leave_type', $filters['leave_type']);
}
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
$query->betweenDates($filters['date_from'], $filters['date_to']);
}
return $query->orderBy('start_date', 'desc')->get();
}
/**
* 부서 목록
*/
public function getDepartments(): \Illuminate\Database\Eloquent\Collection
{
$tenantId = session('selected_tenant_id');
return Department::query()
->where('is_active', true)
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'code']);
}
/**
* 활성 사원 목록 (드롭다운용)
*/
public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
{
$tenantId = session('selected_tenant_id');
return \App\Models\HR\Employee::query()
->with('user:id,name')
->forTenant($tenantId)
->activeEmployees()
->orderBy('display_name')
->get(['id', 'user_id', 'display_name', 'department_id']);
}
// =========================================================================
// Private
// =========================================================================
/**
* 휴가신청 결재 자동 생성 + 상신
*/
private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approvalLineId = null): Approval
{
$approvalService = app(ApprovalService::class);
// 1. 휴가신청 양식 조회
$form = ApprovalForm::where('code', 'leave')
->where('tenant_id', $tenantId)
->where('is_active', true)
->first();
if (! $form) {
throw new \RuntimeException('휴가신청 결재 양식이 등록되지 않았습니다.');
}
// 2. 결재선 조회: 지정된 ID 우선, 없으면 기본결재선
$line = null;
if ($approvalLineId) {
$line = ApprovalLine::where('tenant_id', $tenantId)->find($approvalLineId);
}
if (! $line) {
$line = ApprovalLine::where('tenant_id', $tenantId)
->where('is_default', true)
->first();
}
if (! $line) {
throw new \RuntimeException('결재선을 찾을 수 없습니다. 기본결재선을 설정하거나 결재선을 선택해주세요.');
}
// 3. 결재 본문 생성
$body = $this->buildLeaveApprovalBody($leave, $tenantId);
// 4. steps 변환
$steps = collect($line->steps)->map(fn ($s) => [
'user_id' => $s['user_id'],
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
])->toArray();
// 5. 결재 생성
$typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
$userName = $leave->user->name ?? '';
$period = $leave->start_date->format('n/j').'~'.$leave->end_date->format('n/j');
$approval = $approvalService->createApproval([
'form_id' => $form->id,
'line_id' => $line->id,
'title' => "휴가신청 - {$userName} ({$typeName} {$period})",
'body' => $body,
'content' => [
'leave_id' => $leave->id,
'user_name' => $userName,
'leave_type' => $typeName,
'start_date' => $leave->start_date->toDateString(),
'end_date' => $leave->end_date->toDateString(),
'days' => $leave->days,
'reason' => $leave->reason,
],
'is_urgent' => false,
'steps' => $steps,
]);
// 6. 자동 상신
$approvalService->submit($approval->id);
return $approval->fresh();
}
/**
* 결재 본문 HTML 생성
*/
private function buildLeaveApprovalBody(Leave $leave, int $tenantId): string
{
$user = $leave->user;
$typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
$period = $leave->start_date->format('Y-m-d').' ~ '.$leave->end_date->format('Y-m-d');
$daysStr = ($leave->days == (int) $leave->days) ? (int) $leave->days.'일' : $leave->days.'일';
// 잔여연차 정보
$balanceInfo = '';
if (in_array($leave->leave_type, Leave::DEDUCTIBLE_TYPES)) {
$balance = LeaveBalance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('year', now()->year)
->first();
if ($balance) {
$balanceInfo = $balance->remaining.'일'
.' (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')';
}
}
// 부서 정보
$profile = $user?->tenantProfiles?->where('tenant_id', $tenantId)->first();
$deptName = $profile?->department?->name ?? '';
// HTML 테이블 본문
$rows = [
['신청자', e($user->name ?? '')],
];
if ($deptName) {
$rows[] = ['부서', e($deptName)];
}
$rows[] = ['휴가유형', $typeName];
$rows[] = ['기간', $period.' ('.$daysStr.')'];
if ($leave->reason) {
$rows[] = ['사유', e($leave->reason)];
}
if ($balanceInfo) {
$rows[] = ['잔여연차', $balanceInfo];
}
$html = '<p>아래와 같이 휴가를 신청합니다.</p>';
$html .= '<table style="border-collapse:collapse; width:100%; margin-top:12px; font-size:14px;">';
$thStyle = 'style="padding:8px 12px; background:#f8f9fa; border:1px solid #dee2e6; text-align:left; width:120px; font-weight:600;"';
$tdStyle = 'style="padding:8px 12px; border:1px solid #dee2e6;"';
foreach ($rows as [$label, $value]) {
$html .= "<tr><th {$thStyle}>{$label}</th><td {$tdStyle}>{$value}</td></tr>";
}
$html .= '</table>';
return $html;
}
/**
* 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성
*/
private function createAttendanceRecords(Leave $leave, int $tenantId): void
{
$period = CarbonPeriod::create($leave->start_date, $leave->end_date);
foreach ($period as $date) {
if ($date->isWeekend()) {
continue;
}
Attendance::updateOrCreate(
[
'tenant_id' => $tenantId,
'user_id' => $leave->user_id,
'base_date' => $date->toDateString(),
],
[
'status' => 'vacation',
'remarks' => $leave->reason ? mb_substr($leave->reason, 0, 100) : null,
'updated_by' => auth()->id(),
]
);
}
}
/**
* 취소 시 해당 기간 vacation Attendance soft delete
*/
private function deleteAttendanceRecords(Leave $leave, int $tenantId): void
{
Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('status', 'vacation')
->whereBetween('base_date', [
$leave->start_date->toDateString(),
$leave->end_date->toDateString(),
])
->update(['deleted_by' => auth()->id()]);
Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('status', 'vacation')
->whereBetween('base_date', [
$leave->start_date->toDateString(),
$leave->end_date->toDateString(),
])
->delete();
}
}