feat: 작업일지/중간검사 설정을 ProcessStep → Process 레벨로 이동

- Process 모델에 document_template_id, needs_work_log, work_log_template_id 추가
- ProcessStep에서 해당 필드 제거
- WorkOrderService의 검사 관련 3개 메서드(getInspectionTemplate, resolveInspectionDocument, createInspectionDocument) 공정 레벨 참조로 변경
- ProcessService eager loading에 documentTemplate, workLogTemplateRelation 추가
- FormRequest 검증 규칙 이동 (ProcessStep → Process)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 09:51:35 +09:00
parent bb457d4ca8
commit 1d7ef66d19
10 changed files with 133 additions and 84 deletions

View File

@@ -19,6 +19,9 @@ public function rules(): array
'process_type' => ['required', 'string', 'in:생산,검사,포장,조립'],
'department' => ['nullable', 'string', 'max:100'],
'work_log_template' => ['nullable', 'string', 'max:100'],
'document_template_id' => ['nullable', 'integer', 'exists:document_templates,id'],
'needs_work_log' => ['nullable', 'boolean'],
'work_log_template_id' => ['nullable', 'integer', 'exists:document_templates,id'],
'required_workers' => ['nullable', 'integer', 'min:1'],
'equipment_info' => ['nullable', 'string', 'max:255'],
'work_steps' => ['nullable'],
@@ -48,6 +51,9 @@ public function attributes(): array
'process_type' => '공정구분',
'department' => '담당부서',
'work_log_template' => '작업일지 양식',
'document_template_id' => '중간검사 양식',
'needs_work_log' => '작업일지 여부',
'work_log_template_id' => '작업일지 양식 ID',
'required_workers' => '필요인원',
'equipment_info' => '설비정보',
'work_steps' => '작업단계',

View File

@@ -19,6 +19,9 @@ public function rules(): array
'process_type' => ['sometimes', 'required', 'string', 'in:생산,검사,포장,조립'],
'department' => ['nullable', 'string', 'max:100'],
'work_log_template' => ['nullable', 'string', 'max:100'],
'document_template_id' => ['nullable', 'integer', 'exists:document_templates,id'],
'needs_work_log' => ['nullable', 'boolean'],
'work_log_template_id' => ['nullable', 'integer', 'exists:document_templates,id'],
'required_workers' => ['nullable', 'integer', 'min:1'],
'equipment_info' => ['nullable', 'string', 'max:255'],
'work_steps' => ['nullable'],
@@ -48,6 +51,9 @@ public function attributes(): array
'process_type' => '공정구분',
'department' => '담당부서',
'work_log_template' => '작업일지 양식',
'document_template_id' => '중간검사 양식',
'needs_work_log' => '작업일지 여부',
'work_log_template_id' => '작업일지 양식 ID',
'required_workers' => '필요인원',
'equipment_info' => '설비정보',
'work_steps' => '작업단계',

View File

@@ -18,7 +18,6 @@ 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'],
@@ -33,7 +32,6 @@ public function attributes(): array
'is_required' => '필수여부',
'needs_approval' => '승인필요여부',
'needs_inspection' => '검사필요여부',
'document_template_id' => '문서양식',
'is_active' => '사용여부',
'connection_type' => '연결유형',
'connection_target' => '연결대상',

View File

@@ -18,7 +18,6 @@ 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'],
@@ -33,7 +32,6 @@ public function attributes(): array
'is_required' => '필수여부',
'needs_approval' => '승인필요여부',
'needs_inspection' => '검사필요여부',
'document_template_id' => '문서양식',
'is_active' => '사용여부',
'connection_type' => '연결유형',
'connection_target' => '연결대상',

View File

@@ -7,6 +7,7 @@
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -26,6 +27,9 @@ class Process extends Model
'process_type',
'department',
'work_log_template',
'document_template_id',
'needs_work_log',
'work_log_template_id',
'required_workers',
'equipment_info',
'work_steps',
@@ -39,9 +43,26 @@ class Process extends Model
protected $casts = [
'work_steps' => 'array',
'is_active' => 'boolean',
'needs_work_log' => 'boolean',
'required_workers' => 'integer',
];
/**
* 중간검사 양식
*/
public function documentTemplate(): BelongsTo
{
return $this->belongsTo(Documents\DocumentTemplate::class);
}
/**
* 작업일지 양식 (관계명: work_log_template 컬럼과 충돌 방지)
*/
public function workLogTemplateRelation(): BelongsTo
{
return $this->belongsTo(Documents\DocumentTemplate::class, 'work_log_template_id');
}
/**
* 공정 자동 분류 규칙 (패턴 규칙)
*/

View File

@@ -2,7 +2,6 @@
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;
@@ -18,7 +17,6 @@ class ProcessStep extends Model
'is_required',
'needs_approval',
'needs_inspection',
'document_template_id',
'is_active',
'sort_order',
'connection_type',
@@ -41,12 +39,4 @@ public function process(): BelongsTo
{
return $this->belongsTo(Process::class);
}
/**
* 문서 양식 (검사 시 사용할 템플릿)
*/
public function documentTemplate(): BelongsTo
{
return $this->belongsTo(DocumentTemplate::class);
}
}

