tenantId(); $query = Document::query() ->where('tenant_id', $tenantId) ->with([ 'template:id,name,category', 'creator:id,name', ]); // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 템플릿 필터 if (! empty($params['template_id'])) { $query->where('template_id', $params['template_id']); } // 검색 (문서번호, 제목) if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('document_no', 'like', "%{$search}%") ->orWhere('title', 'like', "%{$search}%"); }); } // 날짜 범위 필터 if (! empty($params['from_date'])) { $query->whereDate('created_at', '>=', $params['from_date']); } if (! empty($params['to_date'])) { $query->whereDate('created_at', '<=', $params['to_date']); } // 정렬 $sortBy = $params['sort_by'] ?? 'created_at'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } /** * 문서 상세 조회 */ public function show(int $id): Document { $tenantId = $this->tenantId(); return Document::query() ->where('tenant_id', $tenantId) ->with([ 'template:id,name,category,title', 'template.approvalLines', 'template.basicFields', 'template.sections.items', 'template.columns', 'approvals.user:id,name', 'data', 'attachments.file', 'creator:id,name', 'updater:id,name', ]) ->findOrFail($id); } // ========================================================================= // 문서 생성/수정/삭제 // ========================================================================= /** * 문서 생성 */ public function create(array $data): Document { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 문서번호 생성 $documentNo = $this->generateDocumentNo($tenantId, $data['template_id']); // 문서 생성 $document = Document::create([ 'tenant_id' => $tenantId, 'template_id' => $data['template_id'], 'document_no' => $documentNo, 'title' => $data['title'], 'status' => Document::STATUS_DRAFT, 'linkable_type' => $data['linkable_type'] ?? null, 'linkable_id' => $data['linkable_id'] ?? null, 'created_by' => $userId, 'updated_by' => $userId, ]); // 결재선 생성 if (! empty($data['approvers'])) { $this->createApprovals($document, $data['approvers'], $userId); } // 문서 데이터 저장 if (! empty($data['data'])) { $this->saveDocumentData($document, $data['data']); } // 첨부파일 연결 if (! empty($data['attachments'])) { $this->attachFiles($document, $data['attachments'], $userId); } return $document->fresh([ 'template:id,name,category', 'approvals.user:id,name', 'data', 'attachments.file', 'creator:id,name', ]); }); } /** * 문서 수정 */ public function update(int $id, array $data): Document { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $data, $tenantId, $userId) { $document = Document::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // 수정 가능 상태 확인 if (! $document->canEdit()) { throw new BadRequestHttpException(__('error.document.not_editable')); } // 기본 정보 수정 $document->fill([ 'title' => $data['title'] ?? $document->title, 'linkable_type' => $data['linkable_type'] ?? $document->linkable_type, 'linkable_id' => $data['linkable_id'] ?? $document->linkable_id, 'updated_by' => $userId, ]); // 반려 상태에서 수정 시 DRAFT로 변경 if ($document->status === Document::STATUS_REJECTED) { $document->status = Document::STATUS_DRAFT; $document->submitted_at = null; $document->completed_at = null; } $document->save(); // 결재선 수정 if (isset($data['approvers'])) { $document->approvals()->delete(); if (! empty($data['approvers'])) { $this->createApprovals($document, $data['approvers'], $userId); } } // 문서 데이터 수정 if (isset($data['data'])) { $document->data()->delete(); if (! empty($data['data'])) { $this->saveDocumentData($document, $data['data']); } } // 첨부파일 수정 if (isset($data['attachments'])) { $document->attachments()->delete(); if (! empty($data['attachments'])) { $this->attachFiles($document, $data['attachments'], $userId); } } return $document->fresh([ 'template:id,name,category', 'approvals.user:id,name', 'data', 'attachments.file', 'creator:id,name', ]); }); } /** * 문서 삭제 */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $tenantId, $userId) { $document = Document::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // DRAFT 상태만 삭제 가능 if ($document->status !== Document::STATUS_DRAFT) { throw new BadRequestHttpException(__('error.document.not_deletable')); } $document->deleted_by = $userId; $document->save(); $document->delete(); return true; }); } // ========================================================================= // 결재 처리 // ========================================================================= /** * 결재 요청 (DRAFT → PENDING) */ public function submit(int $id): Document { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $tenantId, $userId) { $document = Document::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // 결재 요청 가능 상태 확인 if (! $document->canSubmit()) { throw new BadRequestHttpException(__('error.document.not_submittable')); } // 결재선 존재 확인 $approvalCount = $document->approvals()->count(); if ($approvalCount === 0) { throw new BadRequestHttpException(__('error.document.approvers_required')); } $document->status = Document::STATUS_PENDING; $document->submitted_at = now(); $document->updated_by = $userId; $document->save(); return $document->fresh([ 'template:id,name,category', 'approvals.user:id,name', 'creator:id,name', ]); }); } /** * 결재 승인 */ public function approve(int $id, ?string $comment = null): Document { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { $document = Document::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // 결재 가능 상태 확인 if (! $document->canApprove()) { throw new BadRequestHttpException(__('error.document.not_approvable')); } // 현재 사용자의 대기 중인 결재 단계 찾기 $myApproval = $document->approvals() ->where('user_id', $userId) ->where('status', DocumentApproval::STATUS_PENDING) ->orderBy('step') ->first(); if (! $myApproval) { throw new BadRequestHttpException(__('error.document.not_your_turn')); } // 순차 결재 확인 (이전 단계가 완료되었는지) $pendingBefore = $document->approvals() ->where('step', '<', $myApproval->step) ->where('status', DocumentApproval::STATUS_PENDING) ->exists(); if ($pendingBefore) { throw new BadRequestHttpException(__('error.document.not_your_turn')); } // 승인 처리 $myApproval->status = DocumentApproval::STATUS_APPROVED; $myApproval->comment = $comment; $myApproval->acted_at = now(); $myApproval->updated_by = $userId; $myApproval->save(); // 모든 결재 완료 확인 $allApproved = ! $document->approvals() ->where('status', DocumentApproval::STATUS_PENDING) ->exists(); if ($allApproved) { $document->status = Document::STATUS_APPROVED; $document->completed_at = now(); } $document->updated_by = $userId; $document->save(); return $document->fresh([ 'template:id,name,category', 'approvals.user:id,name', 'creator:id,name', ]); }); } /** * 결재 반려 */ public function reject(int $id, string $comment): Document { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $comment, $tenantId, $userId) { $document = Document::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // 결재 가능 상태 확인 if (! $document->canApprove()) { throw new BadRequestHttpException(__('error.document.not_approvable')); } // 현재 사용자의 대기 중인 결재 단계 찾기 $myApproval = $document->approvals() ->where('user_id', $userId) ->where('status', DocumentApproval::STATUS_PENDING) ->orderBy('step') ->first(); if (! $myApproval) { throw new BadRequestHttpException(__('error.document.not_your_turn')); } // 순차 결재 확인 $pendingBefore = $document->approvals() ->where('step', '<', $myApproval->step) ->where('status', DocumentApproval::STATUS_PENDING) ->exists(); if ($pendingBefore) { throw new BadRequestHttpException(__('error.document.not_your_turn')); } // 반려 처리 $myApproval->status = DocumentApproval::STATUS_REJECTED; $myApproval->comment = $comment; $myApproval->acted_at = now(); $myApproval->updated_by = $userId; $myApproval->save(); // 문서 반려 상태로 변경 $document->status = Document::STATUS_REJECTED; $document->completed_at = now(); $document->updated_by = $userId; $document->save(); return $document->fresh([ 'template:id,name,category', 'approvals.user:id,name', 'creator:id,name', ]); }); } /** * 결재 취소/회수 (작성자만) */ public function cancel(int $id): Document { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $tenantId, $userId) { $document = Document::query() ->where('tenant_id', $tenantId) ->findOrFail($id); // 취소 가능 상태 확인 if (! $document->canCancel()) { throw new BadRequestHttpException(__('error.document.not_cancellable')); } // 작성자만 취소 가능 if ($document->created_by !== $userId) { throw new BadRequestHttpException(__('error.document.only_creator_can_cancel')); } $document->status = Document::STATUS_CANCELLED; $document->completed_at = now(); $document->updated_by = $userId; $document->save(); return $document->fresh([ 'template:id,name,category', 'creator:id,name', ]); }); } // ========================================================================= // 헬퍼 메서드 // ========================================================================= /** * 문서번호 생성 */ private function generateDocumentNo(int $tenantId, int $templateId): string { $prefix = 'DOC'; $date = now()->format('Ymd'); // 오늘 생성된 문서 중 마지막 번호 조회 $lastNumber = Document::query() ->where('tenant_id', $tenantId) ->where('document_no', 'like', "{$prefix}-{$date}-%") ->orderByDesc('document_no') ->value('document_no'); if ($lastNumber) { $sequence = (int) substr($lastNumber, -4) + 1; } else { $sequence = 1; } return sprintf('%s-%s-%04d', $prefix, $date, $sequence); } /** * 결재선 생성 */ private function createApprovals(Document $document, array $approvers, int $userId): void { foreach ($approvers as $index => $approver) { DocumentApproval::create([ 'document_id' => $document->id, 'user_id' => $approver['user_id'], 'step' => $index + 1, 'role' => $approver['role'] ?? '승인', 'status' => DocumentApproval::STATUS_PENDING, 'created_by' => $userId, 'updated_by' => $userId, ]); } } /** * 문서 데이터 저장 */ private function saveDocumentData(Document $document, array $dataItems): void { foreach ($dataItems as $item) { DocumentData::create([ 'document_id' => $document->id, 'section_id' => $item['section_id'] ?? null, 'column_id' => $item['column_id'] ?? null, 'row_index' => $item['row_index'] ?? 0, 'field_key' => $item['field_key'], 'field_value' => $item['field_value'] ?? null, ]); } } /** * 첨부파일 연결 */ private function attachFiles(Document $document, array $attachments, int $userId): void { foreach ($attachments as $attachment) { DocumentAttachment::create([ 'document_id' => $document->id, 'file_id' => $attachment['file_id'], 'attachment_type' => $attachment['attachment_type'] ?? DocumentAttachment::TYPE_GENERAL, 'description' => $attachment['description'] ?? null, 'created_by' => $userId, ]); } } }