Files
sam-manage/app/Services/HR/LeaveService.php
김보곤 617c89a33f fix: [approval] 연차사용촉진 통지서 Employee 모델 속성 수정
- departments->first() → department? (BelongsTo 단수 관계)
- $emp->name → $emp->display_name
- $emp->position → $emp->position_key
- $emp->id → $emp->user_id
- LeaveService에 department eager load 추가
2026-03-07 00:33:36 +09:00

1012 lines
34 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');
$leaveType = $data['leave_type'];
// 사유서는 days=0, 그 외는 자동 계산
if (in_array($leaveType, Leave::REASON_REPORT_TYPES)) {
$days = 0;
} else {
$days = $this->calculateDays($leaveType, $data['start_date'], $data['end_date']);
}
// 연차 차감 대상이면 잔여일수 검증
if (in_array($leaveType, 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, $leaveType) {
$leave = Leave::create([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'leave_type' => $leaveType,
'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 반영
$this->applyAttendanceByType($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 deleteLeave(int $id): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->find($id);
if (! $leave) {
return null;
}
return DB::transaction(function () use ($leave, $tenantId) {
// 연결된 결재 취소 시도
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);
}
}
}
// 승인된 연차 차감 복원
if ($leave->status === 'approved' && $leave->is_deductible) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($leave->user_id)
->forYear($leave->start_date->year)
->first();
$balance?->restoreLeave($leave->days);
}
$leave->update(['deleted_by' => auth()->id()]);
$leave->delete();
return $leave;
});
}
/**
* 휴가/신청 영구삭제 (슈퍼관리자 전용)
*/
public function forceDeleteLeave(int $id): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::withTrashed()
->forTenant($tenantId)
->find($id);
if (! $leave) {
return null;
}
return DB::transaction(function () use ($leave, $tenantId) {
// 승인된 연차 차감 복원 (아직 soft-deleted 아닌 경우)
if ($leave->status === 'approved' && $leave->is_deductible && ! $leave->trashed()) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($leave->user_id)
->forYear($leave->start_date->year)
->first();
$balance?->restoreLeave($leave->days);
}
// 연결된 결재 정리
if ($leave->approval_id) {
$approval = Approval::withTrashed()->find($leave->approval_id);
$approval?->forceDelete();
}
$leave->forceDelete();
return $leave;
});
}
/**
* 결재 승인에 의한 휴가/근태신청/사유서 자동 승인
*/
public function approveByApproval(Leave $leave, Approval $approval): Leave
{
$tenantId = $leave->tenant_id;
// 최종 결재자 ID 찾기
$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(),
]);
// 연차 차감 (DEDUCTIBLE_TYPES만)
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->applyAttendanceByType($leave, $tenantId);
return $leave->fresh(['user', 'approver']);
}
/**
* 유형별 Attendance 반영 분기
*/
private function applyAttendanceByType(Leave $leave, int $tenantId): void
{
$attendanceStatus = Leave::ATTENDANCE_STATUS_MAP[$leave->leave_type] ?? null;
if ($attendanceStatus) {
// 휴가/출장/재택/외근 → Attendance 자동 생성
$this->createAttendanceRecords($leave, $tenantId, $attendanceStatus);
} elseif ($leave->leave_type === 'early_leave') {
// 조퇴 → 기존 Attendance에 비고 기록
$this->markEarlyLeave($leave, $tenantId);
} elseif (in_array($leave->leave_type, Leave::REASON_REPORT_TYPES)) {
// 지각/결근 사유서 → 기존 Attendance에 사유 기록
$this->addReasonToAttendance($leave, $tenantId);
}
}
/**
* 조퇴 승인 시 기존 Attendance에 비고 기록
*/
private function markEarlyLeave(Leave $leave, int $tenantId): void
{
$attendance = Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('base_date', $leave->start_date->toDateString())
->first();
if ($attendance) {
$remarks = $attendance->remarks;
$reason = $leave->reason ? " ({$leave->reason})" : '';
$attendance->update([
'remarks' => trim(($remarks ? $remarks.' / ' : '').'조퇴'.$reason),
'updated_by' => auth()->id(),
]);
}
}
/**
* 지각/결근 사유서 승인 시 기존 Attendance에 사유 기록
*/
private function addReasonToAttendance(Leave $leave, int $tenantId): void
{
$attendance = Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('base_date', $leave->start_date->toDateString())
->first();
if ($attendance) {
$typeName = $leave->leave_type === 'late_reason' ? '지각사유' : '결근사유';
$reason = $leave->reason ?? '';
$remarks = $attendance->remarks;
$attendance->update([
'remarks' => trim(($remarks ? $remarks.' / ' : '')."[{$typeName}] {$reason}"),
'updated_by' => auth()->id(),
]);
}
}
/**
* 결재 반려에 의한 휴가 자동 반려
*/
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, ?string $empStatus = null): Collection
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
// (1) 테넌트 연차 정책 조회
$policy = LeavePolicy::forTenant($tenantId)->first();
// (2) 재직상태 필터에 따른 직원 조회
$statusFilter = match ($empStatus) {
'active' => ['active', 'leave'],
'resigned' => ['resigned'],
default => ['active', 'leave', 'resigned'],
};
$employees = Employee::query()
->with(['user:id,name', 'department:id,name'])
->forTenant($tenantId)
->whereIn('employee_status', $statusFilter)
->where(function ($q) {
$q->whereDoesntHave('department', function ($dq) {
$dq->where('name', 'like', '%영업팀%');
})->orWhereNull('department_id');
})
->where(function ($q) {
$q->whereNull('json_extra->is_excluded')
->orWhere('json_extra->is_excluded', false);
})
->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))
->where('name', 'not like', '%영업팀%')
->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', 'department:id,name'])
->forTenant($tenantId)
->activeEmployees()
->where(function ($q) {
$q->whereDoesntHave('department', function ($dq) {
$dq->where('name', 'like', '%영업팀%');
})->orWhereNull('department_id');
})
->where(function ($q) {
$q->whereNull('json_extra->is_excluded')
->orWhere('json_extra->is_excluded', false);
})
->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_CODE_MAP으로 동적 결정)
$formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave';
$form = ApprovalForm::where('code', $formCode)
->where('tenant_id', $tenantId)
->where('is_active', true)
->first();
if (! $form) {
$formNames = ['leave' => '휴가신청', 'attendance_request' => '근태신청', 'reason_report' => '사유서'];
$formName = $formNames[$formCode] ?? $formCode;
throw new \RuntimeException("{$formName} 결재 양식이 등록되지 않았습니다.");
}
// 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');
$titlePrefix = match ($formCode) {
'attendance_request' => '근태신청',
'reason_report' => '사유서',
default => '휴가신청',
};
$approval = $approvalService->createApproval([
'form_id' => $form->id,
'line_id' => $line->id,
'title' => "{$titlePrefix} - {$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;
$formCode = Leave::FORM_CODE_MAP[$leave->leave_type] ?? 'leave';
// 부서 정보
$profile = $user?->tenantProfiles?->where('tenant_id', $tenantId)->first();
$deptName = $profile?->department?->name ?? '';
// 공통 행
$rows = [['신청자', e($user->name ?? '')]];
if ($deptName) {
$rows[] = ['부서', e($deptName)];
}
$rows[] = ['유형', $typeName];
// 사유서: 대상일 + 사유
if ($formCode === 'reason_report') {
$rows[] = ['대상일', $leave->start_date->format('Y-m-d')];
if ($leave->reason) {
$rows[] = ['사유', e($leave->reason)];
}
$intro = '아래와 같이 사유서를 제출합니다.';
} else {
// 휴가/근태신청: 기간 + 일수
$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.'일';
$rows[] = ['기간', $period.' ('.$daysStr.')'];
if ($leave->reason) {
$rows[] = ['사유', e($leave->reason)];
}
// 잔여연차 (연차 차감 대상만)
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) {
$rows[] = ['잔여연차', $balance->remaining.'일 (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')'];
}
}
$intro = match ($formCode) {
'attendance_request' => '아래와 같이 근태를 신청합니다.',
default => '아래와 같이 휴가를 신청합니다.',
};
}
// HTML 테이블 생성
$html = "<p>{$intro}</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 자동 생성
*/
private function createAttendanceRecords(Leave $leave, int $tenantId, string $status = 'vacation'): 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' => $status,
'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();
}
}