diff --git a/app/Models/HR/Leave.php b/app/Models/HR/Leave.php index 59c16146..b94c9f68 100644 --- a/app/Models/HR/Leave.php +++ b/app/Models/HR/Leave.php @@ -2,6 +2,7 @@ namespace App\Models\HR; +use App\Models\Approvals\Approval; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -24,6 +25,7 @@ class Leave extends Model 'status', 'approved_by', 'approved_at', + 'approval_id', 'reject_reason', 'created_by', 'updated_by', @@ -33,6 +35,7 @@ class Leave extends Model protected $casts = [ 'tenant_id' => 'int', 'user_id' => 'int', + 'approval_id' => 'int', 'approved_by' => 'int', 'created_by' => 'int', 'updated_by' => 'int', @@ -92,6 +95,11 @@ public function creator(): BelongsTo return $this->belongsTo(User::class, 'created_by'); } + public function approval(): BelongsTo + { + return $this->belongsTo(Approval::class, 'approval_id'); + } + // ========================================================================= // Accessor // ========================================================================= diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index f756d75c..e95fb54c 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -276,6 +276,9 @@ public function approve(int $id, ?string $comment = null): Approval 'completed_at' => now(), 'updated_by' => auth()->id(), ]); + + // 연동 후처리 (휴가 등) + $this->handleApprovalCompleted($approval); } return $approval->fresh(['form', 'drafter', 'steps.approver']); @@ -315,6 +318,9 @@ public function reject(int $id, string $comment): Approval 'updated_by' => auth()->id(), ]); + // 연동 후처리 (휴가 등) + $this->handleApprovalRejected($approval, $comment); + return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } @@ -358,6 +364,9 @@ public function cancel(int $id, ?string $recallReason = null): Approval 'updated_by' => auth()->id(), ]); + // 연동 후처리 (휴가 회수) + $this->handleApprovalCancelled($approval); + return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } @@ -472,6 +481,9 @@ public function preDecide(int $id, ?string $comment = null): Approval 'updated_by' => auth()->id(), ]); + // 연동 후처리 (휴가 등) + $this->handleApprovalCompleted($approval); + return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } @@ -692,6 +704,58 @@ public function getBadgeCounts(int $userId): array // Private 헬퍼 // ========================================================================= + /** + * 결재 최종 승인 시 연동 처리 (휴가 등) + */ + private function handleApprovalCompleted(Approval $approval): void + { + if (! $approval->form || $approval->form->code !== 'leave') { + return; + } + + $leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first(); + if ($leave && $leave->status === 'pending') { + app(\App\Services\HR\LeaveService::class)->approveByApproval($leave, $approval); + } + } + + /** + * 결재 반려 시 연동 처리 (휴가 등) + */ + private function handleApprovalRejected(Approval $approval, string $comment): void + { + if (! $approval->form || $approval->form->code !== 'leave') { + return; + } + + $leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first(); + if ($leave && $leave->status === 'pending') { + app(\App\Services\HR\LeaveService::class)->rejectByApproval( + $leave, + $comment, + auth()->id() + ); + } + } + + /** + * 결재 회수 시 연동 처리 (휴가 등) + */ + private function handleApprovalCancelled(Approval $approval): void + { + if (! $approval->form || $approval->form->code !== 'leave') { + return; + } + + $leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first(); + if ($leave && $leave->status === 'pending') { + $leave->update([ + 'status' => 'cancelled', + 'updated_by' => auth()->id(), + ]); + } + } + /** * 결재선 steps에 user_name, department, position 스냅샷 보강 */ diff --git a/app/Services/HR/LeaveService.php b/app/Services/HR/LeaveService.php index 9e90cf43..18a4bf31 100644 --- a/app/Services/HR/LeaveService.php +++ b/app/Services/HR/LeaveService.php @@ -2,12 +2,16 @@ 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; @@ -71,7 +75,7 @@ public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePa } /** - * 휴가 신청 등록 + * 휴가 신청 등록 → 결재 자동 생성 + 상신 */ public function storeLeave(array $data): Leave { @@ -91,18 +95,26 @@ public function storeLeave(array $data): Leave } } - return 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(), - ]); + 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); + $leave->update(['approval_id' => $approval->id]); + + return $leave; + }); } /** @@ -177,7 +189,7 @@ public function reject(int $id, ?string $reason = null): ?Leave } /** - * 취소 → LeaveBalance 복원 → Attendance 삭제 + * 취소 → LeaveBalance 복원 → Attendance 삭제 → 결재 취소 */ public function cancel(int $id): ?Leave { @@ -218,6 +230,98 @@ public function cancel(int $id): ?Leave }); } + /** + * 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 찾기 + $lastApprover = $approval->steps + ->where('status', 'approved') + ->sortByDesc('step_order') + ->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']); + } + /** * 전체 사원 잔여연차 요약 * @@ -525,6 +629,127 @@ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection // Private // ========================================================================= + /** + * 휴가신청 결재 자동 생성 + 상신 + */ + private function createLeaveApproval(Leave $leave, int $tenantId): 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. 기본결재선 조회 + $defaultLine = ApprovalLine::where('tenant_id', $tenantId) + ->where('is_default', true) + ->first(); + + if (! $defaultLine) { + throw new \RuntimeException('기본결재선을 먼저 설정해주세요.'); + } + + // 3. 결재 본문 생성 + $body = $this->buildLeaveApprovalBody($leave, $tenantId); + + // 4. steps 변환 + $steps = collect($defaultLine->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' => $defaultLine->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 = '

아래와 같이 휴가를 신청합니다.

'; + $html .= ''; + $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 .= ""; + } + $html .= '
{$label}{$value}
'; + + return $html; + } + /** * 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성 */ diff --git a/resources/views/hr/leaves/index.blade.php b/resources/views/hr/leaves/index.blade.php index 5232cf1c..e4710606 100644 --- a/resources/views/hr/leaves/index.blade.php +++ b/resources/views/hr/leaves/index.blade.php @@ -8,7 +8,7 @@

휴가관리

-

휴가 신청, 승인/반려 및 연차 현황을 관리합니다.

+

휴가 신청 시 결재가 자동 생성되며, 결재 승인/반려에 따라 휴가가 처리됩니다.

+ @elseif($leave->status === 'pending' && !$leave->approval_id) + {{-- 결재 연동 없는 기존 pending 건만 직접 승인/반려 허용 --}} -
- @elseif($leave->status === 'approved') - - @else - - - @endif + @elseif(!$leave->approval_id && $leave->status !== 'pending' && $leave->status !== 'approved') + - + @endif +
@empty