diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index 640f29c..2e6d5a9 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -189,6 +189,16 @@ public function materialInputHistory(int $id) }, __('message.work_order.fetched')); } + /** + * 자재 투입 LOT 번호 조회 (stock_transactions 기반) + */ + public function materialInputLots(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->getMaterialInputLots($id); + }, __('message.work_order.fetched')); + } + /** * 품목별 중간검사 데이터 저장 */ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 5e6bcc0..813964a 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -12,6 +12,7 @@ use App\Models\Production\WorkOrderStepProgress; use App\Models\Tenants\Shipment; use App\Models\Tenants\ShipmentItem; +use App\Models\Tenants\StockTransaction; use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -1538,13 +1539,56 @@ public function getMaterialInputHistory(int $workOrderId): array return [ 'id' => $log->id, - 'materials' => $after['materials'] ?? [], + 'materials' => $after['input_results'] ?? [], 'created_at' => $log->created_at, 'actor_id' => $log->actor_id, ]; })->toArray(); } + /** + * 작업지시에 투입된 자재 LOT 번호 조회 (stock_transactions 기반) + * + * stock_transactions에서 reference_type='work_order_input'인 거래를 조회하여 + * 중복 없는 LOT 번호 목록을 반환합니다. + */ + public function getMaterialInputLots(int $workOrderId): array + { + $tenantId = $this->tenantId(); + + $workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId); + if (! $workOrder) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $transactions = DB::table('stock_transactions') + ->where('tenant_id', $tenantId) + ->where('reference_type', StockTransaction::REASON_WORK_ORDER_INPUT) + ->where('reference_id', $workOrderId) + ->orderBy('created_at') + ->get(['id', 'lot_no', 'item_code', 'item_name', 'qty', 'stock_lot_id', 'created_at']); + + // LOT 번호별 그룹핑 (동일 LOT에서 여러번 투입 가능) + $lotMap = []; + foreach ($transactions as $tx) { + $lotNo = $tx->lot_no; + if (! isset($lotMap[$lotNo])) { + $lotMap[$lotNo] = [ + 'lot_no' => $lotNo, + 'item_code' => $tx->item_code, + 'item_name' => $tx->item_name, + 'total_qty' => 0, + 'input_count' => 0, + 'first_input_at' => $tx->created_at, + ]; + } + $lotMap[$lotNo]['total_qty'] += abs((float) $tx->qty); + $lotMap[$lotNo]['input_count']++; + } + + return array_values($lotMap); + } + // ────────────────────────────────────────────────────────────── // 중간검사 관련 // ────────────────────────────────────────────────────────────── @@ -1796,9 +1840,10 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData ->latest() ->first(); - // 프론트 InspectionData를 document_data 레코드 형식으로 변환 + // 프론트 InspectionData를 document_data 정규화 레코드 형식으로 변환 $documentDataRecords = $this->transformInspectionDataToDocumentRecords( - $inspectionData['data'] ?? [] + $inspectionData['data'] ?? [], + $templateId ); if ($existingDocument) { @@ -1841,42 +1886,97 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData } /** - * 프론트 InspectionData 배열을 document_data 레코드 형식으로 변환 + * 프론트 InspectionData를 정규화된 document_data 레코드로 변환 * - * 프론트에서 보내는 형식: - * [{ productName, specification, judgment, nonConformingContent, templateValues: { section_X_item_Y: "ok"|number } }] + * 정규화 형식 (NEW): + * [{ section_id, column_id, row_index, field_key, field_value }] + * field_key: 'value', 'n1', 'n2', 'n1_ok', 'n1_ng', 'overall_result', 'remark', 'row_judgment' * - * document_data 테이블 형식: - * [{ field_key, field_value, row_index, section_id?, column_id? }] + * 레거시 형식 (WorkerScreen): + * [{ templateValues: { section_X_item_Y: "ok"|number }, judgment, nonConformingContent }] */ - private function transformInspectionDataToDocumentRecords(array $rawItems): array + private function transformInspectionDataToDocumentRecords(array $rawItems, int $templateId): array { + if (empty($rawItems)) { + return []; + } + + // 정규화 형식 감지: 첫 번째 요소에 field_key가 있으면 새 형식 + if (isset($rawItems[0]['field_key'])) { + return array_map(fn (array $item) => [ + 'section_id' => $item['section_id'] ?? null, + 'column_id' => $item['column_id'] ?? null, + 'row_index' => $item['row_index'] ?? 0, + 'field_key' => $item['field_key'], + 'field_value' => $item['field_value'] ?? null, + ], $rawItems); + } + + // 레거시 형식: templateValues/values 기반 → 정규화 변환 + return $this->normalizeOldFormatRecords($rawItems, $templateId); + } + + /** + * 레거시 형식(section_X_item_Y 키)을 정규화 레코드로 변환 + */ + private function normalizeOldFormatRecords(array $rawItems, int $templateId): array + { + $template = DocumentTemplate::with(['sections.items', 'columns'])->find($templateId); + if (! $template) { + return []; + } + + // sectionItem.id → { section_id, column_id, measurement_type } 매핑 + $itemMap = $this->buildItemColumnMap($template); + $records = []; - foreach ($rawItems as $rowIdx => $item) { - // templateValues의 각 키-값을 document_data 레코드로 변환 - $templateValues = $item['templateValues'] ?? []; - foreach ($templateValues as $key => $value) { + $values = $item['values'] ?? $item['templateValues'] ?? []; + + foreach ($values as $key => $cellValue) { + // section_{sectionId}_item_{itemId} 또는 item_{itemId} 형식 파싱 + if (! preg_match('/^(?:section_(\d+)_)?item_(\d+)$/', $key, $m)) { + continue; + } + + $sectionId = $m[1] ? (int) $m[1] : null; + $itemId = (int) $m[2]; + $info = $itemMap[$itemId] ?? null; + $columnId = $info['column_id'] ?? null; + $sectionId = $sectionId ?? ($info['section_id'] ?? null); + + $expanded = $this->expandCellValue($cellValue, $info['measurement_type'] ?? ''); + foreach ($expanded as $rec) { + $records[] = [ + 'section_id' => $sectionId, + 'column_id' => $columnId, + 'row_index' => $rowIdx, + 'field_key' => $rec['field_key'], + 'field_value' => $rec['field_value'], + ]; + } + } + + // 행 판정 + $judgment = $item['judgment'] ?? null; + if ($judgment !== null) { $records[] = [ - 'field_key' => $key, - 'field_value' => is_array($value) ? json_encode($value) : (string) $value, + 'section_id' => null, + 'column_id' => null, 'row_index' => $rowIdx, + 'field_key' => 'row_judgment', + 'field_value' => (string) $judgment, ]; } - // 판정, 부적합 내용 등 메타 필드도 저장 - if (isset($item['judgment'])) { - $records[] = [ - 'field_key' => '_judgment', - 'field_value' => (string) $item['judgment'], - 'row_index' => $rowIdx, - ]; - } + // 부적합 내용 if (! empty($item['nonConformingContent'])) { $records[] = [ - 'field_key' => '_nonConformingContent', - 'field_value' => (string) $item['nonConformingContent'], + 'section_id' => null, + 'column_id' => null, 'row_index' => $rowIdx, + 'field_key' => 'remark', + 'field_value' => (string) $item['nonConformingContent'], ]; } } @@ -1884,6 +1984,97 @@ private function transformInspectionDataToDocumentRecords(array $rawItems): arra return $records; } + /** + * 템플릿 구조에서 sectionItem → (section_id, column_id) 매핑 구축 + */ + private function buildItemColumnMap(DocumentTemplate $template): array + { + $map = []; + foreach ($template->sections as $section) { + foreach ($section->items as $item) { + $itemLabel = $this->normalizeInspectionLabel($item->getFieldValue('item') ?? ''); + $columnId = null; + + foreach ($template->columns as $col) { + $colLabel = $this->normalizeInspectionLabel($col->label); + if ($itemLabel && $colLabel === $itemLabel) { + $columnId = $col->id; + + break; + } + } + + $map[$item->id] = [ + 'section_id' => $section->id, + 'column_id' => $columnId, + 'measurement_type' => $item->getFieldValue('measurement_type') ?? '', + ]; + } + } + + return $map; + } + + /** + * CellValue를 개별 field_key/field_value 레코드로 확장 + */ + private function expandCellValue(mixed $cellValue, string $measurementType): array + { + if ($cellValue === null) { + return []; + } + + // 단순 문자열/숫자 → value 레코드 + if (is_string($cellValue) || is_numeric($cellValue)) { + return [['field_key' => 'value', 'field_value' => (string) $cellValue]]; + } + + if (! is_array($cellValue)) { + return [['field_key' => 'value', 'field_value' => (string) $cellValue]]; + } + + $records = []; + + // measurements 배열: 복합 컬럼 데이터 + if (isset($cellValue['measurements']) && is_array($cellValue['measurements'])) { + foreach ($cellValue['measurements'] as $n => $val) { + $nNum = $n + 1; + if ($measurementType === 'checkbox') { + $lower = strtolower($val ?? ''); + $records[] = ['field_key' => "n{$nNum}_ok", 'field_value' => $lower === 'ok' ? 'OK' : '']; + $records[] = ['field_key' => "n{$nNum}_ng", 'field_value' => $lower === 'ng' ? 'NG' : '']; + } else { + $records[] = ['field_key' => "n{$nNum}", 'field_value' => (string) ($val ?? '')]; + } + } + } + + // value 필드: 단일 값 + if (isset($cellValue['value'])) { + $records[] = ['field_key' => 'value', 'field_value' => (string) $cellValue['value']]; + } + + // text 필드: 텍스트 값 + if (isset($cellValue['text'])) { + $records[] = ['field_key' => 'value', 'field_value' => (string) $cellValue['text']]; + } + + // 아무 필드도 매칭 안 되면 JSON으로 저장 + return $records ?: [['field_key' => 'value', 'field_value' => json_encode($cellValue)]]; + } + + /** + * 라벨 정규화 (매칭용) + */ + private function normalizeInspectionLabel(string $label): string + { + $label = trim($label); + // ①②③ 등 번호 접두사 제거 + $label = preg_replace('/^[①②③④⑤⑥⑦⑧⑨⑩]+/', '', $label); + + return mb_strtolower(trim($label)); + } + /** * 작업지시 기본정보 빌드 (검사 문서 렌더링용) */ diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 526cef3..218f0c8 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -67,6 +67,7 @@ Route::get('/{id}/materials', [WorkOrderController::class, 'materials'])->whereNumber('id')->name('v1.work-orders.materials'); // 자재 목록 조회 Route::post('/{id}/material-inputs', [WorkOrderController::class, 'registerMaterialInput'])->whereNumber('id')->name('v1.work-orders.material-inputs'); // 자재 투입 등록 Route::get('/{id}/material-input-history', [WorkOrderController::class, 'materialInputHistory'])->whereNumber('id')->name('v1.work-orders.material-input-history'); // 자재 투입 이력 + Route::get('/{id}/material-input-lots', [WorkOrderController::class, 'materialInputLots'])->whereNumber('id')->name('v1.work-orders.material-input-lots'); // 투입 LOT 번호 조회 // 공정 단계 진행 관리 Route::get('/{id}/step-progress', [WorkOrderController::class, 'stepProgress'])->whereNumber('id')->name('v1.work-orders.step-progress'); // 단계 진행 조회