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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트타입 + 가이드타입 → 실제 재질 결정
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user