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:
@@ -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`
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => '연결대상',
|
||||
|
||||
@@ -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' => '연결대상',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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() ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 검사 성적서용 데이터 조회 (전체 품목 + 검사 데이터 + 주문 정보)
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -423,6 +423,7 @@
|
||||
'not_bending_process' => '벤딩 공정이 아닙니다.',
|
||||
'invalid_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다. 허용된 상태: :allowed",
|
||||
'assignee_required_for_linked' => '수주 연동 등록 시 담당자를 선택해주세요.',
|
||||
'no_inspection_template' => '검사용 문서 양식이 설정되지 않았습니다.',
|
||||
],
|
||||
|
||||
// 검사 관련
|
||||
|
||||
@@ -439,6 +439,7 @@
|
||||
'materials_fetched' => '자재 목록을 조회했습니다.',
|
||||
'material_input_registered' => '자재 투입이 등록되었습니다.',
|
||||
'inspection_saved' => '검사 데이터가 저장되었습니다.',
|
||||
'inspection_document_created' => '검사 문서가 생성되었습니다.',
|
||||
],
|
||||
|
||||
// 검사 관리
|
||||
|
||||
@@ -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 (작업실적 관리)
|
||||
|
||||
Reference in New Issue
Block a user