diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentController.php b/app/Http/Controllers/Api/V1/Documents/DocumentController.php index 9cbc832..169d7f4 100644 --- a/app/Http/Controllers/Api/V1/Documents/DocumentController.php +++ b/app/Http/Controllers/Api/V1/Documents/DocumentController.php @@ -7,8 +7,10 @@ use App\Http\Requests\Document\ApproveRequest; use App\Http\Requests\Document\IndexRequest; use App\Http\Requests\Document\RejectRequest; +use App\Http\Requests\Document\ResolveRequest; use App\Http\Requests\Document\StoreRequest; use App\Http\Requests\Document\UpdateRequest; +use App\Http\Requests\Document\UpsertRequest; use App\Services\DocumentService; use Illuminate\Http\JsonResponse; @@ -71,6 +73,32 @@ public function destroy(int $id): JsonResponse }, __('message.deleted')); } + // ========================================================================= + // Resolve/Upsert (React 연동용) + // ========================================================================= + + /** + * 문서 Resolve + * GET /v1/documents/resolve?category=incoming_inspection&item_id=12596 + */ + public function resolve(ResolveRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->resolve($request->validated()); + }, __('message.fetched')); + } + + /** + * 문서 Upsert + * POST /v1/documents/upsert + */ + public function upsert(UpsertRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->upsert($request->validated()); + }, __('message.saved')); + } + // ========================================================================= // 결재 워크플로우 // ========================================================================= diff --git a/app/Http/Requests/Document/ResolveRequest.php b/app/Http/Requests/Document/ResolveRequest.php new file mode 100644 index 0000000..0aad3cb --- /dev/null +++ b/app/Http/Requests/Document/ResolveRequest.php @@ -0,0 +1,30 @@ + 'required|string|max:50', + 'item_id' => 'required|integer', + ]; + } + + public function messages(): array + { + return [ + 'category.required' => __('validation.required', ['attribute' => '문서 분류']), + 'item_id.required' => __('validation.required', ['attribute' => '품목 ID']), + 'item_id.integer' => __('validation.integer', ['attribute' => '품목 ID']), + ]; + } +} diff --git a/app/Http/Requests/Document/UpsertRequest.php b/app/Http/Requests/Document/UpsertRequest.php new file mode 100644 index 0000000..99c1920 --- /dev/null +++ b/app/Http/Requests/Document/UpsertRequest.php @@ -0,0 +1,51 @@ + 'required|integer|exists:document_templates,id', + 'item_id' => 'required|integer', + 'title' => 'nullable|string|max:255', + + // 문서 데이터 (EAV) + 'data' => 'nullable|array', + 'data.*.section_id' => 'nullable|integer', + 'data.*.column_id' => 'nullable|integer', + 'data.*.row_index' => 'nullable|integer|min:0', + 'data.*.field_key' => 'required_with:data|string|max:100', + 'data.*.field_value' => 'nullable|string', + + // 첨부파일 + 'attachments' => 'nullable|array', + 'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id', + 'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}", + 'attachments.*.description' => 'nullable|string|max:255', + ]; + } + + public function messages(): array + { + return [ + 'template_id.required' => __('validation.required', ['attribute' => '템플릿']), + 'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']), + 'item_id.required' => __('validation.required', ['attribute' => '품목 ID']), + 'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']), + 'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']), + ]; + } +} diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index e52df08..04d2bc4 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -6,9 +6,11 @@ use App\Models\Documents\DocumentApproval; use App\Models\Documents\DocumentAttachment; use App\Models\Documents\DocumentData; +use App\Models\Documents\DocumentTemplate; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class DocumentService extends Service { @@ -443,6 +445,286 @@ public function cancel(int $id): Document }); } + // ========================================================================= + // Resolve/Upsert (React 연동용) + // ========================================================================= + + /** + * 문서 Resolve (category + item_id로 템플릿 + 기존 문서 조회) + * + * React에서 문서 작성 시: + * 1. category + item_id로 해당 품목이 연결된 템플릿 조회 + * 2. 기존 문서가 있으면 그 문서를, 없으면 빈 폼 + is_new=true 반환 + */ + public function resolve(array $params): array + { + $tenantId = $this->tenantId(); + $category = $params['category']; + $itemId = $params['item_id']; + + // 1. common_codes에서 category 유효성 확인 (tenant 우선, 없으면 global) + $validCategory = DB::table('common_codes') + ->where('code_group', 'document_category') + ->where('code', $category) + ->where('is_active', true) + ->where(function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->orWhereNull('tenant_id'); + }) + ->orderByRaw('tenant_id IS NULL') // tenant 우선 + ->first(); + + if (! $validCategory) { + throw new BadRequestHttpException(__('error.document.invalid_category')); + } + + // 2. category에 매칭되는 템플릿 + 해당 item_id가 연결된 것 조회 + // category 필드 값은 기존 데이터가 "수입검사", "품질검사" 등 한글 또는 + // common_code의 code와 매핑되어야 함 + // 우선 code_name 매핑: incoming_inspection → 수입검사 + $categoryMapping = [ + 'incoming_inspection' => '수입검사', + 'quality_inspection' => '품질검사', + 'outgoing_inspection' => '출하검사', + ]; + $categoryName = $categoryMapping[$category] ?? $category; + + $template = DocumentTemplate::query() + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->where(function ($q) use ($category, $categoryName) { + // category 필드가 code 또는 name과 매칭 + $q->where('category', $category) + ->orWhere('category', $categoryName) + ->orWhere('category', 'LIKE', "%{$categoryName}%"); + }) + ->whereHas('links', function ($q) use ($itemId) { + // 해당 item_id가 연결된 템플릿만 + $q->where('source_table', 'items') + ->whereHas('linkValues', function ($q2) use ($itemId) { + $q2->where('linkable_id', $itemId); + }); + }) + ->with([ + 'approvalLines', + 'basicFields', + 'sections.items', + 'columns', + 'sectionFields', + 'links.linkValues', + ]) + ->first(); + + if (! $template) { + throw new NotFoundHttpException(__('error.document.template_not_found')); + } + + // 3. 기존 문서 조회 (template + item_id, 수정 가능한 상태만) + $existingDocument = Document::query() + ->where('tenant_id', $tenantId) + ->where('template_id', $template->id) + ->where('linkable_type', 'item') + ->where('linkable_id', $itemId) + ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) + ->with(['data', 'attachments.file', 'approvals.user:id,name']) + ->first(); + + // 4. 품목 정보 조회 (auto-highlight용 속성 포함) + $item = DB::table('items') + ->where('id', $itemId) + ->where('tenant_id', $tenantId) + ->select('id', 'code', 'name', 'attributes') + ->first(); + + if (! $item) { + throw new NotFoundHttpException(__('error.item.not_found')); + } + + // 5. 응답 구성 + return [ + 'is_new' => $existingDocument === null, + 'template' => $this->formatTemplateForReact($template), + 'document' => $existingDocument ? $this->formatDocumentForReact($existingDocument) : null, + 'item' => [ + 'id' => $item->id, + 'code' => $item->code, + 'name' => $item->name, + 'attributes' => $item->attributes ? json_decode($item->attributes, true) : null, + ], + ]; + } + + /** + * 문서 Upsert (INSERT if new, UPDATE if exists) + * + * React에서 문서 저장 시: + * - 기존 문서가 있으면 update + * - 없으면 create + */ + public function upsert(array $data): Document + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId) { + $templateId = $data['template_id']; + $itemId = $data['item_id']; + + // 기존 문서 조회 (수정 가능한 상태만) + $existingDocument = Document::query() + ->where('tenant_id', $tenantId) + ->where('template_id', $templateId) + ->where('linkable_type', 'item') + ->where('linkable_id', $itemId) + ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) + ->first(); + + if ($existingDocument) { + // UPDATE: 기존 update 로직 재사용 + return $this->update($existingDocument->id, [ + 'title' => $data['title'] ?? $existingDocument->title, + 'linkable_type' => 'item', + 'linkable_id' => $itemId, + 'data' => $data['data'] ?? [], + 'attachments' => $data['attachments'] ?? [], + ]); + } + + // CREATE: 기존 create 로직 재사용 + return $this->create([ + 'template_id' => $templateId, + 'title' => $data['title'] ?? '', + 'linkable_type' => 'item', + 'linkable_id' => $itemId, + 'data' => $data['data'] ?? [], + 'attachments' => $data['attachments'] ?? [], + ]); + }); + } + + /** + * 템플릿을 React 응답용으로 포맷 + */ + private function formatTemplateForReact(DocumentTemplate $template): array + { + return [ + 'id' => $template->id, + 'name' => $template->name, + 'category' => $template->category, + 'title' => $template->title, + 'company_name' => $template->company_name, + 'company_address' => $template->company_address, + 'company_contact' => $template->company_contact, + 'footer_remark_label' => $template->footer_remark_label, + 'footer_judgement_label' => $template->footer_judgement_label, + 'footer_judgement_options' => $template->footer_judgement_options, + 'approval_lines' => $template->approvalLines->map(fn ($line) => [ + 'id' => $line->id, + 'role' => $line->role, + 'user_id' => $line->user_id, + 'sort_order' => $line->sort_order, + ])->toArray(), + 'basic_fields' => $template->basicFields->map(fn ($field) => [ + 'id' => $field->id, + 'field_key' => $field->field_key, + 'label' => $field->label, + 'input_type' => $field->input_type, + 'options' => $field->options, + 'default_value' => $field->default_value, + 'is_required' => $field->is_required, + 'sort_order' => $field->sort_order, + ])->toArray(), + 'section_fields' => $template->sectionFields->map(fn ($field) => [ + 'id' => $field->id, + 'field_key' => $field->field_key, + 'label' => $field->label, + 'field_type' => $field->field_type, + 'options' => $field->options, + 'width' => $field->width, + 'is_required' => $field->is_required, + 'sort_order' => $field->sort_order, + ])->toArray(), + 'sections' => $template->sections->map(fn ($section) => [ + 'id' => $section->id, + 'name' => $section->name, + 'sort_order' => $section->sort_order, + 'items' => $section->items->map(fn ($item) => [ + 'id' => $item->id, + 'field_values' => $item->field_values ?? [], + // 레거시 필드도 포함 (하위 호환) + 'category' => $item->category, + 'item' => $item->item, + 'standard' => $item->standard, + 'standard_criteria' => $item->standard_criteria, + 'tolerance' => $item->tolerance, + 'method' => $item->method, + 'measurement_type' => $item->measurement_type, + 'frequency' => $item->frequency, + 'frequency_n' => $item->frequency_n, + 'frequency_c' => $item->frequency_c, + 'regulation' => $item->regulation, + 'sort_order' => $item->sort_order, + ])->toArray(), + ])->toArray(), + 'columns' => $template->columns->map(fn ($col) => [ + 'id' => $col->id, + 'label' => $col->label, + 'input_type' => $col->input_type, + 'options' => $col->options, + 'width' => $col->width, + 'is_required' => $col->is_required, + 'sort_order' => $col->sort_order, + ])->toArray(), + ]; + } + + /** + * 문서를 React 응답용으로 포맷 + */ + private function formatDocumentForReact(Document $document): array + { + return [ + 'id' => $document->id, + 'document_no' => $document->document_no, + 'title' => $document->title, + 'status' => $document->status, + 'linkable_type' => $document->linkable_type, + 'linkable_id' => $document->linkable_id, + 'submitted_at' => $document->submitted_at?->toIso8601String(), + 'completed_at' => $document->completed_at?->toIso8601String(), + 'created_at' => $document->created_at?->toIso8601String(), + 'data' => $document->data->map(fn ($d) => [ + 'section_id' => $d->section_id, + 'column_id' => $d->column_id, + 'row_index' => $d->row_index, + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + ])->toArray(), + 'attachments' => $document->attachments->map(fn ($a) => [ + 'id' => $a->id, + 'file_id' => $a->file_id, + 'attachment_type' => $a->attachment_type, + 'description' => $a->description, + 'file' => $a->file ? [ + 'id' => $a->file->id, + 'original_name' => $a->file->original_name, + 'file_path' => $a->file->file_path, + 'file_size' => $a->file->file_size, + ] : null, + ])->toArray(), + 'approvals' => $document->approvals->map(fn ($ap) => [ + 'id' => $ap->id, + 'user_id' => $ap->user_id, + 'user_name' => $ap->user?->name, + 'step' => $ap->step, + 'role' => $ap->role, + 'status' => $ap->status, + 'comment' => $ap->comment, + 'acted_at' => $ap->acted_at?->toIso8601String(), + ])->toArray(), + ]; + } + // ========================================================================= // 헬퍼 메서드 // ========================================================================= diff --git a/database/migrations/2026_02_05_000001_add_document_category_common_codes.php b/database/migrations/2026_02_05_000001_add_document_category_common_codes.php new file mode 100644 index 0000000..166cdb5 --- /dev/null +++ b/database/migrations/2026_02_05_000001_add_document_category_common_codes.php @@ -0,0 +1,41 @@ + 'incoming_inspection', 'name' => '수입검사', 'sort_order' => 1], + ['code' => 'quality_inspection', 'name' => '품질검사', 'sort_order' => 2], + ['code' => 'outgoing_inspection', 'name' => '출하검사', 'sort_order' => 3], + ]; + + foreach ($categories as $item) { + DB::table('common_codes')->updateOrInsert( + ['code_group' => 'document_category', 'code' => $item['code'], 'tenant_id' => null], + [ + 'code_group' => 'document_category', + 'code' => $item['code'], + 'name' => $item['name'], + 'sort_order' => $item['sort_order'], + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + } + + public function down(): void + { + DB::table('common_codes') + ->where('code_group', 'document_category') + ->whereNull('tenant_id') + ->delete(); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index ae1a98f..52bd9f7 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -200,6 +200,21 @@ 'balance_not_found' => '휴가 잔여일수 정보를 찾을 수 없습니다.', ], + // 문서 관리 관련 + 'document' => [ + 'not_found' => '문서를 찾을 수 없습니다.', + 'template_not_found' => '해당 조건에 맞는 문서 양식을 찾을 수 없습니다.', + 'invalid_category' => '유효하지 않은 문서 분류입니다.', + 'not_editable' => '현재 상태에서는 문서를 수정할 수 없습니다.', + 'not_deletable' => '임시저장 상태의 문서만 삭제할 수 있습니다.', + 'not_submittable' => '결재 요청할 수 없는 상태입니다.', + 'not_approvable' => '결재 처리할 수 없는 상태입니다.', + 'not_cancellable' => '취소할 수 없는 상태입니다.', + 'not_your_turn' => '현재 결재 순서가 아닙니다.', + 'only_creator_can_cancel' => '작성자만 취소할 수 있습니다.', + 'approvers_required' => '결재선이 필요합니다.', + ], + // 전자결재 관련 'approval' => [ 'not_found' => '결재 문서를 찾을 수 없습니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index 2e03b3d..281b02a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -15,6 +15,7 @@ 'deleted' => '삭제 성공', 'restored' => '복구 성공', 'toggled' => '상태 변경 성공', + 'saved' => '저장 성공', 'bulk_upsert' => '대량 저장 성공', 'reordered' => '정렬 변경 성공', 'linked' => '연결 성공', diff --git a/routes/api/v1/documents.php b/routes/api/v1/documents.php index f28f438..2460d43 100644 --- a/routes/api/v1/documents.php +++ b/routes/api/v1/documents.php @@ -20,6 +20,10 @@ // 문서 CRUD + 결재 Route::prefix('documents')->group(function () { + // Resolve/Upsert (React 연동용) + Route::get('/resolve', [DocumentController::class, 'resolve'])->name('v1.documents.resolve'); + Route::post('/upsert', [DocumentController::class, 'upsert'])->name('v1.documents.upsert'); + // 문서 CRUD Route::get('/', [DocumentController::class, 'index'])->name('v1.documents.index'); Route::get('/{id}', [DocumentController::class, 'show'])->whereNumber('id')->name('v1.documents.show'); @@ -32,4 +36,4 @@ Route::post('/{id}/approve', [DocumentController::class, 'approve'])->whereNumber('id')->name('v1.documents.approve'); Route::post('/{id}/reject', [DocumentController::class, 'reject'])->whereNumber('id')->name('v1.documents.reject'); Route::post('/{id}/cancel', [DocumentController::class, 'cancel'])->whereNumber('id')->name('v1.documents.cancel'); -}); \ No newline at end of file +});