diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php new file mode 100644 index 0000000..e52df08 --- /dev/null +++ b/app/Services/DocumentService.php @@ -0,0 +1,524 @@ +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, + ]); + } + } +}