id(); $query = Approval::with(['form', 'steps.approver']) ->byDrafter($userId); $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() ->whereColumn('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']) ->whereHas('steps', function ($q) use ($userId) { $q->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) { $q->where('approver_id', $userId) ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); }); $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']); } 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']); } return $approval->load(['form', 'drafter', 'steps.approver']); }); } /** * 삭제 (draft만) */ public function deleteApproval(int $id): bool { $approval = Approval::findOrFail($id); if (! $approval->isDeletable()) { throw new \InvalidArgumentException('삭제할 수 없는 상태입니다.'); } $approval->steps()->delete(); $approval->update(['deleted_by' => auth()->id()]); return $approval->delete(); } // ========================================================================= // 워크플로우 // ========================================================================= /** * 상신 (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 초기화 if ($approval->status === Approval::STATUS_REJECTED) { $approval->steps()->update([ 'status' => ApprovalStep::STATUS_PENDING, 'comment' => null, 'acted_at' => null, ]); } $approval->update([ 'status' => Approval::STATUS_PENDING, 'drafted_at' => now(), 'current_step' => 1, '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(), 'updated_by' => auth()->id(), ]); } 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(), 'updated_by' => auth()->id(), ]); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } /** * 회수 (기안자만, pending → cancelled) */ public function cancel(int $id): Approval { return DB::transaction(function () use ($id) { $approval = Approval::with('steps')->findOrFail($id); if (! $approval->isCancellable()) { throw new \InvalidArgumentException('회수할 수 없는 상태입니다.'); } if ($approval->drafter_id !== auth()->id()) { throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.'); } // 모든 pending steps → skipped $approval->steps() ->where('status', ApprovalStep::STATUS_PENDING) ->update(['status' => ApprovalStep::STATUS_SKIPPED]); $approval->update([ 'status' => Approval::STATUS_CANCELLED, 'completed_at' => now(), 'updated_by' => auth()->id(), ]); return $approval->fresh(['form', 'drafter', 'steps.approver']); }); } // ========================================================================= // 결재선 // ========================================================================= /** * 결재선 템플릿 목록 */ public function getApprovalLines(): Collection { return ApprovalLine::orderBy('name')->get(); } /** * 양식 목록 */ 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; } } 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 getBadgeCounts(int $userId): array { $pendingCount = Approval::pending() ->whereHas('steps', function ($q) use ($userId) { $q->where('approver_id', $userId) ->where('status', ApprovalStep::STATUS_PENDING) ->approvalOnly() ->whereColumn('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::draft()->byDrafter($userId)->count(); return [ 'pending' => $pendingCount, 'draft' => $draftCount, ]; } // ========================================================================= // Private 헬퍼 // ========================================================================= /** * 문서번호 채번 (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'); } } }