diff --git a/app/Http/Controllers/Api/V1/ApprovalController.php b/app/Http/Controllers/Api/V1/ApprovalController.php index 1e75426..d2b1a92 100644 --- a/app/Http/Controllers/Api/V1/ApprovalController.php +++ b/app/Http/Controllers/Api/V1/ApprovalController.php @@ -155,11 +155,104 @@ public function reject(int $id, RejectRequest $request): JsonResponse * 결재 회수 (기안자만) * POST /v1/approvals/{id}/cancel */ - public function cancel(int $id): JsonResponse + public function cancel(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->cancel($id, $request->input('recall_reason')); + }, __('message.approval.cancelled')); + } + + /** + * 보류 (현재 결재자만) + * POST /v1/approvals/{id}/hold + */ + public function hold(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + $comment = $request->input('comment'); + if (empty($comment)) { + throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(__('error.approval.comment_required')); + } + + return $this->service->hold($id, $comment); + }, __('message.approval.held')); + } + + /** + * 보류 해제 (보류한 결재자만) + * POST /v1/approvals/{id}/release-hold + */ + public function releaseHold(int $id): JsonResponse { return ApiResponse::handle(function () use ($id) { - return $this->service->cancel($id); - }, __('message.approval.cancelled')); + return $this->service->releaseHold($id); + }, __('message.approval.hold_released')); + } + + /** + * 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인) + * POST /v1/approvals/{id}/pre-decide + */ + public function preDecide(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->preDecide($id, $request->input('comment')); + }, __('message.approval.pre_decided')); + } + + /** + * 복사 재기안 + * POST /v1/approvals/{id}/copy + */ + public function copyForRedraft(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->copyForRedraft($id); + }, __('message.approval.copied')); + } + + /** + * 완료함 목록 + * GET /v1/approvals/completed + */ + public function completed(IndexRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->completed($request->validated()); + }, __('message.fetched')); + } + + /** + * 완료함 현황 카드 + * GET /v1/approvals/completed/summary + */ + public function completedSummary(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->completedSummary(); + }, __('message.fetched')); + } + + /** + * 미처리 건수 (뱃지용) + * GET /v1/approvals/badge-counts + */ + public function badgeCounts(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->badgeCounts(); + }, __('message.fetched')); + } + + /** + * 완료함 미읽음 일괄 읽음 처리 + * POST /v1/approvals/completed/mark-read + */ + public function markCompletedAsRead(): JsonResponse + { + return ApiResponse::handle(function () { + return $this->service->markCompletedAsRead(); + }, __('message.approval.marked_read')); } /** @@ -183,4 +276,52 @@ public function markUnread(int $id): JsonResponse return $this->service->markUnread($id); }, __('message.approval.marked_unread')); } + + // ========================================================================= + // 위임 관리 + // ========================================================================= + + /** + * 위임 목록 + * GET /v1/approvals/delegations + */ + public function delegationIndex(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->delegationIndex($request->all()); + }, __('message.fetched')); + } + + /** + * 위임 생성 + * POST /v1/approvals/delegations + */ + public function delegationStore(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->delegationStore($request->all()); + }, __('message.created')); + } + + /** + * 위임 수정 + * PATCH /v1/approvals/delegations/{id} + */ + public function delegationUpdate(int $id, Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + return $this->service->delegationUpdate($id, $request->all()); + }, __('message.updated')); + } + + /** + * 위임 삭제 + * DELETE /v1/approvals/delegations/{id} + */ + public function delegationDestroy(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->delegationDestroy($id); + }, __('message.deleted')); + } } diff --git a/app/Models/Tenants/Approval.php b/app/Models/Tenants/Approval.php index 373e14f..270e40c 100644 --- a/app/Models/Tenants/Approval.php +++ b/app/Models/Tenants/Approval.php @@ -39,22 +39,35 @@ class Approval extends Model protected $casts = [ 'content' => 'array', 'attachments' => 'array', + 'rejection_history' => 'array', + 'is_urgent' => 'boolean', 'drafted_at' => 'datetime', 'completed_at' => 'datetime', + 'drafter_read_at' => 'datetime', 'current_step' => 'integer', + 'resubmit_count' => 'integer', ]; protected $fillable = [ 'tenant_id', 'document_number', 'form_id', + 'line_id', 'title', 'content', + 'body', 'status', + 'is_urgent', 'drafter_id', + 'department_id', 'drafted_at', 'completed_at', + 'drafter_read_at', 'current_step', + 'resubmit_count', + 'rejection_history', + 'recall_reason', + 'parent_doc_id', 'attachments', 'linkable_type', 'linkable_id', @@ -66,6 +79,7 @@ class Approval extends Model protected $attributes = [ 'status' => 'draft', 'current_step' => 0, + 'resubmit_count' => 0, ]; // ========================================================================= @@ -82,12 +96,15 @@ class Approval extends Model public const STATUS_CANCELLED = 'cancelled'; // 회수/취소 + public const STATUS_ON_HOLD = 'on_hold'; // 보류 + public const STATUSES = [ self::STATUS_DRAFT, self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, + self::STATUS_ON_HOLD, ]; // ========================================================================= @@ -102,6 +119,14 @@ public function form(): BelongsTo return $this->belongsTo(ApprovalForm::class, 'form_id'); } + /** + * 결재선 템플릿 + */ + public function line(): BelongsTo + { + return $this->belongsTo(ApprovalLine::class, 'line_id'); + } + /** * 기안자 */ @@ -110,6 +135,30 @@ public function drafter(): BelongsTo return $this->belongsTo(User::class, 'drafter_id'); } + /** + * 기안 부서 + */ + public function department(): BelongsTo + { + return $this->belongsTo(Department::class, 'department_id'); + } + + /** + * 원본 문서 (복사 재기안 시) + */ + public function parentDocument(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_doc_id'); + } + + /** + * 하위 문서들 (이 문서에서 복사된 문서들) + */ + public function childDocuments(): HasMany + { + return $this->hasMany(self::class, 'parent_doc_id'); + } + /** * 결재 단계들 */ @@ -207,11 +256,19 @@ public function scopeRejected($query) } /** - * 완료됨 (승인 또는 반려) + * 완료됨 (승인, 반려, 회수) */ public function scopeCompleted($query) { - return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED]); + return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); + } + + /** + * 보류 상태 + */ + public function scopeOnHold($query) + { + return $query->where('status', self::STATUS_ON_HOLD); } /** @@ -227,19 +284,19 @@ public function scopeByDrafter($query, int $userId) // ========================================================================= /** - * 수정 가능 여부 (임시저장 상태만) + * 수정 가능 여부 (임시저장 또는 반려 상태) */ public function isEditable(): bool { - return $this->status === self::STATUS_DRAFT; + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); } /** - * 상신 가능 여부 + * 상신 가능 여부 (임시저장 또는 반려 상태 = 재상신) */ public function isSubmittable(): bool { - return $this->status === self::STATUS_DRAFT; + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); } /** @@ -251,21 +308,60 @@ public function isActionable(): bool } /** - * 회수 가능 여부 (기안자만, 진행중 상태) + * 회수 가능 여부 (기안자만, 진행중 또는 보류 상태) */ public function isCancellable(): bool + { + return in_array($this->status, [self::STATUS_PENDING, self::STATUS_ON_HOLD]); + } + + /** + * 보류 가능 여부 (진행중 상태만) + */ + public function isHoldable(): bool { return $this->status === self::STATUS_PENDING; } /** - * 삭제 가능 여부 (임시저장만) + * 보류 해제 가능 여부 (보류 상태만) + */ + public function isHoldReleasable(): bool + { + return $this->status === self::STATUS_ON_HOLD; + } + + /** + * 복사 재기안 가능 여부 (완료/반려/회수 상태) + */ + public function isCopyable(): bool + { + return in_array($this->status, [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]); + } + + /** + * 삭제 가능 여부 + * 일반 사용자: 임시저장만 + * 관리자: 별도 isDeletableBy 사용 */ public function isDeletable(): bool { return $this->status === self::STATUS_DRAFT; } + /** + * 사용자 기준 삭제 가능 여부 + * 기안자: 임시저장/반려만 삭제 가능 + */ + public function isDeletableBy(int $userId): bool + { + if ($this->drafter_id !== $userId) { + return false; + } + + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); + } + /** * 상태 라벨 */ @@ -277,10 +373,27 @@ public function getStatusLabelAttribute(): string self::STATUS_APPROVED => '완료', self::STATUS_REJECTED => '반려', self::STATUS_CANCELLED => '회수', + self::STATUS_ON_HOLD => '보류', default => $this->status, }; } + /** + * 상태 색상 (UI 배지용) + */ + 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', + self::STATUS_ON_HOLD => 'orange', + default => 'gray', + }; + } + /** * 현재 결재자 확인 */ diff --git a/app/Models/Tenants/ApprovalDelegation.php b/app/Models/Tenants/ApprovalDelegation.php new file mode 100644 index 0000000..b628784 --- /dev/null +++ b/app/Models/Tenants/ApprovalDelegation.php @@ -0,0 +1,80 @@ + 'array', + 'start_date' => 'date', + 'end_date' => 'date', + 'notify_delegator' => 'boolean', + 'is_active' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'delegator_id', + 'delegate_id', + 'start_date', + 'end_date', + 'form_ids', + 'notify_delegator', + 'is_active', + 'reason', + 'created_by', + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 위임자 (원래 결재자) + */ + public function delegator(): BelongsTo + { + return $this->belongsTo(User::class, 'delegator_id'); + } + + /** + * 대리자 (대신 결재하는 사람) + */ + public function delegate(): BelongsTo + { + return $this->belongsTo(User::class, 'delegate_id'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeForDelegator($query, int $userId) + { + return $query->where('delegator_id', $userId); + } + + public function scopeCurrentlyActive($query) + { + $today = now()->toDateString(); + + return $query->active() + ->where('start_date', '<=', $today) + ->where('end_date', '>=', $today); + } +} diff --git a/app/Models/Tenants/ApprovalForm.php b/app/Models/Tenants/ApprovalForm.php index f013df7..7909298 100644 --- a/app/Models/Tenants/ApprovalForm.php +++ b/app/Models/Tenants/ApprovalForm.php @@ -41,6 +41,7 @@ class ApprovalForm extends Model 'code', 'category', 'template', + 'body_template', 'is_active', 'created_by', 'updated_by', diff --git a/app/Models/Tenants/ApprovalStep.php b/app/Models/Tenants/ApprovalStep.php index 5a7c554..017c7f3 100644 --- a/app/Models/Tenants/ApprovalStep.php +++ b/app/Models/Tenants/ApprovalStep.php @@ -24,10 +24,12 @@ class ApprovalStep extends Model { use Auditable; + protected $table = 'approval_steps'; protected $casts = [ 'step_order' => 'integer', + 'parallel_group' => 'integer', 'acted_at' => 'datetime', 'is_read' => 'boolean', 'read_at' => 'datetime', @@ -41,8 +43,14 @@ class ApprovalStep extends Model 'status', 'comment', 'acted_at', + 'acted_by', 'is_read', 'read_at', + 'parallel_group', + 'approval_type', + 'approver_name', + 'approver_department', + 'approver_position', ]; protected $attributes = [ @@ -62,11 +70,14 @@ class ApprovalStep extends Model public const STATUS_SKIPPED = 'skipped'; // 건너뜀 + public const STATUS_ON_HOLD = 'on_hold'; // 보류 + public const STATUSES = [ self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_SKIPPED, + self::STATUS_ON_HOLD, ]; // ========================================================================= @@ -89,6 +100,14 @@ public function approver(): BelongsTo return $this->belongsTo(User::class, 'approver_id'); } + /** + * 실제 처리자 (위임 결재 시 대리자) + */ + public function actedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'acted_by'); + } + // ========================================================================= // 스코프 // ========================================================================= @@ -164,6 +183,7 @@ public function getStatusLabelAttribute(): string self::STATUS_APPROVED => '승인', self::STATUS_REJECTED => '반려', self::STATUS_SKIPPED => '건너뜀', + self::STATUS_ON_HOLD => '보류', default => $this->status, }; } diff --git a/app/Services/ApprovalService.php b/app/Services/ApprovalService.php index 46ebf16..42d7391 100644 --- a/app/Services/ApprovalService.php +++ b/app/Services/ApprovalService.php @@ -3,10 +3,13 @@ namespace App\Services; use App\Models\Documents\Document; +use App\Models\Members\User; use App\Models\Tenants\Approval; +use App\Models\Tenants\ApprovalDelegation; use App\Models\Tenants\ApprovalForm; use App\Models\Tenants\ApprovalLine; use App\Models\Tenants\ApprovalStep; +use App\Models\Tenants\Leave; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -626,10 +629,14 @@ public function store(array $data): Approval 'tenant_id' => $tenantId, 'document_number' => $documentNumber, 'form_id' => $form->id, + 'line_id' => $data['line_id'] ?? null, 'title' => $data['title'], 'content' => $data['content'], + 'body' => $data['body'] ?? null, 'status' => $status, + 'is_urgent' => $data['is_urgent'] ?? false, 'drafter_id' => $userId, + 'department_id' => $data['department_id'] ?? null, 'drafted_at' => $status === Approval::STATUS_PENDING ? now() : null, 'attachments' => $data['attachments'] ?? null, 'created_by' => $userId, @@ -687,8 +694,12 @@ public function update(int $id, array $data): Approval $approval->fill([ 'form_id' => $formId, + '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, 'attachments' => $data['attachments'] ?? $approval->attachments, 'updated_by' => $userId, ]); @@ -740,7 +751,7 @@ public function destroy(int $id): bool } /** - * 결재 상신 + * 결재 상신 (draft → pending, rejected → pending 재상신 포함) */ public function submit(int $id, array $data): Approval { @@ -750,6 +761,7 @@ public function submit(int $id, array $data): Approval return DB::transaction(function () use ($id, $data, $tenantId, $userId) { $approval = Approval::query() ->where('tenant_id', $tenantId) + ->with('steps') ->findOrFail($id); if (! $approval->isSubmittable()) { @@ -767,20 +779,48 @@ public function submit(int $id, array $data): Approval } } - // 먼저 approval을 pending으로 변경 (Observer가 올바른 상태로 트리거되도록) + // 반려 후 재상신 처리 + $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 ?? '', + 'approver_position' => $rejectedStep->approver_position ?? '', + 'comment' => $rejectedStep->comment, + 'rejected_at' => $rejectedStep->acted_at?->format('Y-m-d H:i:s'), + ]; + $approval->rejection_history = $history; + } + + // 기존 steps를 모두 pending으로 초기화 + $approval->steps()->update([ + 'status' => ApprovalStep::STATUS_PENDING, + 'comment' => null, + 'acted_at' => null, + ]); + } + + // approval을 pending으로 변경 $approval->status = Approval::STATUS_PENDING; $approval->drafted_at = now(); $approval->current_step = 1; + $approval->resubmit_count = $isResubmit + ? ($approval->resubmit_count ?? 0) + 1 + : ($approval->resubmit_count ?? 0); $approval->updated_by = $userId; $approval->save(); - // steps가 있으면 새로 생성 (approval이 pending 상태일 때 생성해야 알림 발송) + // steps가 있으면 새로 생성 if (! empty($data['steps'])) { - // 기존 결재선 삭제 후 새로 생성 $approval->steps()->delete(); $this->createApprovalSteps($approval, $data['steps']); } else { - // 기존 결재선 사용 시, Observer가 트리거되지 않으므로 수동으로 알림 발송 + // 기존 결재선 사용 시 알림 발송 $firstPendingStep = $approval->steps() ->where('status', ApprovalStep::STATUS_PENDING) ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) @@ -850,6 +890,7 @@ public function approve(int $id, ?string $comment = null): Approval // 모든 결재 완료 $approval->status = Approval::STATUS_APPROVED; $approval->completed_at = now(); + $approval->drafter_read_at = null; } $approval->current_step = $myStep->step_order + 1; @@ -859,6 +900,11 @@ public function approve(int $id, ?string $comment = null): Approval // Document 브릿지 동기화 $this->syncToLinkedDocument($approval); + // Leave 연동 (승인 완료 시) + if ($approval->status === Approval::STATUS_APPROVED) { + $this->handleApprovalCompleted($approval); + } + return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', @@ -909,12 +955,16 @@ public function reject(int $id, string $comment): Approval // 문서 반려 상태로 변경 $approval->status = Approval::STATUS_REJECTED; $approval->completed_at = now(); + $approval->drafter_read_at = null; $approval->updated_by = $userId; $approval->save(); // Document 브릿지 동기화 $this->syncToLinkedDocument($approval); + // Leave 연동 (반려 시) + $this->handleApprovalRejected($approval, $comment); + return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', @@ -928,14 +978,14 @@ public function reject(int $id, string $comment): Approval } /** - * 결재 회수 (기안자만) + * 결재 회수 (기안자만, pending/on_hold → cancelled) */ - public function cancel(int $id): Approval + public function cancel(int $id, ?string $recallReason = null): Approval { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - return DB::transaction(function () use ($id, $tenantId, $userId) { + return DB::transaction(function () use ($id, $recallReason, $tenantId, $userId) { $approval = Approval::query() ->where('tenant_id', $tenantId) ->findOrFail($id); @@ -949,24 +999,407 @@ public function cancel(int $id): Approval throw new BadRequestHttpException(__('error.approval.only_drafter_can_cancel')); } + // 첫 번째 결재자가 이미 처리했으면 회수 불가 + $firstApproverStep = $approval->steps() + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->orderBy('step_order') + ->first(); + + if ($firstApproverStep + && $firstApproverStep->status !== ApprovalStep::STATUS_PENDING + && $firstApproverStep->status !== ApprovalStep::STATUS_ON_HOLD) { + throw new BadRequestHttpException(__('error.approval.first_approver_already_acted')); + } + + // 모든 pending/on_hold steps → skipped + $approval->steps() + ->whereIn('status', [ApprovalStep::STATUS_PENDING, ApprovalStep::STATUS_ON_HOLD]) + ->update(['status' => ApprovalStep::STATUS_SKIPPED]); + $approval->status = Approval::STATUS_CANCELLED; $approval->completed_at = now(); + $approval->recall_reason = $recallReason; $approval->updated_by = $userId; $approval->save(); - // Document 브릿지 동기화 (steps 삭제 전에 실행) + // Document 브릿지 동기화 $this->syncToLinkedDocument($approval); - // 결재 단계들 삭제 - $approval->steps()->delete(); + // Leave 연동 (회수 시) + $this->handleApprovalCancelled($approval); return $approval->fresh([ 'form:id,name,code,category', 'drafter:id,name', + 'steps.approver:id,name', ]); }); } + /** + * 보류 (현재 결재자만, pending → on_hold) + */ + public function hold(int $id, string $comment): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $approval->isHoldable()) { + throw new BadRequestHttpException(__('error.approval.not_holdable')); + } + + $currentStep = $approval->getCurrentApproverStep(); + if (! $currentStep || $currentStep->approver_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.not_your_turn')); + } + + $currentStep->status = ApprovalStep::STATUS_ON_HOLD; + $currentStep->comment = $comment; + $currentStep->acted_at = now(); + $currentStep->save(); + + $approval->status = Approval::STATUS_ON_HOLD; + $approval->updated_by = $userId; + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 보류 해제 (보류한 결재자만, on_hold → pending) + */ + public function releaseHold(int $id): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $approval->isHoldReleasable()) { + throw new BadRequestHttpException(__('error.approval.not_hold_releasable')); + } + + $holdStep = $approval->steps() + ->where('status', ApprovalStep::STATUS_ON_HOLD) + ->first(); + + if (! $holdStep || $holdStep->approver_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.only_holder_can_release')); + } + + $holdStep->status = ApprovalStep::STATUS_PENDING; + $holdStep->comment = null; + $holdStep->acted_at = null; + $holdStep->save(); + + $approval->status = Approval::STATUS_PENDING; + $approval->updated_by = $userId; + $approval->save(); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인) + */ + public function preDecide(int $id, ?string $comment = null): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { + $approval = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $approval->isActionable()) { + throw new BadRequestHttpException(__('error.approval.not_actionable')); + } + + $currentStep = $approval->getCurrentApproverStep(); + if (! $currentStep || $currentStep->approver_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.not_your_turn')); + } + + // 현재 step → approved + pre_decided + $currentStep->status = ApprovalStep::STATUS_APPROVED; + $currentStep->approval_type = 'pre_decided'; + $currentStep->comment = $comment; + $currentStep->acted_at = now(); + $currentStep->save(); + + // 이후 모든 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->status = Approval::STATUS_APPROVED; + $approval->completed_at = now(); + $approval->drafter_read_at = null; + $approval->updated_by = $userId; + $approval->save(); + + // Document 브릿지 동기화 + $this->syncToLinkedDocument($approval); + + // Leave 연동 (승인 완료) + $this->handleApprovalCompleted($approval); + + return $approval->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'drafter.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'drafter.tenantProfile.department:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 복사 재기안 (완료/반려/회수 문서를 복사하여 새 draft 생성) + */ + public function copyForRedraft(int $id): Approval + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $original = Approval::query() + ->where('tenant_id', $tenantId) + ->with('steps') + ->findOrFail($id); + + if (! $original->isCopyable()) { + throw new BadRequestHttpException(__('error.approval.not_copyable')); + } + + if ($original->drafter_id !== $userId) { + throw new BadRequestHttpException(__('error.approval.only_drafter_can_copy')); + } + + $documentNumber = $this->generateDocumentNumber($tenantId); + + $newApproval = Approval::create([ + 'tenant_id' => $tenantId, + 'document_number' => $documentNumber, + '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' => $userId, + 'department_id' => $original->department_id, + 'current_step' => 0, + 'parent_doc_id' => $original->id, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 결재선 복사 (모두 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->fresh([ + 'form:id,name,code,category', + 'drafter:id,name', + 'steps.approver:id,name', + 'steps.approver.tenantProfile:id,user_id,tenant_id,department_id,position_key,display_name', + 'steps.approver.tenantProfile.department:id,name', + ]); + }); + } + + /** + * 완료함 - 내가 기안한 완료 문서 + 내가 결재 처리한 문서 + */ + public function completed(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $query = Approval::query() + ->where('tenant_id', $tenantId) + ->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]); + }); + }) + ->with([ + 'form:id,name,code,category', + 'drafter:id,name', + 'drafter.tenantProfile:id,user_id,position_key,department_id', + 'drafter.tenantProfile.department:id,name', + 'steps.approver:id,name', + ]); + + if (! empty($params['search'])) { + $query->where(function ($q) use ($params) { + $q->where('title', 'like', "%{$params['search']}%") + ->orWhere('document_number', 'like', "%{$params['search']}%"); + }); + } + + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + $sortBy = $params['sort_by'] ?? 'updated_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 완료함 현황 카드 + */ + public function completedSummary(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $myCompleted = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED, Approval::STATUS_CANCELLED]) + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status') + ->toArray(); + + $unreadCount = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED]) + ->whereNull('drafter_read_at') + ->count(); + + return [ + 'approved' => $myCompleted[Approval::STATUS_APPROVED] ?? 0, + 'rejected' => $myCompleted[Approval::STATUS_REJECTED] ?? 0, + 'cancelled' => $myCompleted[Approval::STATUS_CANCELLED] ?? 0, + 'unread' => $unreadCount, + ]; + } + + /** + * 미처리 건수 (뱃지용) + */ + public function badgeCounts(): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $pendingCount = Approval::query() + ->where('tenant_id', $tenantId) + ->where('status', Approval::STATUS_PENDING) + ->whereHas('steps', function ($q) use ($userId) { + $q->where('approver_id', $userId) + ->where('status', ApprovalStep::STATUS_PENDING) + ->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]) + ->whereRaw('step_order = (SELECT MIN(s2.step_order) FROM approval_steps s2 WHERE s2.approval_id = approval_steps.approval_id AND s2.status = ?)', [ApprovalStep::STATUS_PENDING]); + }) + ->count(); + + $draftCount = Approval::query() + ->where('tenant_id', $tenantId) + ->where('status', Approval::STATUS_PENDING) + ->where('drafter_id', $userId) + ->count(); + + $referenceUnreadCount = ApprovalStep::query() + ->where('approver_id', $userId) + ->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE) + ->where('is_read', false) + ->whereHas('approval', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }) + ->count(); + + $completedUnreadCount = Approval::query() + ->where('tenant_id', $tenantId) + ->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 + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->whereIn('status', [Approval::STATUS_APPROVED, Approval::STATUS_REJECTED]) + ->whereNull('drafter_read_at') + ->update(['drafter_read_at' => now()]); + } + /** * Approval → Document 브릿지 동기화 * 결재 승인/반려/회수 시 연결된 Document의 상태와 결재란을 동기화 @@ -1118,12 +1551,13 @@ private function generateDocumentNumber(int $tenantId): string } /** - * 결재 단계 생성 + * 결재 단계 생성 + 결재자 스냅샷 저장 * 프론트엔드 호환성: step_type/approver_id 또는 type/user_id 지원 * 중복 결재자 자동 제거 */ private function createApprovalSteps(Approval $approval, array $steps): void { + $tenantId = $this->tenantId(); $order = 1; $processedApprovers = []; // 중복 체크용 @@ -1142,14 +1576,248 @@ private function createApprovalSteps(Approval $approval, array $steps): void } $processedApprovers[] = $approverId; + // 결재자 스냅샷 (이름/부서/직위) + $approverName = $step['approver_name'] ?? ''; + $approverDepartment = $step['approver_department'] ?? null; + $approverPosition = $step['approver_position'] ?? null; + + // 스냅샷이 비어있으면 DB에서 조회 + if (empty($approverName)) { + $user = User::find($approverId); + if ($user) { + $approverName = $user->name; + $profile = $user->tenantProfile ?? $user->tenantProfiles() + ->where('tenant_id', $tenantId) + ->first(); + if ($profile) { + $approverDepartment = $approverDepartment ?: ($profile->department?->name ?? null); + $approverPosition = $approverPosition ?: ($profile->position_label ?? $profile->job_title_label ?? null); + } + } + } + ApprovalStep::create([ 'approval_id' => $approval->id, 'step_order' => $stepOrder, 'step_type' => $stepType, 'approver_id' => $approverId, + 'approver_name' => $approverName, + 'approver_department' => $approverDepartment, + 'approver_position' => $approverPosition, 'status' => ApprovalStep::STATUS_PENDING, ]); } } } + + // ========================================================================= + // Leave 연동 (휴가/근태신청/사유서) + // ========================================================================= + + /** + * 휴가/근태신청/사유서 관련 결재 양식인지 확인 + */ + private function isLeaveRelatedForm(?string $code): bool + { + return in_array($code, ['leave', 'attendance_request', 'reason_report']); + } + + /** + * 결재 최종 승인 시 연동 처리 + */ + private function handleApprovalCompleted(Approval $approval): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + + // 기안함에서 직접 올린 경우: Leave 레코드 자동 생성 + if (! $leave && ! empty($approval->content['leave_type'])) { + $leave = $this->createLeaveFromApproval($approval); + } + + if ($leave && $leave->status === 'pending') { + $leave->update([ + 'status' => 'approved', + 'updated_by' => $approval->updated_by, + ]); + } + } + + /** + * 결재 반려 시 연동 처리 + */ + private function handleApprovalRejected(Approval $approval, string $comment): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + if ($leave && $leave->status === 'pending') { + $leave->update([ + 'status' => 'rejected', + 'updated_by' => $approval->updated_by, + ]); + } + } + + /** + * 결재 회수 시 연동 처리 + */ + private function handleApprovalCancelled(Approval $approval): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + if ($leave && in_array($leave->status, ['pending', 'approved'])) { + $leave->update([ + 'status' => 'cancelled', + 'updated_by' => $approval->updated_by, + ]); + } + } + + /** + * 결재 삭제 시 연동 처리 + */ + private function handleApprovalDeleted(Approval $approval): void + { + $approval->loadMissing('form'); + + if (! $approval->form || ! $this->isLeaveRelatedForm($approval->form->code)) { + return; + } + + $leave = Leave::where('approval_id', $approval->id)->first(); + if ($leave && in_array($leave->status, ['pending', 'approved'])) { + $leave->update([ + 'status' => 'cancelled', + 'updated_by' => $this->apiUserId(), + ]); + } + } + + /** + * 기안함에서 직접 올린 결재 → Leave 레코드 자동 생성 + */ + private function createLeaveFromApproval(Approval $approval): Leave + { + $content = $approval->content; + + return Leave::create([ + 'tenant_id' => $approval->tenant_id, + 'user_id' => $content['user_id'] ?? $approval->drafter_id, + 'leave_type' => $content['leave_type'], + 'start_date' => $content['start_date'], + 'end_date' => $content['end_date'], + 'days' => $content['days'] ?? 0, + 'reason' => $content['reason'] ?? null, + 'status' => 'pending', + 'approval_id' => $approval->id, + 'created_by' => $approval->drafter_id, + 'updated_by' => $approval->drafter_id, + ]); + } + + // ========================================================================= + // 위임 관리 + // ========================================================================= + + /** + * 위임 목록 + */ + public function delegationIndex(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + + $query = ApprovalDelegation::query() + ->where('tenant_id', $tenantId) + ->with(['delegator:id,name', 'delegate:id,name']); + + if (! empty($params['delegator_id'])) { + $query->where('delegator_id', $params['delegator_id']); + } + + if (isset($params['is_active'])) { + $query->where('is_active', $params['is_active']); + } + + $query->orderByDesc('created_at'); + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 위임 생성 + */ + public function delegationStore(array $data): ApprovalDelegation + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return ApprovalDelegation::create([ + 'tenant_id' => $tenantId, + 'delegator_id' => $data['delegator_id'], + 'delegate_id' => $data['delegate_id'], + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'form_ids' => $data['form_ids'] ?? null, + 'notify_delegator' => $data['notify_delegator'] ?? true, + 'is_active' => $data['is_active'] ?? true, + 'reason' => $data['reason'] ?? null, + 'created_by' => $userId, + ]); + } + + /** + * 위임 수정 + */ + public function delegationUpdate(int $id, array $data): ApprovalDelegation + { + $tenantId = $this->tenantId(); + + $delegation = ApprovalDelegation::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $delegation->fill([ + 'delegate_id' => $data['delegate_id'] ?? $delegation->delegate_id, + 'start_date' => $data['start_date'] ?? $delegation->start_date, + 'end_date' => $data['end_date'] ?? $delegation->end_date, + 'form_ids' => array_key_exists('form_ids', $data) ? $data['form_ids'] : $delegation->form_ids, + 'notify_delegator' => $data['notify_delegator'] ?? $delegation->notify_delegator, + 'is_active' => $data['is_active'] ?? $delegation->is_active, + 'reason' => $data['reason'] ?? $delegation->reason, + ]); + + $delegation->save(); + + return $delegation->fresh(['delegator:id,name', 'delegate:id,name']); + } + + /** + * 위임 삭제 + */ + public function delegationDestroy(int $id): bool + { + $tenantId = $this->tenantId(); + + $delegation = ApprovalDelegation::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + return $delegation->delete(); + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 7684cc9..bc537ea 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -232,15 +232,22 @@ 'form_code_exists' => '중복된 양식 코드입니다.', 'form_in_use' => '사용 중인 양식은 삭제할 수 없습니다.', 'line_not_found' => '결재선을 찾을 수 없습니다.', - 'not_editable' => '임시저장 상태의 문서만 수정할 수 있습니다.', - 'not_deletable' => '임시저장 상태의 문서만 삭제할 수 있습니다.', - 'not_submittable' => '임시저장 상태의 문서만 상신할 수 있습니다.', + 'not_editable' => '수정할 수 없는 상태입니다.', + 'not_deletable' => '삭제할 수 없는 상태입니다.', + 'not_submittable' => '상신할 수 없는 상태입니다.', 'not_actionable' => '진행중인 문서에서만 결재 가능합니다.', - 'not_cancellable' => '진행중인 문서만 회수할 수 있습니다.', + 'not_cancellable' => '회수할 수 없는 상태입니다.', + 'not_holdable' => '보류할 수 없는 상태입니다.', + 'not_hold_releasable' => '보류 해제할 수 없는 상태입니다.', + 'not_copyable' => '복사할 수 없는 상태입니다.', 'not_your_turn' => '현재 결재 순서가 아닙니다.', 'only_drafter_can_cancel' => '기안자만 회수할 수 있습니다.', + 'only_drafter_can_copy' => '기안자만 복사할 수 있습니다.', + 'only_holder_can_release' => '보류한 결재자만 해제할 수 있습니다.', + 'first_approver_already_acted' => '첫 번째 결재자가 이미 처리하여 회수할 수 없습니다.', 'steps_required' => '결재선은 필수입니다.', 'reject_comment_required' => '반려 사유는 필수입니다.', + 'comment_required' => '사유를 입력해주세요.', 'not_referee' => '참조자가 아닙니다.', ], diff --git a/lang/ko/message.php b/lang/ko/message.php index 5f20784..60a6b53 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -244,6 +244,10 @@ 'approved' => '결재가 승인되었습니다.', 'rejected' => '결재가 반려되었습니다.', 'cancelled' => '결재가 회수되었습니다.', + 'held' => '결재가 보류되었습니다.', + 'hold_released' => '보류가 해제되었습니다.', + 'pre_decided' => '전결 처리되었습니다.', + 'copied' => '문서가 복사되었습니다.', 'marked_read' => '열람 처리되었습니다.', 'marked_unread' => '미열람 처리되었습니다.', 'form_created' => '결재 양식이 등록되었습니다.', diff --git a/routes/api/v1/hr.php b/routes/api/v1/hr.php index 0353a7e..102addb 100644 --- a/routes/api/v1/hr.php +++ b/routes/api/v1/hr.php @@ -139,6 +139,17 @@ Route::get('/inbox/summary', [ApprovalController::class, 'inboxSummary'])->name('v1.approvals.inbox.summary'); // 참조함 Route::get('/reference', [ApprovalController::class, 'reference'])->name('v1.approvals.reference'); + // 완료함 + Route::get('/completed', [ApprovalController::class, 'completed'])->name('v1.approvals.completed'); + Route::get('/completed/summary', [ApprovalController::class, 'completedSummary'])->name('v1.approvals.completed.summary'); + Route::post('/completed/mark-read', [ApprovalController::class, 'markCompletedAsRead'])->name('v1.approvals.completed.mark-read'); + // 뱃지 카운트 + Route::get('/badge-counts', [ApprovalController::class, 'badgeCounts'])->name('v1.approvals.badge-counts'); + // 위임 관리 + Route::get('/delegations', [ApprovalController::class, 'delegationIndex'])->name('v1.approvals.delegations.index'); + Route::post('/delegations', [ApprovalController::class, 'delegationStore'])->name('v1.approvals.delegations.store'); + Route::patch('/delegations/{id}', [ApprovalController::class, 'delegationUpdate'])->whereNumber('id')->name('v1.approvals.delegations.update'); + Route::delete('/delegations/{id}', [ApprovalController::class, 'delegationDestroy'])->whereNumber('id')->name('v1.approvals.delegations.destroy'); // CRUD Route::post('', [ApprovalController::class, 'store'])->name('v1.approvals.store'); Route::get('/{id}', [ApprovalController::class, 'show'])->whereNumber('id')->name('v1.approvals.show'); @@ -149,6 +160,10 @@ Route::post('/{id}/approve', [ApprovalController::class, 'approve'])->whereNumber('id')->name('v1.approvals.approve'); Route::post('/{id}/reject', [ApprovalController::class, 'reject'])->whereNumber('id')->name('v1.approvals.reject'); Route::post('/{id}/cancel', [ApprovalController::class, 'cancel'])->whereNumber('id')->name('v1.approvals.cancel'); + Route::post('/{id}/hold', [ApprovalController::class, 'hold'])->whereNumber('id')->name('v1.approvals.hold'); + Route::post('/{id}/release-hold', [ApprovalController::class, 'releaseHold'])->whereNumber('id')->name('v1.approvals.release-hold'); + Route::post('/{id}/pre-decide', [ApprovalController::class, 'preDecide'])->whereNumber('id')->name('v1.approvals.pre-decide'); + Route::post('/{id}/copy', [ApprovalController::class, 'copyForRedraft'])->whereNumber('id')->name('v1.approvals.copy'); // 참조 열람 Route::post('/{id}/read', [ApprovalController::class, 'markRead'])->whereNumber('id')->name('v1.approvals.read'); Route::post('/{id}/unread', [ApprovalController::class, 'markUnread'])->whereNumber('id')->name('v1.approvals.unread');