From 5e4cbc774218c98bb3abf931f864cbfd6dba20dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Mar 2026 02:58:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EB=AC=B8=EC=84=9C]=20rendered=5Fhtml?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A0=80=EC=9E=A5=20+=20snaps?= =?UTF-8?q?hot=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document upsert에 rendered_html 필드 추가 - Lazy Snapshot API (snapshot_document_id resolve) - UpsertRequest rendered_html 검증 추가 - DocumentTemplateSection description 컬럼 Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/Documents/DocumentController.php | 16 ++ app/Http/Requests/Document/StoreRequest.php | 3 + app/Http/Requests/Document/UpdateRequest.php | 3 + app/Http/Requests/Document/UpsertRequest.php | 3 + app/Models/Documents/Document.php | 1 + .../Documents/DocumentTemplateSection.php | 1 + app/Services/DocumentService.php | 162 ++++++++++++++++-- ...cription_to_document_template_sections.php | 22 +++ routes/api/v1/documents.php | 1 + 9 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php diff --git a/app/Http/Controllers/Api/V1/Documents/DocumentController.php b/app/Http/Controllers/Api/V1/Documents/DocumentController.php index 729ed46..d63bdf9 100644 --- a/app/Http/Controllers/Api/V1/Documents/DocumentController.php +++ b/app/Http/Controllers/Api/V1/Documents/DocumentController.php @@ -74,6 +74,22 @@ public function destroy(int $id): JsonResponse }, __('message.deleted')); } + /** + * rendered_html 스냅샷 저장 (Lazy Snapshot) + * PATCH /v1/documents/{id}/snapshot + */ + public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse + { + return ApiResponse::handle(function () use ($id, $request) { + $renderedHtml = $request->validated()['rendered_html'] ?? null; + if (! $renderedHtml) { + throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required'); + } + + return $this->service->patchSnapshot($id, $renderedHtml); + }, __('message.updated')); + } + // ========================================================================= // FQC 일괄생성 (제품검사) // ========================================================================= diff --git a/app/Http/Requests/Document/StoreRequest.php b/app/Http/Requests/Document/StoreRequest.php index a6681e1..4a4e977 100644 --- a/app/Http/Requests/Document/StoreRequest.php +++ b/app/Http/Requests/Document/StoreRequest.php @@ -28,6 +28,9 @@ public function rules(): array 'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id', 'approvers.*.role' => 'nullable|string|max:50', + // HTML 스냅샷 + 'rendered_html' => 'nullable|string', + // 문서 데이터 (EAV) 'data' => 'nullable|array', 'data.*.section_id' => 'nullable|integer', diff --git a/app/Http/Requests/Document/UpdateRequest.php b/app/Http/Requests/Document/UpdateRequest.php index 8388c00..8b07e69 100644 --- a/app/Http/Requests/Document/UpdateRequest.php +++ b/app/Http/Requests/Document/UpdateRequest.php @@ -27,6 +27,9 @@ public function rules(): array 'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id', 'approvers.*.role' => 'nullable|string|max:50', + // HTML 스냅샷 + 'rendered_html' => 'nullable|string', + // 문서 데이터 (EAV) 'data' => 'nullable|array', 'data.*.section_id' => 'nullable|integer', diff --git a/app/Http/Requests/Document/UpsertRequest.php b/app/Http/Requests/Document/UpsertRequest.php index 99c1920..dc7eba3 100644 --- a/app/Http/Requests/Document/UpsertRequest.php +++ b/app/Http/Requests/Document/UpsertRequest.php @@ -30,6 +30,9 @@ public function rules(): array 'data.*.field_key' => 'required_with:data|string|max:100', 'data.*.field_value' => 'nullable|string', + // HTML 스냅샷 + 'rendered_html' => 'nullable|string', + // 첨부파일 'attachments' => 'nullable|array', 'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id', diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php index 1f5b4ed..8ce6a10 100644 --- a/app/Models/Documents/Document.php +++ b/app/Models/Documents/Document.php @@ -73,6 +73,7 @@ class Document extends Model 'linkable_id', 'submitted_at', 'completed_at', + 'rendered_html', 'created_by', 'updated_by', 'deleted_by', diff --git a/app/Models/Documents/DocumentTemplateSection.php b/app/Models/Documents/DocumentTemplateSection.php index 1346f35..de149e6 100644 --- a/app/Models/Documents/DocumentTemplateSection.php +++ b/app/Models/Documents/DocumentTemplateSection.php @@ -22,6 +22,7 @@ class DocumentTemplateSection extends Model protected $fillable = [ 'template_id', 'title', + 'description', 'image_path', 'sort_order', ]; diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index b4e614c..ed25399 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -7,6 +7,10 @@ use App\Models\Documents\DocumentAttachment; use App\Models\Documents\DocumentData; use App\Models\Documents\DocumentTemplate; +use App\Models\Tenants\Approval; +use App\Models\Tenants\ApprovalForm; +use App\Models\Tenants\ApprovalLine; +use App\Models\Tenants\ApprovalStep; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -118,6 +122,7 @@ public function create(array $data): Document 'status' => Document::STATUS_DRAFT, 'linkable_type' => $data['linkable_type'] ?? null, 'linkable_id' => $data['linkable_id'] ?? null, + 'rendered_html' => $data['rendered_html'] ?? null, 'created_by' => $userId, 'updated_by' => $userId, ]); @@ -166,12 +171,16 @@ public function update(int $id, array $data): Document } // 기본 정보 수정 - $document->fill([ + $updateFields = [ 'title' => $data['title'] ?? $document->title, 'linkable_type' => $data['linkable_type'] ?? $document->linkable_type, 'linkable_id' => $data['linkable_id'] ?? $document->linkable_id, 'updated_by' => $userId, - ]); + ]; + if (isset($data['rendered_html'])) { + $updateFields['rendered_html'] = $data['rendered_html']; + } + $document->fill($updateFields); // 반려 상태에서 수정 시 DRAFT로 변경 if ($document->status === Document::STATUS_REJECTED) { @@ -275,6 +284,9 @@ public function submit(int $id): Document $document->updated_by = $userId; $document->save(); + // Approval 시스템 브릿지: 결재함(/approval/inbox)에 표시되도록 Approval 자동 생성 + $this->createApprovalBridge($document); + return $document->fresh([ 'template:id,name,category', 'approvals.user:id,name', @@ -283,6 +295,78 @@ public function submit(int $id): Document }); } + /** + * Document → Approval 브릿지 생성 + * Document 상신 시 Approval 레코드를 자동 생성하여 /approval/inbox에 표시 + */ + private function createApprovalBridge(Document $document): void + { + $form = ApprovalForm::where('code', 'document') + ->where('tenant_id', $document->tenant_id) + ->first(); + + if (! $form) { + return; // 문서 결재 양식 미등록 시 스킵 (기존 동작 유지) + } + + // 기존 브릿지가 있으면 스킵 (재상신 방지) + $existingApproval = Approval::where('linkable_type', Document::class) + ->where('linkable_id', $document->id) + ->whereNotIn('status', [Approval::STATUS_CANCELLED]) + ->first(); + + if ($existingApproval) { + return; + } + + // 문서번호 생성 (Approval 체계) + $today = now()->format('Ymd'); + $lastNumber = Approval::where('tenant_id', $document->tenant_id) + ->where('document_number', 'like', "AP-{$today}-%") + ->orderByDesc('document_number') + ->value('document_number'); + + $seq = 1; + if ($lastNumber && preg_match('/AP-\d{8}-(\d{4})/', $lastNumber, $matches)) { + $seq = (int) $matches[1] + 1; + } + $documentNumber = sprintf('AP-%s-%04d', $today, $seq); + + $approval = Approval::create([ + 'tenant_id' => $document->tenant_id, + 'document_number' => $documentNumber, + 'form_id' => $form->id, + 'title' => $document->title, + 'content' => [ + 'document_id' => $document->id, + 'template_id' => $document->template_id, + 'document_no' => $document->document_no, + ], + 'status' => Approval::STATUS_PENDING, + 'drafter_id' => $document->created_by, + 'drafted_at' => now(), + 'current_step' => 1, + 'linkable_type' => Document::class, + 'linkable_id' => $document->id, + 'created_by' => $document->updated_by ?? $document->created_by, + ]); + + // document_approvals → approval_steps 변환 + $docApprovals = $document->approvals() + ->orderBy('step') + ->get(); + + foreach ($docApprovals as $docApproval) { + ApprovalStep::create([ + 'approval_id' => $approval->id, + 'step_order' => $docApproval->step, + 'step_type' => ApprovalLine::STEP_TYPE_APPROVAL, + 'approver_id' => $docApproval->user_id, + 'status' => ApprovalStep::STATUS_PENDING, + ]); + } + } + /** * 결재 승인 */ @@ -579,20 +663,32 @@ public function fqcStatus(int $orderId, int $templateId): array $tenantId = $this->tenantId(); $order = \App\Models\Orders\Order::where('tenant_id', $tenantId) - ->with('items') + ->with(['rootNodes.items' => fn ($q) => $q->orderBy('sort_order')]) ->findOrFail($orderId); - // 해당 수주의 FQC 문서 조회 + // 개소별 대표 OrderItem ID 수집 + $representativeItemIds = $order->rootNodes + ->map(fn ($node) => $node->items->first()?->id) + ->filter() + ->values(); + + // 해당 대표 품목의 FQC 문서 조회 $documents = Document::where('tenant_id', $tenantId) ->where('template_id', $templateId) ->where('linkable_type', \App\Models\Orders\OrderItem::class) - ->whereIn('linkable_id', $order->items->pluck('id')) + ->whereIn('linkable_id', $representativeItemIds) ->with('data') ->get() ->keyBy('linkable_id'); - $items = $order->items->map(function ($orderItem) use ($documents) { - $doc = $documents->get($orderItem->id); + // 개소(root node)별 진행현황 + $items = $order->rootNodes->map(function ($node) use ($documents) { + $representativeItem = $node->items->first(); + if (! $representativeItem) { + return null; + } + + $doc = $documents->get($representativeItem->id); // 종합판정 값 추출 $judgement = null; @@ -602,17 +698,17 @@ public function fqcStatus(int $orderId, int $templateId): array } return [ - 'order_item_id' => $orderItem->id, - 'floor_code' => $orderItem->floor_code, - 'symbol_code' => $orderItem->symbol_code, - 'specification' => $orderItem->specification, - 'item_name' => $orderItem->item_name, + 'order_item_id' => $representativeItem->id, + 'floor_code' => $representativeItem->floor_code, + 'symbol_code' => $representativeItem->symbol_code, + 'specification' => $representativeItem->specification, + 'item_name' => $representativeItem->item_name, 'document_id' => $doc?->id, 'document_no' => $doc?->document_no, 'status' => $doc?->status ?? 'NONE', 'judgement' => $judgement, ]; - }); + })->filter()->values(); // 통계 $total = $items->count(); @@ -634,6 +730,28 @@ public function fqcStatus(int $orderId, int $templateId): array ]; } + // ========================================================================= + // Snapshot (Lazy Snapshot) + // ========================================================================= + + /** + * rendered_html만 업데이트 (상태 무관, canEdit 체크 없음) + * Lazy Snapshot: 조회 시 rendered_html이 없으면 프론트에서 캡처 후 저장 + */ + public function patchSnapshot(int $id, string $renderedHtml): Document + { + $tenantId = $this->tenantId(); + + $document = Document::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $document->rendered_html = $renderedHtml; + $document->save(); + + return $document; + } + // ========================================================================= // Resolve/Upsert (React 연동용) // ========================================================================= @@ -773,24 +891,34 @@ public function upsert(array $data): Document if ($existingDocument) { // UPDATE: 기존 update 로직 재사용 - return $this->update($existingDocument->id, [ + $updatePayload = [ 'title' => $data['title'] ?? $existingDocument->title, 'linkable_type' => 'item', 'linkable_id' => $itemId, 'data' => $data['data'] ?? [], 'attachments' => $data['attachments'] ?? [], - ]); + ]; + if (isset($data['rendered_html'])) { + $updatePayload['rendered_html'] = $data['rendered_html']; + } + + return $this->update($existingDocument->id, $updatePayload); } // CREATE: 기존 create 로직 재사용 - return $this->create([ + $createPayload = [ 'template_id' => $templateId, 'title' => $data['title'] ?? '', 'linkable_type' => 'item', 'linkable_id' => $itemId, 'data' => $data['data'] ?? [], 'attachments' => $data['attachments'] ?? [], - ]); + ]; + if (isset($data['rendered_html'])) { + $createPayload['rendered_html'] = $data['rendered_html']; + } + + return $this->create($createPayload); }); } diff --git a/database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php b/database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php new file mode 100644 index 0000000..50b5e71 --- /dev/null +++ b/database/migrations/2026_03_06_212500_add_description_to_document_template_sections.php @@ -0,0 +1,22 @@ +text('description')->nullable()->after('title')->comment('섹션 설명/안내문'); + }); + } + + public function down(): void + { + Schema::table('document_template_sections', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/routes/api/v1/documents.php b/routes/api/v1/documents.php index ae56c70..2044ab4 100644 --- a/routes/api/v1/documents.php +++ b/routes/api/v1/documents.php @@ -33,6 +33,7 @@ Route::get('/{id}', [DocumentController::class, 'show'])->whereNumber('id')->name('v1.documents.show'); Route::post('/', [DocumentController::class, 'store'])->name('v1.documents.store'); Route::patch('/{id}', [DocumentController::class, 'update'])->whereNumber('id')->name('v1.documents.update'); + Route::patch('/{id}/snapshot', [DocumentController::class, 'patchSnapshot'])->whereNumber('id')->name('v1.documents.snapshot'); Route::delete('/{id}', [DocumentController::class, 'destroy'])->whereNumber('id')->name('v1.documents.destroy'); // 결재 워크플로우