View File

@@ -25,7 +25,7 @@ public function index(array $params)
$query = Process::query()
->where('tenant_id', $tenantId)
->with(['classificationRules', 'processItems.item:id,code,name', 'steps']);
->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']);
// 검색어
if ($q !== '') {
@@ -62,7 +62,7 @@ public function show(int $id)
$tenantId = $this->tenantId();
$process = Process::where('tenant_id', $tenantId)
->with(['classificationRules', 'processItems.item:id,code,name', 'steps'])
->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category'])
->find($id);
if (! $process) {
@@ -104,7 +104,7 @@ public function store(array $data)
// 개별 품목 연결
$this->syncProcessItems($process, $itemIds);
return $process->load(['classificationRules', 'processItems.item:id,code,name', 'steps']);
return $process->load(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']);
});
}
@@ -145,7 +145,7 @@ public function update(int $id, array $data)
$this->syncProcessItems($process, $itemIds);
}
return $process->fresh(['classificationRules', 'processItems.item:id,code,name', 'steps']);
return $process->fresh(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']);
});
}
@@ -201,7 +201,7 @@ public function toggleActive(int $id)
'updated_by' => $userId,
]);
return $process->fresh(['classificationRules', 'processItems.item:id,code,name', 'steps']);
return $process->fresh(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']);
}
/**

View File

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

View File

@@ -55,7 +55,7 @@ public function index(array $params)
'salesOrder.client:id,name',
'process:id,process_name,process_code,department',
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
'items.sourceOrderItem.node:id,name,code',
]);
@@ -210,7 +210,7 @@ public function show(int $id)
'salesOrder.writer:id,name',
'process:id,process_name,process_code,work_steps,department',
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
'items.sourceOrderItem.node:id,name,code',
'bendingDetail',
'issues' => fn ($q) => $q->orderByDesc('created_at'),
@@ -257,9 +257,22 @@ public function store(array $data)
// 품목 저장: 직접 전달된 품목이 없고 수주 ID가 있으면 수주에서 복사
if (empty($items) && $salesOrderId) {
$salesOrder = \App\Models\Orders\Order::with('items')->find($salesOrderId);
$salesOrder = \App\Models\Orders\Order::with('items.node')->find($salesOrderId);
if ($salesOrder && $salesOrder->items->isNotEmpty()) {
foreach ($salesOrder->items as $index => $orderItem) {
// 수주 품목 + 노드에서 options 조합
$nodeOptions = $orderItem->node?->options ?? [];
$options = array_filter([
'floor' => $orderItem->floor_code,
'code' => $orderItem->symbol_code,
'width' => $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null,
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
'slat_info' => $nodeOptions['slat_info'] ?? null,
'bending_info' => $nodeOptions['bending_info'] ?? null,
'wip_info' => $nodeOptions['wip_info'] ?? null,
], fn ($v) => $v !== null);
$workOrder->items()->create([
'tenant_id' => $tenantId,
'source_order_item_id' => $orderItem->id, // 원본 수주 품목 추적용
@@ -269,6 +282,7 @@ public function store(array $data)
'quantity' => $orderItem->quantity,
'unit' => $orderItem->unit,
'sort_order' => $index,
'options' => ! empty($options) ? $options : null,
]);
}
}
@@ -1639,11 +1653,7 @@ public function getInspectionTemplate(int $workOrderId): array
$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([
'process.documentTemplate' => fn ($q) => $q->with([
'approvalLines',
'basicFields',
'sections.items',
@@ -1659,8 +1669,10 @@ public function getInspectionTemplate(int $workOrderId): array
throw new NotFoundHttpException(__('error.not_found'));
}
$inspectionSteps = $workOrder->process?->steps ?? collect();
if ($inspectionSteps->isEmpty()) {
$process = $workOrder->process;
$docTemplate = $process?->documentTemplate;
if (! $docTemplate) {
return [
'work_order_id' => $workOrderId,
'has_template' => false,
@@ -1671,28 +1683,17 @@ public function getInspectionTemplate(int $workOrderId): array
}
$documentService = app(DocumentService::class);
$templates = [];
foreach ($inspectionSteps as $step) {
if (! $step->documentTemplate) {
continue;
}
$templates[] = [
'step_id' => $step->id,
'step_name' => $step->step_name,
'step_code' => $step->step_code,
'sort_order' => $step->sort_order,
'template' => $documentService->formatTemplateForReact($step->documentTemplate),
];
}
$formattedTemplate = $documentService->formatTemplateForReact($docTemplate);
return [
'work_order_id' => $workOrderId,
'has_template' => ! empty($templates),
'templates' => $templates,
// 하위호환: 첫 번째 템플릿
'template' => ! empty($templates) ? $templates[0]['template'] : null,
'has_template' => true,
'templates' => [[
'template_id' => $docTemplate->id,
'template_name' => $docTemplate->name,
'template' => $formattedTemplate,
]],
'template' => $formattedTemplate,
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
];
}
@@ -1709,11 +1710,7 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []):
$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([
'process.documentTemplate' => fn ($q) => $q->with([
'approvalLines',
'basicFields',
'sections.items',
@@ -1729,25 +1726,21 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []):
throw new NotFoundHttpException(__('error.not_found'));
}
// step_id가 지정되면 해당 단계 사용, 아니면 첫 번째 검사 단계
$inspectionSteps = $workOrder->process?->steps ?? collect();
$stepId = $params['step_id'] ?? null;
$process = $workOrder->process;
$templateId = $process?->document_template_id;
$docTemplate = $process?->documentTemplate;
$inspectionStep = $stepId
? $inspectionSteps->firstWhere('id', (int) $stepId)
: $inspectionSteps->first();
if (! $inspectionStep || ! $inspectionStep->document_template_id) {
if (! $templateId || ! $docTemplate) {
throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
}
$documentService = app(DocumentService::class);
$formattedTemplate = $documentService->formatTemplateForReact($inspectionStep->documentTemplate);
$formattedTemplate = $documentService->formatTemplateForReact($docTemplate);
// 기존 문서 조회 (work_order + template, 수정 가능한 상태)
$existingDocument = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $inspectionStep->document_template_id)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
@@ -1757,8 +1750,7 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []):
return [
'work_order_id' => $workOrderId,
'step_id' => $inspectionStep->id,
'step_name' => $inspectionStep->step_name,
'template_id' => $templateId,
'template' => $formattedTemplate,
'existing_document' => $existingDocument,
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
@@ -1768,7 +1760,7 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []):
/**
* 검사 완료 시 Document + DocumentData 생성
*
* step_id 지정 시 해당 단계의 템플릿 사용, 미지정 시 첫 번째 검사 단계 사용.
* 공정(Process) 레벨의 document_template_id를 사용.
* 기존 DRAFT/REJECTED 문서가 있으면 update, 없으면 create.
*/
public function createInspectionDocument(int $workOrderId, array $inspectionData): array
@@ -1777,27 +1769,18 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
$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'),
])
->with(['process'])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
// step_id가 지정되면 해당 단계 사용, 아니면 첫 번째
$inspectionSteps = $workOrder->process?->steps ?? collect();
$stepId = $inspectionData['step_id'] ?? null;
// 공정 레벨의 중간검사 양식 사용
$process = $workOrder->process;
$templateId = $process?->document_template_id;
$inspectionStep = $stepId
? $inspectionSteps->firstWhere('id', (int) $stepId)
: $inspectionSteps->first();
if (! $inspectionStep || ! $inspectionStep->document_template_id) {
if (! $templateId) {
throw new BadRequestHttpException(__('error.work_order.no_inspection_template'));
}
@@ -1806,7 +1789,7 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
// 기존 DRAFT/REJECTED 문서가 있으면 update
$existingDocument = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $inspectionStep->document_template_id)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED])
@@ -1822,7 +1805,7 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
$action = 'inspection_document_updated';
} else {
$documentData = [
'template_id' => $inspectionStep->document_template_id,
'template_id' => $templateId,
'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 검사양식/작업일지 설정을 ProcessStep → Process 레벨로 이동
* - processes: document_template_id, needs_work_log, work_log_template_id 추가
* - process_steps: document_template_id 제거
*/
return new class extends Migration
{
public function up(): void
{
// 1. Process에 검사양식/작업일지 필드 추가
Schema::table('processes', function (Blueprint $table) {
$table->foreignId('document_template_id')->nullable()->after('work_log_template')
->constrained('document_templates')->nullOnDelete()->comment('중간검사 양식 ID');
$table->boolean('needs_work_log')->default(false)->after('document_template_id')->comment('작업일지 여부');
$table->foreignId('work_log_template_id')->nullable()->after('needs_work_log')
->constrained('document_templates')->nullOnDelete()->comment('작업일지 양식 ID');
});
// 2. ProcessStep에서 document_template_id 제거
Schema::table('process_steps', function (Blueprint $table) {
$table->dropForeign(['document_template_id']);
$table->dropColumn('document_template_id');
});
}
public function down(): void
{
// ProcessStep에 document_template_id 복원
Schema::table('process_steps', function (Blueprint $table) {
$table->foreignId('document_template_id')->nullable()->after('needs_inspection')
->constrained('document_templates')->nullOnDelete();
});
// Process에서 필드 제거
Schema::table('processes', function (Blueprint $table) {
$table->dropForeign(['work_log_template_id']);
$table->dropForeign(['document_template_id']);
$table->dropColumn(['work_log_template_id', 'needs_work_log', 'document_template_id']);
});
}
};