feat(API):자재 투입 LOT 조회 API 및 중간검사 데이터 정규화

- materialInputLots: stock_transactions 기반 투입 LOT 조회 엔드포인트 추가
- createInspectionDocument: 정규화 형식(section_id/column_id/field_key) 지원
- 레거시 형식(section_X_item_Y) 자동 변환 로직 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:01:25 +09:00
parent 818f764aa5
commit 376348a491
3 changed files with 227 additions and 25 deletions

View File

@@ -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'));
}
/**
* 품목별 중간검사 데이터 저장
*/

View File

@@ -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));
}
/**
* 작업지시 기본정보 빌드 (검사 문서 렌더링용)
*/

View File

@@ -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'); // 단계 진행 조회