diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index a093065..61828a4 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -230,6 +230,16 @@ public function inspectionReport(int $id) }, __('message.work_order.fetched')); } + /** + * 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록) + */ + public function inspectionConfig(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getInspectionConfig($id); + }, __('message.work_order.fetched')); + } + /** * 작업지시의 검사용 문서 템플릿 조회 */ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 13e519b..d727b5c 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -5,6 +5,7 @@ use App\Models\Documents\Document; use App\Models\Documents\DocumentTemplate; use App\Models\Orders\Order; +use App\Models\Process; use App\Models\Production\WorkOrder; use App\Models\Production\WorkOrderAssignee; use App\Models\Production\WorkOrderBendingDetail; @@ -1949,6 +1950,186 @@ public function getInspectionData(int $workOrderId, array $params = []): array ]; } + // ────────────────────────────────────────────────────────────── + // 검사 설정 (inspection-config) + // ────────────────────────────────────────────────────────────── + + /** + * 절곡 검사 기준 간격 프로파일 (단면 치수 공학 사양) + * 향후 DB 테이블 또는 테넌트 설정으로 이관 가능 + */ + private const BENDING_GAP_PROFILES = [ + 'guide_rail_wall' => [ + 'name' => '가이드레일 벽면', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '30'], + ['point' => '(2)', 'design_value' => '78'], + ['point' => '(3)', 'design_value' => '25'], + ['point' => '(4)', 'design_value' => '45'], + ], + ], + 'guide_rail_side' => [ + 'name' => '가이드레일 측면', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '28'], + ['point' => '(2)', 'design_value' => '75'], + ['point' => '(3)', 'design_value' => '42'], + ['point' => '(4)', 'design_value' => '38'], + ['point' => '(5)', 'design_value' => '32'], + ], + ], + 'bottom_bar' => [ + 'name' => '하단마감재', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '60'], + ], + ], + 'case_box' => [ + 'name' => '케이스', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '550'], + ['point' => '(2)', 'design_value' => '50'], + ['point' => '(3)', 'design_value' => '385'], + ['point' => '(4)', 'design_value' => '50'], + ['point' => '(5)', 'design_value' => '410'], + ], + ], + 'smoke_w50' => [ + 'name' => '연기차단재 W50', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '50'], + ['point' => '(2)', 'design_value' => '12'], + ], + ], + 'smoke_w80' => [ + 'name' => '연기차단재 W80', + 'gap_points' => [ + ['point' => '(1)', 'design_value' => '80'], + ['point' => '(2)', 'design_value' => '12'], + ], + ], + ]; + + /** + * 작업지시의 검사 설정 조회 (공정 자동 판별 + 구성품 목록) + * + * 절곡 공정: bending_info 기반으로 검사 대상 구성품 + 간격 기준치 반환 + * 기타 공정: items 빈 배열 (스크린/슬랫은 별도 구성품 없음) + */ + public function getInspectionConfig(int $workOrderId): array + { + $workOrder = WorkOrder::where('tenant_id', $this->tenantId()) + ->with(['process', 'items' => fn ($q) => $q->orderBy('sort_order')]) + ->findOrFail($workOrderId); + + $process = $workOrder->process; + $processType = $this->resolveInspectionProcessType($process); + + $firstItem = $workOrder->items->first(); + $productCode = $firstItem?->options['product_code'] ?? null; + $templateId = $process?->document_template_id; + + $items = []; + if ($processType === 'bending') { + $items = $this->buildBendingInspectionItems($firstItem); + } + + return [ + 'work_order_id' => $workOrder->id, + 'process_type' => $processType, + 'product_code' => $productCode, + 'template_id' => $templateId, + 'items' => $items, + ]; + } + + /** + * 공정명 → 검사 공정 타입 변환 + */ + private function resolveInspectionProcessType(?Process $process): string + { + if (! $process) { + return 'unknown'; + } + + return match ($process->process_name) { + '스크린' => 'screen', + '슬랫' => 'slat', + '절곡' => 'bending', + default => strtolower($process->process_code ?? 'unknown'), + }; + } + + /** + * 절곡 bending_info에서 검사 대상 구성품 + 간격 기준치 빌드 + */ + private function buildBendingInspectionItems(?WorkOrderItem $firstItem): array + { + if (! $firstItem) { + return []; + } + + $bendingInfo = $firstItem->options['bending_info'] ?? null; + $items = []; + + // 가이드레일 벽면 (벽면형 또는 혼합형) + $guideRail = $bendingInfo['guideRail'] ?? null; + $hasWall = ! $bendingInfo || ($guideRail && ($guideRail['wall'] ?? false)); + $hasSide = $guideRail && ($guideRail['side'] ?? false); + + if ($hasWall) { + $profile = self::BENDING_GAP_PROFILES['guide_rail_wall']; + $items[] = [ + 'id' => 'guide_rail_wall', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + } + + if ($hasSide) { + $profile = self::BENDING_GAP_PROFILES['guide_rail_side']; + $items[] = [ + 'id' => 'guide_rail_side', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + } + + // 하단마감재 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['bottom_bar']; + $items[] = [ + 'id' => 'bottom_bar', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + // 케이스 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['case_box']; + $items[] = [ + 'id' => 'case_box', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + // 연기차단재 W50 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['smoke_w50']; + $items[] = [ + 'id' => 'smoke_w50', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + // 연기차단재 W80 (항상 포함) + $profile = self::BENDING_GAP_PROFILES['smoke_w80']; + $items[] = [ + 'id' => 'smoke_w80', + 'name' => $profile['name'], + 'gap_points' => $profile['gap_points'], + ]; + + return $items; + } + // ────────────────────────────────────────────────────────────── // 검사 문서 템플릿 연동 // ────────────────────────────────────────────────────────────── @@ -2096,80 +2277,85 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData throw new BadRequestHttpException(__('error.work_order.no_inspection_template')); } - $documentService = app(DocumentService::class); + return DB::transaction(function () use ($workOrder, $workOrderId, $tenantId, $templateId, $inspectionData) { + // 동시 생성 방지: 동일 작업지시에 대한 락 + $workOrder->lockForUpdate(); - // 기존 DRAFT/REJECTED 문서가 있으면 update - $existingDocument = Document::query() - ->where('tenant_id', $tenantId) - ->where('template_id', $templateId) - ->where('linkable_type', 'work_order') - ->where('linkable_id', $workOrderId) - ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) - ->latest() - ->first(); + $documentService = app(DocumentService::class); - // ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집 - $rawItems = []; - foreach ($workOrder->items as $item) { - $inspData = $item->getInspectionData(); - if ($inspData) { - $rawItems[] = $inspData; + // 기존 DRAFT/REJECTED 문서가 있으면 update + $existingDocument = Document::query() + ->where('tenant_id', $tenantId) + ->where('template_id', $templateId) + ->where('linkable_type', 'work_order') + ->where('linkable_id', $workOrderId) + ->whereIn('status', [Document::STATUS_DRAFT, Document::STATUS_REJECTED]) + ->latest() + ->first(); + + // ★ 원본 기반 동기화: work_order_items.options.inspection_data에서 전체 수집 + $rawItems = []; + foreach ($workOrder->items as $item) { + $inspData = $item->getInspectionData(); + if ($inspData) { + $rawItems[] = $inspData; + } } - } - $documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId); + $documentDataRecords = $this->transformInspectionDataToDocumentRecords($rawItems, $templateId); - // 기존 문서의 기본필드(bf_*) 보존 - if ($existingDocument) { - $existingBasicFields = $existingDocument->data() - ->whereNull('section_id') - ->where('field_key', 'LIKE', 'bf_%') - ->get() - ->map(fn ($d) => [ - 'section_id' => null, - 'column_id' => null, - 'row_index' => $d->row_index, - 'field_key' => $d->field_key, - 'field_value' => $d->field_value, - ]) - ->toArray(); + // 기존 문서의 기본필드(bf_*) 보존 + if ($existingDocument) { + $existingBasicFields = $existingDocument->data() + ->whereNull('section_id') + ->where('field_key', 'LIKE', 'bf_%') + ->get() + ->map(fn ($d) => [ + 'section_id' => null, + 'column_id' => null, + 'row_index' => $d->row_index, + 'field_key' => $d->field_key, + 'field_value' => $d->field_value, + ]) + ->toArray(); - $document = $documentService->update($existingDocument->id, [ - 'title' => $inspectionData['title'] ?? $existingDocument->title, - 'data' => array_merge($existingBasicFields, $documentDataRecords), - ]); + $document = $documentService->update($existingDocument->id, [ + 'title' => $inspectionData['title'] ?? $existingDocument->title, + 'data' => array_merge($existingBasicFields, $documentDataRecords), + ]); - $action = 'inspection_document_updated'; - } else { - $documentData = [ - 'template_id' => $templateId, - 'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}", - 'linkable_type' => 'work_order', - 'linkable_id' => $workOrderId, - 'data' => $documentDataRecords, - 'approvers' => $inspectionData['approvers'] ?? [], + $action = 'inspection_document_updated'; + } else { + $documentData = [ + 'template_id' => $templateId, + 'title' => $inspectionData['title'] ?? "중간검사성적서 - {$workOrder->work_order_no}", + 'linkable_type' => 'work_order', + 'linkable_id' => $workOrderId, + 'data' => $documentDataRecords, + 'approvers' => $inspectionData['approvers'] ?? [], + ]; + + $document = $documentService->create($documentData); + $action = 'inspection_document_created'; + } + + // 감사 로그 + $this->auditLogger->log( + $tenantId, + self::AUDIT_TARGET, + $workOrderId, + $action, + null, + ['document_id' => $document->id, 'document_no' => $document->document_no] + ); + + return [ + 'document_id' => $document->id, + 'document_no' => $document->document_no, + 'status' => $document->status, + 'is_new' => $action === 'inspection_document_created', ]; - - $document = $documentService->create($documentData); - $action = 'inspection_document_created'; - } - - // 감사 로그 - $this->auditLogger->log( - $tenantId, - self::AUDIT_TARGET, - $workOrderId, - $action, - null, - ['document_id' => $document->id, 'document_no' => $document->document_no] - ); - - return [ - 'document_id' => $document->id, - 'document_no' => $document->document_no, - 'status' => $document->status, - 'is_new' => $action === 'inspection_document_created', - ]; + }); } /** diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 0700bfa..5fac326 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -82,6 +82,7 @@ // 중간검사 관리 Route::post('/{id}/items/{itemId}/inspection', [WorkOrderController::class, 'storeItemInspection'])->whereNumber('id')->whereNumber('itemId')->name('v1.work-orders.items.inspection'); // 품목 검사 저장 + Route::get('/{id}/inspection-config', [WorkOrderController::class, 'inspectionConfig'])->whereNumber('id')->name('v1.work-orders.inspection-config'); // 검사 설정 (공정 판별 + 구성품) 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'); // 검사 문서 템플릿 조회