feat: [재공품] STOCK 작업지시 dynamic_bom 생성 + 자재 매칭 구현

- BendingCodeService: lengthCodeToMm() public static 메서드 추가
- BendingInfoBuilder: buildDynamicBomForStockItem() 신규 메서드
  - bending_lot(prodCode/specCode/lengthCode) + partKey → BD 품목 코드 → dynamic_bom 엔트리 생성
  - PrefixResolver 활용하여 정확한 prefix 매핑
- OrderService: STOCK 확정 시 bending_lot 기반 dynamic_bom 자동 생성
- WorkOrderService: 기존 STOCK 호환 fallback (getMaterials + getMaterialsForItem)
  - dynamic_bom 없는 기존 재공품도 bending_lot.material로 원자재 검색
This commit is contained in:
김보곤
2026-03-22 14:54:11 +09:00
parent af69f2df0c
commit a8b04e15c3
4 changed files with 305 additions and 1 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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]);
}
/**
* 파트타입 + 가이드타입 → 실제 재질 결정
*/

View File

@@ -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)