diff --git a/app/Services/BendingCodeService.php b/app/Services/BendingCodeService.php index d9ca7978..1855d745 100644 --- a/app/Services/BendingCodeService.php +++ b/app/Services/BendingCodeService.php @@ -284,4 +284,31 @@ public function getBendingWidthByItemCode(string $itemCode): ?float return isset($last['sum']) ? (float) $last['sum'] : null; } + + /** + * length_code → mm 변환 + * + * @param string $prodCode 제품코드 (G=연기차단재 전용 길이) + * @param string $lengthCode 길이코드 (24, 30, 53 등) + */ + public static function lengthCodeToMm(string $prodCode, string $lengthCode): int + { + // 연기차단재 전용 길이 + if ($prodCode === 'G') { + return match ($lengthCode) { + '53', '83' => 3000, + '54', '84' => 4000, + default => 0, + }; + } + + // 일반 길이 + $map = [ + '06' => 610, '12' => 1219, '17' => 1750, '20' => 2000, + '24' => 2438, '30' => 3000, '35' => 3500, '40' => 4000, + '41' => 4150, '42' => 4200, '43' => 4300, '45' => 4500, + ]; + + return $map[$lengthCode] ?? 0; + } } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 8d71fe81..bad0a47c 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -1735,6 +1735,24 @@ public function createProductionOrder(int $orderId, array $data) } } + // STOCK(재고생산): bending_lot 기반 dynamic_bom 생성 + if ($isStock && empty($woItemOptions['dynamic_bom'])) { + $orderOptions = $order->options ?? []; + $bendingLot = $orderOptions['bending_lot'] ?? null; + if ($bendingLot) { + $partKey = \App\Services\WorkOrderService::parseStockPartKeyStatic($orderItem->item_name); + $stockDynamicBom = app(BendingInfoBuilder::class)->buildDynamicBomForStockItem( + $bendingLot, + $partKey, + (int) ($orderItem->quantity ?? 1), + $tenantId, + ); + if (! empty($stockDynamicBom)) { + $woItemOptions['dynamic_bom'] = $stockDynamicBom; + } + } + } + DB::table('work_order_items')->insert([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrder->id, @@ -2239,7 +2257,7 @@ private static function buildStockBendingInfoFromLot(array $bendingLot, int $qua } // length_code → mm 변환 - $lengthMm = self::stockLengthCodeToMmStatic($prodCode, $lengthCode); + $lengthMm = \App\Services\BendingCodeService::lengthCodeToMm($prodCode, $lengthCode); if ($lengthMm <= 0) { return null; } diff --git a/app/Services/Production/BendingInfoBuilder.php b/app/Services/Production/BendingInfoBuilder.php index a35677cc..88f1a6ef 100644 --- a/app/Services/Production/BendingInfoBuilder.php +++ b/app/Services/Production/BendingInfoBuilder.php @@ -359,6 +359,191 @@ public function buildDynamicBomForItem(array $context, int $width, int $height, return DynamicBomEntry::toArrayList($entries); } + /** + * STOCK(재고생산) 개별 품목의 dynamic_bom 생성 + * + * 일반 수주는 buildDynamicBomForItem()이 context+width+height로 전체 BOM을 생성하지만, + * STOCK은 bending_lot 정보 + partKey 기반으로 단일 원자재(BD 품목) 엔트리를 생성한다. + * + * @param array $bendingLot order.options.bending_lot { prod_code, spec_code, length_code, material } + * @param string $partKey WorkOrderService::parseStockPartKeyStatic() 결과 ("본체", "마감재", "C형" 등) + * @param int $qty 수량 + * @param int $tenantId 테넌트 ID + * @return array DynamicBomEntry::toArray() 배열 (빈 배열 = 매칭 실패) + */ + public function buildDynamicBomForStockItem(array $bendingLot, string $partKey, int $qty, int $tenantId): array + { + $prodCode = $bendingLot['prod_code'] ?? ''; + $specCode = $bendingLot['spec_code'] ?? ''; + $lengthCode = $bendingLot['length_code'] ?? ''; + $material = $bendingLot['material'] ?? ''; + + if (! $prodCode || ! $specCode || ! $lengthCode) { + return []; + } + + $lengthMm = \App\Services\BendingCodeService::lengthCodeToMm($prodCode, $lengthCode); + if ($lengthMm <= 0) { + return []; + } + + // productCode/guideType/finishMaterial 결정 (buildStockBendingInfo와 동일 로직) + $productCode = match (true) { + in_array($specCode, ['S', 'U', 'F']) => 'KSS01', + $specCode === 'T' => 'KTE01', + default => 'KSE01', + }; + $guideType = $prodCode === 'S' ? 'side' : 'wall'; + $finishMaterial = in_array($specCode, ['S', 'U', 'F']) ? 'SUS마감' : ''; + + $resolver = new PrefixResolver; + $prefix = null; + $partType = ''; + $category = ''; + + // partKey → prefix + category 매핑 + switch ($partKey) { + // 가이드레일 파트 + case '본체': + $prefix = $resolver->resolveGuideRailPrefix('body', $guideType, $productCode); + $partType = '본체'; + $category = 'guideRail'; + break; + case '마감재': + $prefix = $resolver->resolveGuideRailPrefix('finish', $guideType, $productCode); + $partType = '마감재'; + $category = 'guideRail'; + break; + case 'C형': + $prefix = $resolver->resolveGuideRailPrefix('c_type', $guideType, $productCode); + $partType = 'C형'; + $category = 'guideRail'; + break; + case 'D형': + $prefix = $resolver->resolveGuideRailPrefix('d_type', $guideType, $productCode); + $partType = 'D형'; + $category = 'guideRail'; + break; + case '별도마감': + $prefix = $resolver->resolveGuideRailPrefix('extra_finish', $guideType, $productCode); + $partType = '별도마감'; + $category = 'guideRail'; + break; + case 'BASE': + case '하부BASE': + $prefix = $resolver->resolveGuideRailPrefix('base', $guideType, $productCode); + $partType = '하부BASE'; + $category = 'guideRail'; + break; + + // 하단마감재 파트 + case '하단마감재': + $prefix = $resolver->resolveBottomBarPrefix('main', $productCode, $finishMaterial); + $partType = '메인'; + $category = 'bottomBar'; + break; + case 'L-Bar': + $prefix = 'LA'; + $partType = 'L-Bar'; + $category = 'bottomBar'; + break; + + // 셔터박스 파트 + case '전면판': + case '전면부': + $prefix = $resolver->resolveShutterBoxPrefix('front'); + $partType = '전면부'; + $category = 'shutterBox'; + break; + case '점검구': + $prefix = $resolver->resolveShutterBoxPrefix('inspection'); + $partType = '점검구'; + $category = 'shutterBox'; + break; + case '린텔부': + $prefix = $resolver->resolveShutterBoxPrefix('lintel'); + $partType = '린텔부'; + $category = 'shutterBox'; + break; + case '후면코너부': + $prefix = $resolver->resolveShutterBoxPrefix('rear_corner'); + $partType = '후면코너부'; + $category = 'shutterBox'; + break; + case '케이스': + $prefix = $resolver->resolveShutterBoxPrefix('front'); + $partType = '전면부'; + $category = 'shutterBox'; + break; + + // 연기차단재 + case '연기차단재': + $prefix = $resolver->resolveSmokeBarrierPrefix(); + $partType = '연기차단재'; + $category = 'smokeBarrier'; + break; + + default: + Log::warning('BendingInfoBuilder::buildDynamicBomForStockItem: 알 수 없는 partKey', [ + 'partKey' => $partKey, 'prodCode' => $prodCode, + ]); + + return []; + } + + if (empty($prefix)) { + return []; + } + + // 연기차단재: smokeCategory 결정 (길이코드 기반) + $smokeCategory = null; + if ($category === 'smokeBarrier') { + $smokeCategory = str_starts_with($lengthCode, '5') ? 'w50' : 'w80'; + } + + // BD 코드 생성 → item_id 조회 + $itemCode = $resolver->buildItemCode($prefix, $lengthMm, $smokeCategory); + if (! $itemCode) { + Log::warning('BendingInfoBuilder::buildDynamicBomForStockItem: lengthCode 변환 실패', [ + 'prefix' => $prefix, 'lengthMm' => $lengthMm, + ]); + + return []; + } + + $itemId = $resolver->resolveItemId($itemCode, $tenantId); + if (! $itemId) { + Log::warning('BendingInfoBuilder::buildDynamicBomForStockItem: 미등록 품목', [ + 'code' => $itemCode, 'tenantId' => $tenantId, + ]); + + return []; + } + + // material_type 결정: material 문자열에서 추출 (예: "SUS 1.2T" → "SUS", "EGI 1.55T" → "EGI") + $materialType = ''; + if ($material && preg_match('/^([A-Za-zㄱ-ㅎ가-힣]+)/', $material, $m)) { + $materialType = strtoupper($m[1]); + } + // 연기차단재는 GI 고정 + if ($category === 'smokeBarrier') { + $materialType = 'GI'; + } + + $entry = new DynamicBomEntry( + child_item_id: $itemId, + child_item_code: $itemCode, + lot_prefix: $prefix, + part_type: $partType, + category: $category, + material_type: $materialType ?: $prefix, + length_mm: $lengthMm, + qty: $qty, + ); + + return DynamicBomEntry::toArrayList([$entry]); + } + /** * 파트타입 + 가이드타입 → 실제 재질 결정 */ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index d6e9f774..ca2d422c 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1782,6 +1782,40 @@ public function getMaterials(int $workOrderId): array ]; } + // STOCK 호환: item_id 없는 기존 재공품 → bending_lot.material 기반 원자재 검색 + if (empty($materialItems) && ! $woItem->item_id) { + $woBendingInfo = $workOrder->options['bending_info'] ?? []; + if (! empty($woBendingInfo['isStockProduction'])) { + $salesOrder = $workOrder->salesOrder ?? \App\Models\Orders\Order::find($workOrder->sales_order_id); + $bendingLot = $salesOrder?->options['bending_lot'] ?? null; + $material = $bendingLot['material'] ?? null; + $lengthCode = $bendingLot['length_code'] ?? null; + $prodCode = $bendingLot['prod_code'] ?? ''; + + if ($material && $lengthCode) { + $lengthMm = \App\Services\BendingCodeService::lengthCodeToMm($prodCode, $lengthCode); + if (preg_match('/^([A-Za-zㄱ-ㅎ가-힣]+)\s*(\d+\.?\d*)/u', $material, $matMatch)) { + $matName = $matMatch[1]; + $matThickness = (float) $matMatch[2]; + $rawItems = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->where('item_type', 'RM') + ->where('name', 'LIKE', "%{$matName}{$matThickness}%") + ->get(); + foreach ($rawItems as $rawItem) { + if ($lengthMm > 0 && ! str_contains($rawItem->name, (string) $lengthMm)) { + continue; + } + $materialItems[] = [ + 'item' => $rawItem, + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + ]; + } + } + } + } + } + // 기존 방식: item_id 기준 합산 foreach ($materialItems as $matInfo) { $itemId = $matInfo['item']->id; @@ -4072,6 +4106,46 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array } } + // ④ STOCK 호환: dynamic_bom/BOM/item 모두 없는 기존 재공품 → bending_lot.material 기반 원자재 검색 + if (empty($materialItems) && ! $woItem->item_id) { + $woBendingInfo = $workOrder->options['bending_info'] ?? []; + if (! empty($woBendingInfo['isStockProduction'])) { + $salesOrder = \App\Models\Orders\Order::find($workOrder->sales_order_id); + $bendingLot = $salesOrder?->options['bending_lot'] ?? null; + $material = $bendingLot['material'] ?? null; + $lengthCode = $bendingLot['length_code'] ?? null; + $prodCode = $bendingLot['prod_code'] ?? ''; + + if ($material && $lengthCode) { + $lengthMm = \App\Services\BendingCodeService::lengthCodeToMm($prodCode, $lengthCode); + + // material "SUS 1.2T" 또는 "EGI 1.55T" → 재질명 + 두께 파싱 + if (preg_match('/^([A-Za-zㄱ-ㅎ가-힣]+)\s*(\d+\.?\d*)/u', $material, $matMatch)) { + $matName = $matMatch[1]; + $matThickness = (float) $matMatch[2]; + + // items 테이블에서 RM(원자재) 검색: 이름에 재질명 + 두께 포함 + $rawItems = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->where('item_type', 'RM') + ->where('name', 'LIKE', "%{$matName}{$matThickness}%") + ->get(); + + // 길이 조건: 원자재 이름에 길이(mm) 포함 여부 + foreach ($rawItems as $rawItem) { + if ($lengthMm > 0 && ! str_contains($rawItem->name, (string) $lengthMm)) { + continue; + } + $materialItems[] = [ + 'item' => $rawItem, + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + ]; + } + } + } + } + } + // 이미 투입된 수량 조회 (item_id별 SUM) $inputtedQties = WorkOrderMaterialInput::where('tenant_id', $tenantId) ->where('work_order_id', $workOrderId)