feat(API): 중간검사 문서 템플릿 동적 연동 - process_steps ↔ document_templates 연결

- process_steps 테이블에 document_template_id FK 추가 (migration)
- ProcessStep 모델에 documentTemplate BelongsTo 관계 추가
- ProcessStepService에서 documentTemplate eager loading
- StoreProcessStepRequest/UpdateProcessStepRequest에 document_template_id 유효성 검증
- WorkOrderService에 getInspectionTemplate(), createInspectionDocument() 메서드 추가
- WorkOrderController에 inspection-template/inspection-document 엔드포인트 추가
- DocumentService.formatTemplateForReact() 접근자 public으로 변경
- i18n 메시지 키 추가 (inspection_document_created, no_inspection_template)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 08:35:57 +09:00
parent 7adfc6d536
commit e885b1ca45
12 changed files with 232 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-02-07 01:10:55
> **자동 생성**: 2026-02-09 22:02:49
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -594,6 +594,7 @@ ### process_steps
**모델**: `App\Models\ProcessStep`
- **process()**: belongsTo → `processes`
- **documentTemplate()**: belongsTo → `document_templates`
### work_orders
**모델**: `App\Models\Production\WorkOrder`
@@ -635,6 +636,7 @@ ### work_order_items
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items`
- **sourceOrderItem()**: belongsTo → `order_items`
### work_order_step_progress
**모델**: `App\Models\Production\WorkOrderStepProgress`
@@ -782,6 +784,11 @@ ### ai_token_usages
- **creator()**: belongsTo → `users`
### ai_voice_recordings
**모델**: `App\Models\Tenants\AiVoiceRecording`
- **user()**: belongsTo → `users`
### approvals
**모델**: `App\Models\Tenants\Approval`

View File

@@ -218,4 +218,24 @@ public function inspectionReport(int $id)
return $this->service->getInspectionReport($id);
}, __('message.work_order.fetched'));
}
/**
* 작업지시의 검사용 문서 템플릿 조회
*/
public function inspectionTemplate(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionTemplate($id);
}, __('message.work_order.fetched'));
}
/**
* 검사 완료 시 검사 문서(Document) 생성
*/
public function createInspectionDocument(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->createInspectionDocument($id, $request->all());
}, __('message.work_order.inspection_document_created'));
}
}

View File

@@ -18,6 +18,7 @@ public function rules(): array
'is_required' => ['nullable', 'boolean'],
'needs_approval' => ['nullable', 'boolean'],
'needs_inspection' => ['nullable', 'boolean'],
'document_template_id' => ['nullable', 'integer', 'exists:document_templates,id'],
'is_active' => ['nullable', 'boolean'],
'connection_type' => ['nullable', 'string', 'max:20'],
'connection_target' => ['nullable', 'string', 'max:255'],
@@ -32,6 +33,7 @@ public function attributes(): array
'is_required' => '필수여부',
'needs_approval' => '승인필요여부',
'needs_inspection' => '검사필요여부',
'document_template_id' => '문서양식',
'is_active' => '사용여부',
'connection_type' => '연결유형',
'connection_target' => '연결대상',

View File

@@ -18,6 +18,7 @@ public function rules(): array
'is_required' => ['nullable', 'boolean'],
'needs_approval' => ['nullable', 'boolean'],
'needs_inspection' => ['nullable', 'boolean'],
'document_template_id' => ['nullable', 'integer', 'exists:document_templates,id'],
'is_active' => ['nullable', 'boolean'],
'connection_type' => ['nullable', 'string', 'max:20'],
'connection_target' => ['nullable', 'string', 'max:255'],
@@ -32,6 +33,7 @@ public function attributes(): array
'is_required' => '필수여부',
'needs_approval' => '승인필요여부',
'needs_inspection' => '검사필요여부',
'document_template_id' => '문서양식',
'is_active' => '사용여부',
'connection_type' => '연결유형',
'connection_target' => '연결대상',

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Documents\DocumentTemplate;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,6 +18,7 @@ class ProcessStep extends Model
'is_required',
'needs_approval',
'needs_inspection',
'document_template_id',
'is_active',
'sort_order',
'connection_type',
@@ -39,4 +41,12 @@ public function process(): BelongsTo
{
return $this->belongsTo(Process::class);
}
/**
* 문서 양식 (검사 시 사용할 템플릿)
*/
public function documentTemplate(): BelongsTo
{
return $this->belongsTo(DocumentTemplate::class);
}
}

View File

@@ -608,7 +608,7 @@ public function upsert(array $data): Document
/**
* 템플릿을 React 응답용으로 포맷
*/
private function formatTemplateForReact(DocumentTemplate $template): array
public function formatTemplateForReact(DocumentTemplate $template): array
{
// common_codes에서 inspection_method 코드 목록 조회 (code → name 매핑)
$tenantId = $this->tenantId();

View File

@@ -17,6 +17,7 @@ public function index(int $processId)
$process = $this->findProcess($processId);
return $process->steps()
->with('documentTemplate:id,name,category')
->orderBy('sort_order')
->get();
}
@@ -28,7 +29,9 @@ public function show(int $processId, int $stepId)
{
$this->findProcess($processId);
$step = ProcessStep::where('process_id', $processId)->find($stepId);
$step = ProcessStep::where('process_id', $processId)
->with('documentTemplate:id,name,category')
->find($stepId);
if (! $step) {
throw new NotFoundHttpException(__('error.not_found'));
}

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\Documents\DocumentTemplate;
use App\Models\Orders\Order;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderAssignee;
@@ -1621,6 +1622,159 @@ public function getInspectionData(int $workOrderId, array $params = []): array
];
}
// ──────────────────────────────────────────────────────────────
// 검사 문서 템플릿 연동
// ──────────────────────────────────────────────────────────────
/**
* 작업지시의 검사용 문서 템플릿 조회
*
* work_order → process → steps(needs_inspection=true) → documentTemplate 로드
*/
public function getInspectionTemplate(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with([
'process.steps' => fn ($q) => $q->where('is_active', true)
->where('needs_inspection', true)
->whereNotNull('document_template_id')
->orderBy('sort_order'),
'process.steps.documentTemplate' => fn ($q) => $q->with([
'approvalLines',
'basicFields',
'sections.items',
'columns',
'sectionFields',
]),
'salesOrder:id,order_no,client_name,site_name',
'items:id,work_order_id,item_name,specification,quantity,unit,sort_order',
])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$inspectionSteps = $workOrder->process?->steps ?? collect();
if ($inspectionSteps->isEmpty()) {
return [
'work_order_id' => $workOrderId,
'has_template' => false,
'template' => null,
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
];
}
// 첫 번째 검사 단계의 템플릿 사용 (향후 다중 검사 단계 지원 가능)
$inspectionStep = $inspectionSteps->first();
$template = $inspectionStep->documentTemplate;
if (! $template) {
return [
'work_order_id' => $workOrderId,
'has_template' => false,
'template' => null,
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
];
}
// DocumentService의 formatTemplateForReact와 동일한 포맷
$documentService = app(DocumentService::class);
$formattedTemplate = $documentService->formatTemplateForReact($template);
return [
'work_order_id' => $workOrderId,
'has_template' => true,
'template' => $formattedTemplate,
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
];
}
/**
* 검사 완료 시 Document + DocumentData 생성
*/
public function createInspectionDocument(int $workOrderId, array $inspectionData): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with([
'process.steps' => fn ($q) => $q->where('is_active', true)
->where('needs_inspection', true)
->whereNotNull('document_template_id')
->orderBy('sort_order'),
])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$inspectionStep = $workOrder->process?->steps?->first();
if (! $inspectionStep || ! $inspectionStep->document_template_id) {
throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
}
$documentService = app(DocumentService::class);
// DocumentService::create() 재사용
$documentData = [
'template_id' => $inspectionStep->document_template_id,
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,
'data' => $inspectionData['data'] ?? [],
'approvers' => $inspectionData['approvers'] ?? [],
];
$document = $documentService->create($documentData);
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'inspection_document_created',
null,
['document_id' => $document->id, 'document_no' => $document->document_no]
);
return [
'document_id' => $document->id,
'document_no' => $document->document_no,
'status' => $document->status,
];
}
/**
* 작업지시 기본정보 빌드 (검사 문서 렌더링용)
*/
private function buildWorkOrderInfo(WorkOrder $workOrder): array
{
return [
'id' => $workOrder->id,
'work_order_no' => $workOrder->work_order_no,
'project_name' => $workOrder->project_name,
'status' => $workOrder->status,
'scheduled_date' => $workOrder->scheduled_date,
'sales_order' => $workOrder->salesOrder ? [
'order_no' => $workOrder->salesOrder->order_no,
'client_name' => $workOrder->salesOrder->client_name,
'site_name' => $workOrder->salesOrder->site_name,
] : null,
'items' => $workOrder->items?->map(fn ($item) => [
'id' => $item->id,
'item_name' => $item->item_name,
'specification' => $item->specification,
'quantity' => $item->quantity,
'unit' => $item->unit,
])->toArray() ?? [],
];
}
/**
* 작업지시 검사 성적서용 데이터 조회 (전체 품목 + 검사 데이터 + 주문 정보)
*/

View File

@@ -0,0 +1,27 @@
<?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('process_steps', function (Blueprint $table) {
$table->foreignId('document_template_id')
->nullable()
->after('needs_inspection')
->constrained('document_templates')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('process_steps', function (Blueprint $table) {
$table->dropForeign(['document_template_id']);
$table->dropColumn('document_template_id');
});
}
};

View File

@@ -423,6 +423,7 @@
'not_bending_process' => '벤딩 공정이 아닙니다.',
'invalid_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다. 허용된 상태: :allowed",
'assignee_required_for_linked' => '수주 연동 등록 시 담당자를 선택해주세요.',
'no_inspection_template' => '검사용 문서 양식이 설정되지 않았습니다.',
],
// 검사 관련

View File

@@ -439,6 +439,7 @@
'materials_fetched' => '자재 목록을 조회했습니다.',
'material_input_registered' => '자재 투입이 등록되었습니다.',
'inspection_saved' => '검사 데이터가 저장되었습니다.',
'inspection_document_created' => '검사 문서가 생성되었습니다.',
],
// 검사 관리

View File

@@ -76,6 +76,8 @@
Route::post('/{id}/items/{itemId}/inspection', [WorkOrderController::class, 'storeItemInspection'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.inspection'); // 품목 검사 저장
Route::get('/{id}/inspection-data', [WorkOrderController::class, 'inspectionData'])->whereNumber('id')->name('v1.work-orders.inspection-data'); // 검사 데이터 조회
Route::get('/{id}/inspection-report', [WorkOrderController::class, 'inspectionReport'])->whereNumber('id')->name('v1.work-orders.inspection-report'); // 검사 성적서 조회
Route::get('/{id}/inspection-template', [WorkOrderController::class, 'inspectionTemplate'])->whereNumber('id')->name('v1.work-orders.inspection-template'); // 검사 문서 템플릿 조회
Route::post('/{id}/inspection-document', [WorkOrderController::class, 'createInspectionDocument'])->whereNumber('id')->name('v1.work-orders.inspection-document'); // 검사 문서 생성
});
// Work Result API (작업실적 관리)