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:
@@ -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 일괄생성 (제품검사)
|
||||
// =========================================================================
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -73,6 +73,7 @@ class Document extends Model
|
||||
'linkable_id',
|
||||
'submitted_at',
|
||||
'completed_at',
|
||||
'rendered_html',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
|
||||
@@ -22,6 +22,7 @@ class DocumentTemplateSection extends Model
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'title',
|
||||
'description',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
// 결재 워크플로우
|
||||
|
||||
Reference in New Issue
Block a user