feat: [문서] rendered_html 스냅샷 저장 + snapshot 엔드포인트

- Document upsert에 rendered_html 필드 추가
- Lazy Snapshot API (snapshot_document_id resolve)
- UpsertRequest rendered_html 검증 추가
- DocumentTemplateSection description 컬럼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 02:58:06 +09:00
parent 4dd38ab14d
commit 5e4cbc7742
9 changed files with 195 additions and 17 deletions

View File

@@ -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 일괄생성 (제품검사)
// =========================================================================

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -73,6 +73,7 @@ class Document extends Model
'linkable_id',
'submitted_at',
'completed_at',
'rendered_html',
'created_by',
'updated_by',
'deleted_by',

View File

@@ -22,6 +22,7 @@ class DocumentTemplateSection extends Model
protected $fillable = [
'template_id',
'title',
'description',
'image_path',
'sort_order',
];

View File

@@ -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);
});
}

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('document_template_sections', function (Blueprint $table) {
$table->text('description')->nullable()->after('title')->comment('섹션 설명/안내문');
});
}
public function down(): void
{
Schema::table('document_template_sections', function (Blueprint $table) {
$table->dropColumn('description');
});
}
};

View File

@@ -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');
// 결재 워크플로우