user(); $query = Approval::with(['form', 'drafter', 'steps.approver']); // 슈퍼관리자는 전체 조회, 일반 사용자는 본인 기안만 if (! $user->isSuperAdmin()) { $query->byDrafter($user->id); } $this->applyFilters($query, $filters); return $query->orderByDesc('created_at')->paginate($perPage); } /** * 결재 대기함 (내가 현재 결재자인 문서) */ public function getPendingForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator { $query = Approval::with(['form', 'drafter', 'steps.approver']) ->pending() ->whereHas('steps', function ($q) use ($userId) { $q->where('approver_id', $userId) ->where('status', ApprovalStep::STATUS_PENDING) ->approvalOnly() ->where('step_order', function ($sub) { $sub->selectRaw('MIN(step_order)') ->from('approval_steps as inner_steps') ->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id') ->where('inner_steps.status', ApprovalStep::STATUS_PENDING) ->whereIn('inner_steps.step_type', [ ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT, ]); }); }); $this->applyFilters($query, $filters); return $query->orderByDesc('drafted_at')->paginate($perPage); } /** * 처리 완료함 (내가 기안한 완료 문서 + 내가 결재 처리한 문서) */ public function getCompletedByMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator { $query = Approval::with(['form', 'drafter', 'steps.approver']) ->where(function ($q) use ($userId) { // 내가 기안한 완료/반려/회수 문서 $q->where(function ($sub) use ($userId) { $sub->where('drafter_id', $userId) ->whereIn('status', [ Approval::STATUS_APPROVED, Approval::STATUS_REJECTED, Approval::STATUS_CANCELLED, ]); }) // 또는 내가 결재자로 처리한 문서 ->orWhereHas('steps', function ($sub) use ($userId) { $sub->where('approver_id', $userId) ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]); }); }); $this->applyFilters($query, $filters); return $query->orderByDesc('updated_at')->paginate($perPage); } /** * 참조함 (내가 참조자인 문서) */ public function getReferencesForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator { $query = Approval::with(['form', 'drafter', 'steps.approver']) ->whereHas('steps', function ($q) use ($userId, $filters) { $q->where('approver_id', $userId) ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); if (isset($filters['is_read'])) { $q->where('is_read', $filters['is_read'] === 'true' || $filters['is_read'] === '1'); } }); $this->applyFilters($query, $filters); return $query->orderByDesc('created_at')->paginate($perPage); } // ========================================================================= // CRUD // ========================================================================= /** * 상세 조회 */ public function getApproval(int $id): Approval { return Approval::with(['form', 'drafter', 'line', 'steps.approver']) ->findOrFail($id); } /** * 생성 (임시저장) */ public function createApproval(array $data): Approval { return DB::transaction(function () use ($data) { $tenantId = session('selected_tenant_id'); $userId = auth()->id(); $approval = Approval::create([ 'tenant_id' => $tenantId, 'document_number' => $this->generateDocumentNumber($tenantId), 'form_id' => $data['form_id'], 'line_id' => $data['line_id'] ?? null, 'title' => $data['title'], 'content' => $data['content'] ?? [], 'body' => $data['body'] ?? null, 'status' => Approval::STATUS_DRAFT, 'is_urgent' => $data['is_urgent'] ?? false, 'drafter_id' => $userId, 'department_id' => $data['department_id'] ?? null, 'current_step' => 0, 'created_by' => $userId, 'updated_by' => $userId, ]); if (! empty($data['steps'])) { $this->saveApprovalSteps($approval, $data['steps']); } // 첨부파일 연결 if (! empty($data['attachment_file_ids'])) { $this->linkAttachments($approval, $data['attachment_file_ids']); } return $approval->load(['form', 'drafter', 'steps.approver']); }); } /** * 수정 (draft/rejected만) */ public function updateApproval(int $id, array $data): Approval { return DB::transaction(function () use ($id, $data) { $approval = Approval::findOrFail($id); if (! $approval->isEditable()) { throw new \InvalidArgumentException('수정할 수 없는 상태입니다.'); } $approval->update([ 'form_id' => $data['form_id'] ?? $approval->form_id, 'line_id' => $data['line_id'] ?? $approval->line_id, 'title' => $data['title'] ?? $approval->title, 'content' => $data['content'] ?? $approval->content, 'body' => $data['body'] ?? $approval->body, 'is_urgent' => $data['is_urgent'] ?? $approval->is_urgent, 'department_id' => $data['department_id'] ?? $approval->department_id, 'updated_by' => auth()->id(), ]); if (isset($data['steps'])) { $approval->steps()->delete(); $this->saveApprovalSteps($approval, $data['steps']); } // 첨부파일 갱신 if (array_key_exists('attachment_file_ids', $data)) { $this->linkAttachments($approval, $data['attachment_file_ids'] ?? []); } return $approval->load(['form', 'drafter', 'steps.approver']); }); } /** * 삭제 (일반: draft만 / 관리자: 모든 상태) */ public function deleteApproval(int $id, ?User $user = null): bool { $approval = Approval::with('form')->findOrFail($id); $user = $user ?? auth()->user(); if (! $approval->isDeletableBy($user)) { throw new \InvalidArgumentException('삭제 권한이 없습니다.'); } // 진행 중/보류 문서 삭제 시 연동 후처리 (휴가 등) if (in_array($approval->status, [Approval::STATUS_PENDING, Approval::STATUS_ON_HOLD])) { $this->handleApprovalDeleted($approval); } $approval->steps()->delete(); $approval->update(['deleted_by' => $user->id]); return $approval->delete(); } /** * 영구삭제 (슈퍼관리자 전용) */ public function forceDeleteApproval(int $id): bool { $approval = Approval::withTrashed()->with('form')->findOrFail($id); return DB::transaction(function () use ($approval) { // 연동 Leave 정리 $leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first(); if ($leave) { $leave->update(['deleted_by' => auth()->id()]); $leave->delete(); } // 첨부파일 정리 (files 테이블) \App\Models\File::where('document_id', $approval->id) ->where('document_type', 'approval') ->update(['deleted_by' => auth()->id(), 'deleted_at' => now()]); // 하위 문서 참조 해제 Approval::withTrashed() ->where('parent_doc_id', $approval->id) ->update(['parent_doc_id' => null]); // 결재 단계 삭제 $approval->steps()->forceDelete(); return $approval->forceDelete(); }); } // ========================================================================= // 워크플로우 // ========================================================================= /** * 상신 (draft/rejected → pending) */ public function submit(int $id): Approval { return DB::transaction(function () use ($id) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isSubmittable()) { throw new \InvalidArgumentException('상신할 수 없는 상태입니다.'); } $approverSteps = $approval->steps ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); if ($approverSteps->isEmpty()) { throw new \InvalidArgumentException('결재선을 설정해주세요.'); } // 반려 후 재상신이면 반려 이력 저장 + 모든 step 초기화 + 재상신 카운트 증가 $isResubmit = $approval->status === Approval::STATUS_REJECTED; if ($isResubmit) { // 반려 이력 저장 $rejectedStep = $approval->steps ->firstWhere('status', ApprovalStep::STATUS_REJECTED); if ($rejectedStep) { $history = $approval->rejection_history ?? []; $history[] = [ 'round' => $approval->resubmit_count + 1, 'approver_name' => $rejectedStep->approver_name ?? ($rejectedStep->approver?->name ?? ''), 'approver_position' => $rejectedStep->approver_position ?? '', 'comment' => $rejectedStep->comment, 'rejected_at' => $rejectedStep->acted_at?->format('Y-m-d H:i:s'), ]; $approval->rejection_history = $history; } $approval->steps()->update([ 'status' => ApprovalStep::STATUS_PENDING, 'comment' => null, 'acted_at' => null, ]); } $approval->update([ 'status' => Approval::STATUS_PENDING, 'drafted_at' => now(), 'current_step' => 1, 'resubmit_count' => $isResubmit ? $approval->resubmit_count + 1 : $approval->resubmit_count, 'updated_by' => auth()->id(), ]); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 승인 */ public function approve(int $id, ?string $comment = null): Approval { return DB::transaction(function () use ($id, $comment) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isActionable()) { throw new \InvalidArgumentException('승인할 수 없는 상태입니다.'); } $currentStep = $approval->getCurrentApproverStep(); if (! $currentStep || $currentStep->approver_id !== auth()->id()) { throw new \InvalidArgumentException('현재 결재자가 아닙니다.'); } $currentStep->update([ 'status' => ApprovalStep::STATUS_APPROVED, 'comment' => $comment, 'acted_at' => now(), ]); // 다음 결재자 확인 $nextStep = $approval->steps() ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) ->where('status', ApprovalStep::STATUS_PENDING) ->orderBy('step_order') ->first(); if ($nextStep) { $approval->update([ 'current_step' => $nextStep->step_order, 'updated_by' => auth()->id(), ]); } else { // 마지막 결재자 → 문서 승인 완료 $approval->update([ 'status' => Approval::STATUS_APPROVED, 'completed_at' => now(), 'drafter_read_at' => null, 'updated_by' => auth()->id(), ]); // 연동 후처리 (휴가 등) $this->handleApprovalCompleted($approval); } return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 반려 */ public function reject(int $id, string $comment): Approval { return DB::transaction(function () use ($id, $comment) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isActionable()) { throw new \InvalidArgumentException('반려할 수 없는 상태입니다.'); } $currentStep = $approval->getCurrentApproverStep(); if (! $currentStep || $currentStep->approver_id !== auth()->id()) { throw new \InvalidArgumentException('현재 결재자가 아닙니다.'); } if (empty($comment)) { throw new \InvalidArgumentException('반려 사유를 입력해주세요.'); } $currentStep->update([ 'status' => ApprovalStep::STATUS_REJECTED, 'comment' => $comment, 'acted_at' => now(), ]); $approval->update([ 'status' => Approval::STATUS_REJECTED, 'completed_at' => now(), 'drafter_read_at' => null, 'updated_by' => auth()->id(), ]); // 연동 후처리 (휴가 등) $this->handleApprovalRejected($approval, $comment); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 회수 (기안자만, pending/on_hold → cancelled) */ public function cancel(int $id, ?string $recallReason = null): Approval { return DB::transaction(function () use ($id, $recallReason) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isCancellable()) { throw new \InvalidArgumentException('회수할 수 없는 상태입니다.'); } if ($approval->drafter_id !== auth()->id()) { throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.'); } // 첫 번째 결재자가 이미 처리했으면 회수 불가 $firstApproverStep = $approval->steps() ->approvalOnly() ->orderBy('step_order') ->first(); if ($firstApproverStep && $firstApproverStep->status !== ApprovalStep::STATUS_PENDING && $firstApproverStep->status !== ApprovalStep::STATUS_ON_HOLD) { throw new \InvalidArgumentException('첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.'); } // 모든 pending/on_hold steps → skipped $approval->steps() ->whereIn('status', [ApprovalStep::STATUS_PENDING, ApprovalStep::STATUS_ON_HOLD]) ->update(['status' => ApprovalStep::STATUS_SKIPPED]); $approval->update([ 'status' => Approval::STATUS_CANCELLED, 'completed_at' => now(), 'recall_reason' => $recallReason, 'updated_by' => auth()->id(), ]); // 연동 후처리 (휴가 회수) $this->handleApprovalCancelled($approval); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 보류 (현재 결재자만, pending → on_hold) */ public function hold(int $id, string $comment): Approval { return DB::transaction(function () use ($id, $comment) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isHoldable()) { throw new \InvalidArgumentException('보류할 수 없는 상태입니다.'); } $currentStep = $approval->getCurrentApproverStep(); if (! $currentStep || $currentStep->approver_id !== auth()->id()) { throw new \InvalidArgumentException('현재 결재자가 아닙니다.'); } if (empty($comment)) { throw new \InvalidArgumentException('보류 사유를 입력해주세요.'); } $currentStep->update([ 'status' => ApprovalStep::STATUS_ON_HOLD, 'comment' => $comment, 'acted_at' => now(), ]); $approval->update([ 'status' => Approval::STATUS_ON_HOLD, 'updated_by' => auth()->id(), ]); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 보류 해제 (보류한 결재자만, on_hold → pending) */ public function releaseHold(int $id): Approval { return DB::transaction(function () use ($id) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isHoldReleasable()) { throw new \InvalidArgumentException('보류 해제할 수 없는 상태입니다.'); } $holdStep = $approval->steps() ->where('status', ApprovalStep::STATUS_ON_HOLD) ->first(); if (! $holdStep || $holdStep->approver_id !== auth()->id()) { throw new \InvalidArgumentException('보류한 결재자만 해제할 수 있습니다.'); } $holdStep->update([ 'status' => ApprovalStep::STATUS_PENDING, 'comment' => null, 'acted_at' => null, ]); $approval->update([ 'status' => Approval::STATUS_PENDING, 'updated_by' => auth()->id(), ]); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인) */ public function preDecide(int $id, ?string $comment = null): Approval { return DB::transaction(function () use ($id, $comment) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isActionable()) { throw new \InvalidArgumentException('전결할 수 없는 상태입니다.'); } $currentStep = $approval->getCurrentApproverStep(); if (! $currentStep || $currentStep->approver_id !== auth()->id()) { throw new \InvalidArgumentException('현재 결재자가 아닙니다.'); } // 현재 step → approved + pre_decided $currentStep->update([ 'status' => ApprovalStep::STATUS_APPROVED, 'approval_type' => 'pre_decided', 'comment' => $comment, 'acted_at' => now(), ]); // 이후 모든 pending approval/agreement steps → skipped $approval->steps() ->where('step_order', '>', $currentStep->step_order) ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) ->where('status', ApprovalStep::STATUS_PENDING) ->update(['status' => ApprovalStep::STATUS_SKIPPED]); // 문서 최종 승인 $approval->update([ 'status' => Approval::STATUS_APPROVED, 'completed_at' => now(), 'drafter_read_at' => null, 'updated_by' => auth()->id(), ]); // 연동 후처리 (휴가 등) $this->handleApprovalCompleted($approval); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 복사 재기안 (완료/반려/회수 문서를 복사하여 새 draft 생성) */ public function copyForRedraft(int $id): Approval { return DB::transaction(function () use ($id) { $original = Approval::with('steps')->findOrFail($id); if (! $original->isCopyable()) { throw new \InvalidArgumentException('복사할 수 없는 상태입니다.'); } if ($original->drafter_id !== auth()->id()) { throw new \InvalidArgumentException('기안자만 복사할 수 있습니다.'); } $tenantId = session('selected_tenant_id'); // 새 문서 생성 $newApproval = Approval::create([ 'tenant_id' => $tenantId, 'document_number' => $this->generateDocumentNumber($tenantId), 'form_id' => $original->form_id, 'line_id' => $original->line_id, 'title' => $original->title, 'content' => $original->content, 'body' => $original->body, 'status' => Approval::STATUS_DRAFT, 'is_urgent' => $original->is_urgent, 'drafter_id' => auth()->id(), 'department_id' => $original->department_id, 'current_step' => 0, 'parent_doc_id' => $original->id, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); // 결재선 복사 (모두 pending 상태로) foreach ($original->steps as $step) { ApprovalStep::create([ 'approval_id' => $newApproval->id, 'step_order' => $step->step_order, 'step_type' => $step->step_type, 'approver_id' => $step->approver_id, 'approver_name' => $step->approver_name, 'approver_department' => $step->approver_department, 'approver_position' => $step->approver_position, 'status' => ApprovalStep::STATUS_PENDING, ]); } return $newApproval->load(['form', 'drafter', 'steps.approver']); }); } /** * 참조 열람 추적 */ public function markAsRead(int $approvalId): void { $userId = auth()->id(); ApprovalStep::where('approval_id', $approvalId) ->where('approver_id', $userId) ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) ->where('is_read', false) ->update([ 'is_read' => true, 'read_at' => now(), ]); } // ========================================================================= // 결재선 // ========================================================================= /** * 결재선 템플릿 목록 */ public function getApprovalLines(): Collection { return ApprovalLine::orderBy('name')->get(); } /** * 결재선 템플릿 생성 */ public function createLine(array $data): ApprovalLine { $tenantId = session('selected_tenant_id'); $steps = $this->enrichLineSteps($data['steps']); return ApprovalLine::create([ 'tenant_id' => $tenantId, 'name' => $data['name'], 'steps' => $steps, 'is_default' => $data['is_default'] ?? false, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); } /** * 결재선 템플릿 수정 */ public function updateLine(int $id, array $data): ApprovalLine { $line = ApprovalLine::findOrFail($id); $steps = $this->enrichLineSteps($data['steps']); $line->update([ 'name' => $data['name'], 'steps' => $steps, 'is_default' => $data['is_default'] ?? false, 'updated_by' => auth()->id(), ]); return $line->fresh(); } /** * 결재선 템플릿 삭제 */ public function deleteLine(int $id): bool { $line = ApprovalLine::findOrFail($id); $line->update(['deleted_by' => auth()->id()]); return $line->delete(); } /** * 양식 목록 */ public function getApprovalForms(): Collection { return ApprovalForm::active()->orderBy('name')->get(); } /** * 결재 단계 저장 + 스냅샷 */ public function saveApprovalSteps(Approval $approval, array $steps): void { foreach ($steps as $index => $step) { $user = User::find($step['user_id']); $tenantId = session('selected_tenant_id'); $departmentName = null; $positionName = null; if ($user) { $employee = $user->tenantProfiles() ->where('tenant_id', $tenantId) ->first(); if ($employee) { $departmentName = $employee->department?->name; $positionName = $employee->position_label ?? $employee->job_title_label; } } ApprovalStep::create([ 'approval_id' => $approval->id, 'step_order' => $index + 1, 'step_type' => $step['step_type'] ?? ApprovalLine::STEP_TYPE_APPROVAL, 'approver_id' => $step['user_id'], 'approver_name' => $user?->name ?? '', 'approver_department' => $departmentName, 'approver_position' => $positionName, 'status' => ApprovalStep::STATUS_PENDING, ]); } } /** * 첨부파일 연결 */ public function linkAttachments(Approval $approval, array $fileIds): void { $attachments = []; if (! empty($fileIds)) { $files = File::whereIn('id', $fileIds) ->where('uploaded_by', auth()->id()) ->get(); foreach ($files as $file) { $attachments[] = [ 'id' => $file->id, 'name' => $file->original_name, 'size' => $file->file_size, 'mime_type' => $file->mime_type, ]; $file->update([ 'is_temp' => false, 'document_id' => $approval->id, 'document_type' => 'approval', ]); } } $approval->update(['attachments' => $attachments]); } /** * 미처리 건수 (뱃지용) */ public function getBadgeCounts(int $userId): array { $pendingCount = Approval::pending() ->whereHas('steps', function ($q) use ($userId) { $q->where('approver_id', $userId) ->where('status', ApprovalStep::STATUS_PENDING) ->approvalOnly() ->where('step_order', function ($sub) { $sub->selectRaw('MIN(step_order)') ->from('approval_steps as inner_steps') ->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id') ->where('inner_steps.status', ApprovalStep::STATUS_PENDING) ->whereIn('inner_steps.step_type', [ ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT, ]); }); }) ->count(); $draftCount = Approval::pending()->byDrafter($userId)->count(); $referenceUnreadCount = ApprovalStep::where('approver_id', $userId) ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) ->where('is_read', false) ->count(); $completedUnreadCount = Approval::where('drafter_id', $userId) ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED]) ->whereNull('drafter_read_at') ->count(); return [ 'pending' => $pendingCount, 'draft' => $draftCount, 'reference_unread' => $referenceUnreadCount, 'completed_unread' => $completedUnreadCount, ]; } /** * 완료함 미읽음 일괄 읽음 처리 */ public function markCompletedAsRead(int $userId): int { return Approval::where('drafter_id', $userId) ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED]) ->whereNull('drafter_read_at') ->update(['drafter_read_at' => now()]); } // ========================================================================= // Private 헬퍼 // ========================================================================= /** * 기안함에서 직접 올린 결재 → Leave 레코드 자동 생성 */ private function createLeaveFromApproval(Approval $approval): \App\Models\HR\Leave { $content = $approval->content; $leaveType = $content['leave_type']; $leaveService = app(\App\Services\HR\LeaveService::class); // 사유서는 days=0, 그 외는 자동 계산 if (in_array($leaveType, \App\Models\HR\Leave::REASON_REPORT_TYPES)) { $days = 0; } else { $days = $leaveService->calculateDays( $leaveType, $content['start_date'], $content['end_date'] ); } return \App\Models\HR\Leave::create([ 'tenant_id' => $approval->tenant_id, 'user_id' => $content['user_id'] ?? $approval->drafter_id, 'leave_type' => $leaveType, 'start_date' => $content['start_date'], 'end_date' => $content['end_date'], 'days' => $days, 'reason' => $content['reason'] ?? null, 'status' => 'pending', 'approval_id' => $approval->id, 'created_by' => $approval->drafter_id, 'updated_by' => $approval->drafter_id, ]); } /** * 휴가/근태신청/사유서 관련 결재 양식인지 확인 */ private function isLeaveRelatedForm(?string $code): bool { return in_array($code, ['leave', 'attendance_request', 'reason_report']); } /** * 결재 최종 승인 시 연동 처리 (휴가/근태신청/사유서) */ private function handleApprovalCompleted(Approval $approval): void { if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { return; } $leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first(); // 기안함에서 직접 올린 경우: Leave 레코드 자동 생성 if (! $leave && ! empty($approval->content['leave_type'])) { $leave = $this->createLeaveFromApproval($approval); } 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 || ! $this->isLeaveRelatedForm($approval->form->code)) { 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 handleApprovalDeleted(Approval $approval): void { if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { return; } $leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first(); if ($leave && in_array($leave->status, ['pending', 'approved'])) { $leave->update([ 'status' => 'cancelled', 'updated_by' => auth()->id(), ]); } } /** * 결재 회수 시 연동 처리 (휴가/근태신청/사유서) */ private function handleApprovalCancelled(Approval $approval): void { if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { 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 스냅샷 보강 */ private function enrichLineSteps(array $steps): array { $tenantId = session('selected_tenant_id'); return collect($steps)->map(function ($step) use ($tenantId) { $user = User::find($step['user_id']); $profile = $user?->tenantProfiles()->where('tenant_id', $tenantId)->first(); return [ 'user_id' => $step['user_id'], 'user_name' => $user?->name ?? '', 'department' => $profile?->department?->name ?? $step['department'] ?? '', 'position' => $profile?->position_label ?? $profile?->job_title_label ?? $step['position'] ?? '', 'step_type' => $step['step_type'] ?? 'approval', ]; })->toArray(); } /** * 문서번호 채번 (APR-YYMMDD-001) */ private function generateDocumentNumber(int $tenantId): string { $prefix = 'APR'; $dateKey = now()->format('ymd'); $documentType = 'approval'; $periodKey = $dateKey; DB::statement( 'INSERT INTO numbering_sequences (tenant_id, document_type, scope_key, period_key, last_sequence, created_at, updated_at) VALUES (?, ?, ?, ?, 1, NOW(), NOW()) ON DUPLICATE KEY UPDATE last_sequence = last_sequence + 1, updated_at = NOW()', [$tenantId, $documentType, '', $periodKey] ); $sequence = (int) DB::table('numbering_sequences') ->where('tenant_id', $tenantId) ->where('document_type', $documentType) ->where('scope_key', '') ->where('period_key', $periodKey) ->value('last_sequence'); return $prefix.'-'.$dateKey.'-'.str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); } /** * 공통 필터 적용 */ private function applyFilters($query, array $filters): void { if (! empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('document_number', 'like', "%{$search}%"); }); } if (! empty($filters['status'])) { $query->where('status', $filters['status']); } if (! empty($filters['is_urgent'])) { $query->where('is_urgent', true); } if (! empty($filters['date_from'])) { $query->where('created_at', '>=', $filters['date_from']); } if (! empty($filters['date_to'])) { $query->where('created_at', '<=', $filters['date_to'].' 23:59:59'); } } }