feat(WEB): 절곡 자재투입 LOT 매핑 파이프라인 구현

- PrefixResolver: 제품코드×마감재질→LOT prefix 결정 + BD-XX-NN 코드 생성
- DynamicBomEntry DTO: dynamic_bom JSON 항목 타입 안전 관리
- BendingInfoBuilder 확장: build() 리턴 변경 + buildDynamicBomForItem() 추가
- OrderService: 작업지시 생성 시 per-item dynamic_bom 자동 저장
- WorkOrderService.getMaterials(): dynamic_bom 우선 체크 + N+1 배치 최적화
- WorkOrderService.registerMaterialInput(): work_order_item_id 분기 라우팅 통일
- 단위 테스트 58개 + 통합 테스트 6개 (64 tests / 293 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 02:13:08 +09:00
parent 9c88138de8
commit 5a3d6c2243
8 changed files with 1625 additions and 33 deletions

View File

@@ -1192,13 +1192,67 @@ public function getMaterials(int $workOrderId): array
throw new NotFoundHttpException(__('error.not_found'));
}
// Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산)
// ── Step 1: dynamic_bom 대상 item_id 일괄 수집 (N+1 방지) ──
$allDynamicItemIds = [];
foreach ($workOrder->items as $woItem) {
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
$dynamicBom = $options['dynamic_bom'] ?? null;
if ($dynamicBom && is_array($dynamicBom)) {
$allDynamicItemIds = array_merge($allDynamicItemIds, array_column($dynamicBom, 'child_item_id'));
}
}
// 배치 조회 (dynamic_bom 품목)
$dynamicItems = [];
if (! empty($allDynamicItemIds)) {
$dynamicItems = \App\Models\Items\Item::where('tenant_id', $tenantId)
->whereIn('id', array_unique($allDynamicItemIds))
->get()
->keyBy('id');
}
// ── Step 2: 유니크 자재 목록 수집 ──
// 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}"
$uniqueMaterials = [];
foreach ($workOrder->items as $woItem) {
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
$dynamicBom = $options['dynamic_bom'] ?? null;
// dynamic_bom 우선 — 있으면 BOM 무시
if ($dynamicBom && is_array($dynamicBom)) {
foreach ($dynamicBom as $bomEntry) {
$childItemId = $bomEntry['child_item_id'] ?? null;
if (! $childItemId || ! isset($dynamicItems[$childItemId])) {
continue;
}
// 합산 키: (item_id, work_order_item_id) 쌍
$key = $childItemId.'_'.$woItem->id;
$bomQty = (float) ($bomEntry['qty'] ?? 1);
$requiredQty = $bomQty * ($woItem->quantity ?? 1);
if (isset($uniqueMaterials[$key])) {
$uniqueMaterials[$key]['required_qty'] += $requiredQty;
} else {
$uniqueMaterials[$key] = [
'item' => $dynamicItems[$childItemId],
'bom_qty' => $bomQty,
'required_qty' => $requiredQty,
'work_order_item_id' => $woItem->id,
'lot_prefix' => $bomEntry['lot_prefix'] ?? null,
'part_type' => $bomEntry['part_type'] ?? null,
'category' => $bomEntry['category'] ?? null,
];
}
}
continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀
}
// 기존 BOM 로직 (하위 호환)
$materialItems = [];
// BOM이 있으면 자식 품목들을 자재로 사용
if ($woItem->item_id) {
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($woItem->item_id);
@@ -1237,7 +1291,7 @@ public function getMaterials(int $workOrderId): array
];
}
// 유니크 자재 수집 (같은 item_id면 required_qty 합산)
// 기존 방식: item_id 기준 합산
foreach ($materialItems as $matInfo) {
$itemId = $matInfo['item']->id;
if (isset($uniqueMaterials[$itemId])) {
@@ -1248,30 +1302,67 @@ public function getMaterials(int $workOrderId): array
}
}
// Phase 2: 유니크 자재별로 StockLot 조회
// ── Step 3: 유니크 자재별로 StockLot 조회 ──
// 배치 조회를 위해 전체 item_id 수집
$allItemIds = [];
foreach ($uniqueMaterials as $matInfo) {
$allItemIds[] = $matInfo['item']->id;
}
$allItemIds = array_unique($allItemIds);
// Stock 배치 조회 (N+1 방지)
$stockMap = [];
if (! empty($allItemIds)) {
$stocks = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->whereIn('item_id', $allItemIds)
->get();
foreach ($stocks as $stock) {
$stockMap[$stock->item_id] = $stock;
}
}
// StockLot 배치 조회 (N+1 방지)
$lotsByStockId = [];
$stockIds = array_map(fn ($s) => $s->id, $stockMap);
if (! empty($stockIds)) {
$allLots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
->whereIn('stock_id', $stockIds)
->where('status', 'available')
->where('available_qty', '>', 0)
->orderBy('fifo_order', 'asc')
->get();
foreach ($allLots as $lot) {
$lotsByStockId[$lot->stock_id][] = $lot;
}
}
$materials = [];
$rank = 1;
foreach ($uniqueMaterials as $matInfo) {
$materialItem = $matInfo['item'];
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->where('item_id', $materialItem->id)
->first();
$stock = $stockMap[$materialItem->id] ?? null;
$lotsFound = false;
// 공통 필드 (dynamic_bom 추가 필드 포함)
$extraFields = [];
if (isset($matInfo['work_order_item_id'])) {
$extraFields = [
'work_order_item_id' => $matInfo['work_order_item_id'],
'lot_prefix' => $matInfo['lot_prefix'],
'part_type' => $matInfo['part_type'],
'category' => $matInfo['category'],
];
}
if ($stock) {
$lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
->where('stock_id', $stock->id)
->where('status', 'available')
->where('available_qty', '>', 0)
->orderBy('fifo_order', 'asc')
->get();
$lots = $lotsByStockId[$stock->id] ?? [];
foreach ($lots as $lot) {
$lotsFound = true;
$materials[] = [
$materials[] = array_merge([
'stock_lot_id' => $lot->id,
'item_id' => $materialItem->id,
'lot_no' => $lot->lot_no,
@@ -1287,13 +1378,13 @@ public function getMaterials(int $workOrderId): array
'receipt_date' => $lot->receipt_date,
'supplier' => $lot->supplier,
'fifo_rank' => $rank++,
];
], $extraFields);
}
}
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
if (! $lotsFound) {
$materials[] = [
$materials[] = array_merge([
'stock_lot_id' => null,
'item_id' => $materialItem->id,
'lot_no' => null,
@@ -1309,7 +1400,7 @@ public function getMaterials(int $workOrderId): array
'receipt_date' => null,
'supplier' => null,
'fifo_rank' => $rank++,
];
], $extraFields);
}
}
@@ -1337,11 +1428,50 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId) {
// work_order_item_id가 있는 항목은 registerMaterialInputForItem()으로 위임
$groupedByItem = [];
$noItemInputs = [];
foreach ($inputs as $input) {
$woItemId = $input['work_order_item_id'] ?? null;
if ($woItemId) {
$groupedByItem[$woItemId][] = $input;
} else {
$noItemInputs[] = $input;
}
}
// work_order_item_id가 있는 항목 → 개소별 투입으로 위임
$delegatedResults = [];
foreach ($groupedByItem as $woItemId => $itemInputs) {
$delegatedResults[] = $this->registerMaterialInputForItem($workOrderId, $woItemId, $itemInputs);
}
// work_order_item_id가 없는 항목 → 기존 방식 + WorkOrderMaterialInput 레코드 생성
if (empty($noItemInputs)) {
// 전부 위임된 경우
$totalCount = array_sum(array_column($delegatedResults, 'material_count'));
$allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults));
return [
'work_order_id' => $workOrderId,
'material_count' => $totalCount,
'input_results' => $allResults,
'input_at' => now()->toDateTimeString(),
];
}
// fallback: 첫 번째 work_order_item_id로 매핑
$fallbackWoItemId = WorkOrderItem::where('tenant_id', $tenantId)
->where('work_order_id', $workOrderId)
->orderBy('id')
->value('id');
return DB::transaction(function () use ($noItemInputs, $tenantId, $userId, $workOrderId, $fallbackWoItemId, $delegatedResults) {
$stockService = app(StockService::class);
$inputResults = [];
foreach ($inputs as $input) {
foreach ($noItemInputs as $input) {
$stockLotId = $input['stock_lot_id'] ?? null;
$qty = (float) ($input['qty'] ?? 0);
@@ -1357,6 +1487,21 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
referenceId: $workOrderId
);
// WorkOrderMaterialInput 레코드 생성 (이력 통일)
$lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId);
$lotItemId = $lot?->stock?->item_id;
WorkOrderMaterialInput::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
'work_order_item_id' => $fallbackWoItemId,
'stock_lot_id' => $stockLotId,
'item_id' => $lotItemId ?? 0,
'qty' => $qty,
'input_by' => $userId,
'input_at' => now(),
]);
$inputResults[] = [
'stock_lot_id' => $stockLotId,
'qty' => $qty,
@@ -1373,17 +1518,23 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
'material_input',
null,
[
'inputs' => $inputs,
'inputs' => $noItemInputs,
'input_results' => $inputResults,
'input_by' => $userId,
'input_at' => now()->toDateTimeString(),
]
);
// 위임된 결과와 합산
$allResults = $inputResults;
foreach ($delegatedResults as $dr) {
$allResults = array_merge($allResults, $dr['input_results']);
}
return [
'work_order_id' => $workOrderId,
'material_count' => count($inputResults),
'input_results' => $inputResults,
'material_count' => count($allResults),
'input_results' => $allResults,
'input_at' => now()->toDateTimeString(),
];
});
@@ -2856,9 +3007,9 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra
referenceId: $workOrderId
);
// 로트의 품목 ID 조회
$lot = \App\Models\Tenants\StockLot::find($stockLotId);
$lotItemId = $lot ? ($lot->stock->item_id ?? null) : null;
// 로트의 품목 ID 조회 (Eager Loading으로 N+1 방지)
$lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId);
$lotItemId = $lot?->stock?->item_id;
// 개소별 매핑 레코드 생성
WorkOrderMaterialInput::create([