diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php new file mode 100644 index 00000000..91c31e6c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -0,0 +1,288 @@ +service->getMyDrafts( + $request->only(['search', 'status', 'is_urgent', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + /** + * 결재 대기함 + */ + public function pending(Request $request): JsonResponse + { + $result = $this->service->getPendingForMe( + auth()->id(), + $request->only(['search', 'is_urgent', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + /** + * 처리 완료함 + */ + public function completed(Request $request): JsonResponse + { + $result = $this->service->getCompletedByMe( + auth()->id(), + $request->only(['search', 'status', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + /** + * 참조함 + */ + public function references(Request $request): JsonResponse + { + $result = $this->service->getReferencesForMe( + auth()->id(), + $request->only(['search', 'date_from', 'date_to']), + (int) $request->get('per_page', 15) + ); + + return response()->json($result); + } + + // ========================================================================= + // CRUD + // ========================================================================= + + /** + * 상세 조회 + */ + public function show(int $id): JsonResponse + { + $approval = $this->service->getApproval($id); + + return response()->json(['success' => true, 'data' => $approval]); + } + + /** + * 생성 (임시저장) + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'form_id' => 'required|exists:approval_forms,id', + 'title' => 'required|string|max:200', + 'body' => 'nullable|string', + 'is_urgent' => 'boolean', + 'steps' => 'nullable|array', + 'steps.*.user_id' => 'required_with:steps|exists:users,id', + 'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference', + ]); + + $approval = $this->service->createApproval($request->all()); + + return response()->json([ + 'success' => true, + 'message' => '결재 문서가 저장되었습니다.', + 'data' => $approval, + ], 201); + } + + /** + * 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $request->validate([ + 'title' => 'sometimes|string|max:200', + 'body' => 'nullable|string', + 'is_urgent' => 'boolean', + 'steps' => 'nullable|array', + 'steps.*.user_id' => 'required_with:steps|exists:users,id', + 'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference', + ]); + + try { + $approval = $this->service->updateApproval($id, $request->all()); + + return response()->json([ + 'success' => true, + 'message' => '결재 문서가 수정되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 삭제 + */ + public function destroy(int $id): JsonResponse + { + try { + $this->service->deleteApproval($id); + + return response()->json([ + 'success' => true, + 'message' => '결재 문서가 삭제되었습니다.', + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + // ========================================================================= + // 워크플로우 + // ========================================================================= + + /** + * 상신 + */ + public function submit(int $id): JsonResponse + { + try { + $approval = $this->service->submit($id); + + return response()->json([ + 'success' => true, + 'message' => '결재가 상신되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 승인 + */ + public function approve(Request $request, int $id): JsonResponse + { + try { + $approval = $this->service->approve($id, $request->get('comment')); + + return response()->json([ + 'success' => true, + 'message' => '승인되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 반려 + */ + public function reject(Request $request, int $id): JsonResponse + { + $request->validate([ + 'comment' => 'required|string|max:1000', + ]); + + try { + $approval = $this->service->reject($id, $request->get('comment')); + + return response()->json([ + 'success' => true, + 'message' => '반려되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 회수 + */ + public function cancel(int $id): JsonResponse + { + try { + $approval = $this->service->cancel($id); + + return response()->json([ + 'success' => true, + 'message' => '결재가 회수되었습니다.', + 'data' => $approval, + ]); + } catch (\InvalidArgumentException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + // ========================================================================= + // 유틸 + // ========================================================================= + + /** + * 결재선 템플릿 목록 + */ + public function lines(): JsonResponse + { + $lines = $this->service->getApprovalLines(); + + return response()->json(['success' => true, 'data' => $lines]); + } + + /** + * 양식 목록 + */ + public function forms(): JsonResponse + { + $forms = $this->service->getApprovalForms(); + + return response()->json(['success' => true, 'data' => $forms]); + } + + /** + * 미처리 건수 + */ + public function badgeCounts(): JsonResponse + { + $counts = $this->service->getBadgeCounts(auth()->id()); + + return response()->json(['success' => true, 'data' => $counts]); + } +} diff --git a/app/Http/Controllers/ApprovalController.php b/app/Http/Controllers/ApprovalController.php new file mode 100644 index 00000000..21ac1a42 --- /dev/null +++ b/app/Http/Controllers/ApprovalController.php @@ -0,0 +1,113 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.drafts')); + } + + return view('approvals.drafts'); + } + + /** + * 기안 작성 + */ + public function create(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.create')); + } + + $forms = $this->service->getApprovalForms(); + $lines = $this->service->getApprovalLines(); + + return view('approvals.create', compact('forms', 'lines')); + } + + /** + * 기안 수정 + */ + public function edit(Request $request, int $id): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.edit', $id)); + } + + $approval = $this->service->getApproval($id); + + if (! $approval->isEditable() || $approval->drafter_id !== auth()->id()) { + abort(403, '수정할 수 없습니다.'); + } + + $forms = $this->service->getApprovalForms(); + $lines = $this->service->getApprovalLines(); + + return view('approvals.edit', compact('approval', 'forms', 'lines')); + } + + /** + * 결재 상세 + */ + public function show(Request $request, int $id): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.show', $id)); + } + + $approval = $this->service->getApproval($id); + + return view('approvals.show', compact('approval')); + } + + /** + * 결재 대기함 + */ + public function pending(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.pending')); + } + + return view('approvals.pending'); + } + + /** + * 참조함 + */ + public function references(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.references')); + } + + return view('approvals.references'); + } + + /** + * 완료함 + */ + public function completed(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('approvals.completed')); + } + + return view('approvals.completed'); + } +} diff --git a/app/Models/Approvals/Approval.php b/app/Models/Approvals/Approval.php new file mode 100644 index 00000000..761b4368 --- /dev/null +++ b/app/Models/Approvals/Approval.php @@ -0,0 +1,242 @@ + 'array', + 'attachments' => 'array', + 'drafted_at' => 'datetime', + 'completed_at' => 'datetime', + 'current_step' => 'integer', + 'is_urgent' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'document_number', + 'form_id', + 'line_id', + 'title', + 'content', + 'body', + 'status', + 'is_urgent', + 'drafter_id', + 'department_id', + 'drafted_at', + 'completed_at', + 'current_step', + 'attachments', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $attributes = [ + 'status' => 'draft', + 'current_step' => 0, + 'is_urgent' => false, + ]; + + // ========================================================================= + // 상태 상수 + // ========================================================================= + + public const STATUS_DRAFT = 'draft'; + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUSES = [ + self::STATUS_DRAFT, + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_CANCELLED, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function form(): BelongsTo + { + return $this->belongsTo(ApprovalForm::class, 'form_id'); + } + + public function line(): BelongsTo + { + return $this->belongsTo(ApprovalLine::class, 'line_id'); + } + + public function drafter(): BelongsTo + { + return $this->belongsTo(User::class, 'drafter_id'); + } + + public function steps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id')->orderBy('step_order'); + } + + public function approverSteps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id') + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order'); + } + + public function referenceSteps(): HasMany + { + return $this->hasMany(ApprovalStep::class, 'approval_id') + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->orderBy('step_order'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeDraft($query) + { + return $query->where('status', self::STATUS_DRAFT); + } + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + public function scopeRejected($query) + { + return $query->where('status', self::STATUS_REJECTED); + } + + public function scopeCompleted($query) + { + return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); + } + + public function scopeByDrafter($query, int $userId) + { + return $query->where('drafter_id', $userId); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function isEditable(): bool + { + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); + } + + public function isSubmittable(): bool + { + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); + } + + public function isActionable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isCancellable(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isDeletable(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => '임시저장', + self::STATUS_PENDING => '진행', + self::STATUS_APPROVED => '완료', + self::STATUS_REJECTED => '반려', + self::STATUS_CANCELLED => '회수', + default => $this->status, + }; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => 'gray', + self::STATUS_PENDING => 'blue', + self::STATUS_APPROVED => 'green', + self::STATUS_REJECTED => 'red', + self::STATUS_CANCELLED => 'yellow', + default => 'gray', + }; + } + + public function getCurrentApproverStep(): ?ApprovalStep + { + return $this->steps() + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->where('status', ApprovalStep::STATUS_PENDING) + ->orderBy('step_order') + ->first(); + } + + public function isCurrentApprover(int $userId): bool + { + $currentStep = $this->getCurrentApproverStep(); + + return $currentStep && $currentStep->approver_id === $userId; + } + + public function isReferee(int $userId): bool + { + return $this->referenceSteps() + ->where('approver_id', $userId) + ->exists(); + } + + public function getProgressAttribute(): array + { + $totalSteps = $this->approverSteps()->count(); + $completedSteps = $this->approverSteps() + ->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]) + ->count(); + + return [ + 'total' => $totalSteps, + 'completed' => $completedSteps, + 'percentage' => $totalSteps > 0 ? round(($completedSteps / $totalSteps) * 100) : 0, + ]; + } +} diff --git a/app/Models/Approvals/ApprovalForm.php b/app/Models/Approvals/ApprovalForm.php new file mode 100644 index 00000000..cbf07c7c --- /dev/null +++ b/app/Models/Approvals/ApprovalForm.php @@ -0,0 +1,92 @@ + 'array', + 'is_active' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'name', + 'code', + 'category', + 'template', + 'is_active', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + // ========================================================================= + // 카테고리 상수 + // ========================================================================= + + public const CATEGORY_REQUEST = 'request'; + + public const CATEGORY_EXPENSE = 'expense'; + + public const CATEGORY_EXPENSE_ESTIMATE = 'expense_estimate'; + + public const CATEGORIES = [ + self::CATEGORY_REQUEST, + self::CATEGORY_EXPENSE, + self::CATEGORY_EXPENSE_ESTIMATE, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function approvals(): HasMany + { + return $this->hasMany(Approval::class, 'form_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeCategory($query, string $category) + { + return $query->where('category', $category); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function getCategoryLabelAttribute(): string + { + return match ($this->category) { + self::CATEGORY_REQUEST => '품의서', + self::CATEGORY_EXPENSE => '지출결의서', + self::CATEGORY_EXPENSE_ESTIMATE => '지출 예상 내역서', + default => $this->category ?? '', + }; + } +} diff --git a/app/Models/Approvals/ApprovalLine.php b/app/Models/Approvals/ApprovalLine.php new file mode 100644 index 00000000..45b3f395 --- /dev/null +++ b/app/Models/Approvals/ApprovalLine.php @@ -0,0 +1,83 @@ + 'array', + 'is_default' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'name', + 'steps', + 'is_default', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + // ========================================================================= + // 단계 유형 상수 + // ========================================================================= + + public const STEP_TYPE_APPROVAL = 'approval'; + + public const STEP_TYPE_AGREEMENT = 'agreement'; + + public const STEP_TYPE_REFERENCE = 'reference'; + + public const STEP_TYPES = [ + self::STEP_TYPE_APPROVAL, + self::STEP_TYPE_AGREEMENT, + self::STEP_TYPE_REFERENCE, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function getStepCountAttribute(): int + { + return count($this->steps ?? []); + } + + public function getApproverIdsAttribute(): array + { + return collect($this->steps ?? []) + ->pluck('user_id') + ->filter() + ->values() + ->toArray(); + } +} diff --git a/app/Models/Approvals/ApprovalStep.php b/app/Models/Approvals/ApprovalStep.php new file mode 100644 index 00000000..488d5e58 --- /dev/null +++ b/app/Models/Approvals/ApprovalStep.php @@ -0,0 +1,137 @@ + 'integer', + 'acted_at' => 'datetime', + 'is_read' => 'boolean', + 'read_at' => 'datetime', + ]; + + protected $fillable = [ + 'approval_id', + 'step_order', + 'step_type', + 'approver_id', + 'approver_name', + 'approver_department', + 'approver_position', + 'status', + 'comment', + 'acted_at', + 'is_read', + 'read_at', + ]; + + protected $attributes = [ + 'status' => 'pending', + 'is_read' => false, + ]; + + // ========================================================================= + // 상태 상수 + // ========================================================================= + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_REJECTED = 'rejected'; + + public const STATUS_SKIPPED = 'skipped'; + + public const STATUSES = [ + self::STATUS_PENDING, + self::STATUS_APPROVED, + self::STATUS_REJECTED, + self::STATUS_SKIPPED, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function approval(): BelongsTo + { + return $this->belongsTo(Approval::class, 'approval_id'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approver_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeApproved($query) + { + return $query->where('status', self::STATUS_APPROVED); + } + + public function scopeByApprover($query, int $userId) + { + return $query->where('approver_id', $userId); + } + + public function scopeApprovalOnly($query) + { + return $query->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + } + + public function scopeReferenceOnly($query) + { + return $query->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + public function isActionable(): bool + { + return $this->status === self::STATUS_PENDING + && in_array($this->step_type, [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]); + } + + public function isReference(): bool + { + return $this->step_type === ApprovalLine::STEP_TYPE_REFERENCE; + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '대기', + self::STATUS_APPROVED => '승인', + self::STATUS_REJECTED => '반려', + self::STATUS_SKIPPED => '건너뜀', + default => $this->status, + }; + } + + public function getStepTypeLabelAttribute(): string + { + return match ($this->step_type) { + ApprovalLine::STEP_TYPE_APPROVAL => '결재', + ApprovalLine::STEP_TYPE_AGREEMENT => '합의', + ApprovalLine::STEP_TYPE_REFERENCE => '참조', + default => $this->step_type, + }; + } +} diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php new file mode 100644 index 00000000..4f603389 --- /dev/null +++ b/app/Services/ApprovalService.php @@ -0,0 +1,500 @@ +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'); + } + } +} diff --git a/resources/views/approvals/completed.blade.php b/resources/views/approvals/completed.blade.php new file mode 100644 index 00000000..374bab2d --- /dev/null +++ b/resources/views/approvals/completed.blade.php @@ -0,0 +1,122 @@ +@extends('layouts.app') + +@section('title', '완료함') + +@section('content') +
+ {{ $rejectedStep->approver_name ?? '' }} ({{ $rejectedStep->acted_at?->format('Y-m-d H:i') }}): + {{ $rejectedStep->comment }} +
+ @endif +{{ $approval->document_number }}
+{{ $step->comment }}
+