From 5a3d6c2243f904e14f885084ae3486c62bfcf77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 02:13:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=20LOT=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/DTOs/Production/DynamicBomEntry.php | 101 ++++++ app/Services/OrderService.php | 24 +- .../Production/BendingInfoBuilder.php | 307 +++++++++++++++++- app/Services/Production/PrefixResolver.php | 307 ++++++++++++++++++ app/Services/WorkOrderService.php | 205 ++++++++++-- .../Production/BendingLotPipelineTest.php | 278 ++++++++++++++++ tests/Unit/Production/DynamicBomEntryTest.php | 173 ++++++++++ tests/Unit/Production/PrefixResolverTest.php | 263 +++++++++++++++ 8 files changed, 1625 insertions(+), 33 deletions(-) create mode 100644 app/DTOs/Production/DynamicBomEntry.php create mode 100644 app/Services/Production/PrefixResolver.php create mode 100644 tests/Feature/Production/BendingLotPipelineTest.php create mode 100644 tests/Unit/Production/DynamicBomEntryTest.php create mode 100644 tests/Unit/Production/PrefixResolverTest.php diff --git a/app/DTOs/Production/DynamicBomEntry.php b/app/DTOs/Production/DynamicBomEntry.php new file mode 100644 index 0000000..982967c --- /dev/null +++ b/app/DTOs/Production/DynamicBomEntry.php @@ -0,0 +1,101 @@ + $this->child_item_id, + 'child_item_code' => $this->child_item_code, + 'lot_prefix' => $this->lot_prefix, + 'part_type' => $this->part_type, + 'category' => $this->category, + 'material_type' => $this->material_type, + 'length_mm' => $this->length_mm, + 'qty' => $this->qty, + ]; + } + + /** + * 필수 필드 검증 + * + * @throws InvalidArgumentException + */ + public static function validate(array $data): bool + { + $required = ['child_item_id', 'child_item_code', 'lot_prefix', 'part_type', 'category', 'material_type', 'length_mm', 'qty']; + + foreach ($required as $field) { + if (! array_key_exists($field, $data) || $data[$field] === null) { + throw new InvalidArgumentException("DynamicBomEntry: '{$field}' is required"); + } + } + + if ((int) $data['child_item_id'] <= 0) { + throw new InvalidArgumentException('DynamicBomEntry: child_item_id must be positive'); + } + + $validCategories = ['guideRail', 'bottomBar', 'shutterBox', 'smokeBarrier']; + if (! in_array($data['category'], $validCategories, true)) { + throw new InvalidArgumentException('DynamicBomEntry: category must be one of: '.implode(', ', $validCategories)); + } + + if ($data['qty'] <= 0) { + throw new InvalidArgumentException('DynamicBomEntry: qty must be positive'); + } + + return true; + } + + /** + * DynamicBomEntry 배열 → JSON 저장용 배열 변환 + * + * @param DynamicBomEntry[] $entries + */ + public static function toArrayList(array $entries): array + { + return array_map(fn (self $e) => $e->toArray(), $entries); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index de33722..98b35d1 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -1336,9 +1336,9 @@ public function createProductionOrder(int $orderId, array $data) ->values() ->all(); - $bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null); - if ($bendingInfo) { - $workOrderOptions = ['bending_info' => $bendingInfo]; + $buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null); + if ($buildResult) { + $workOrderOptions = ['bending_info' => $buildResult['bending_info']]; } } @@ -1405,17 +1405,33 @@ public function createProductionOrder(int $orderId, array $data) $slatInfo['joint_bar'] = (2 + (int) floor(((float) $woWidth - 500) / 1000)) * $qty; } + $woHeight = $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null; + $woItemOptions = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, 'width' => $woWidth, - 'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null, + 'height' => $woHeight, 'cutting_info' => $nodeOptions['cutting_info'] ?? null, 'slat_info' => $slatInfo, 'bending_info' => $nodeOptions['bending_info'] ?? null, 'wip_info' => $nodeOptions['wip_info'] ?? null, ], fn ($v) => $v !== null); + // 절곡 공정: 개소별 dynamic_bom 생성 + if (! empty($buildResult['context']) && $woWidth && $woHeight) { + $dynamicBom = app(BendingInfoBuilder::class)->buildDynamicBomForItem( + $buildResult['context'], + (int) $woWidth, + (int) $woHeight, + (int) ($orderItem->quantity ?? 1), + $tenantId, + ); + if (! empty($dynamicBom)) { + $woItemOptions['dynamic_bom'] = $dynamicBom; + } + } + DB::table('work_order_items')->insert([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrder->id, diff --git a/app/Services/Production/BendingInfoBuilder.php b/app/Services/Production/BendingInfoBuilder.php index 799d75d..cad62df 100644 --- a/app/Services/Production/BendingInfoBuilder.php +++ b/app/Services/Production/BendingInfoBuilder.php @@ -2,15 +2,17 @@ namespace App\Services\Production; +use App\DTOs\Production\DynamicBomEntry; use App\Models\Orders\Order; use App\Models\Process; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; /** * 수주 → 생산지시 시 절곡 공정용 bending_info JSON 자동 생성 * * 입력: Order (rootNodes eager loaded) + processId - * 출력: BendingInfoExtended 구조의 array (work_orders.options.bending_info에 저장) + * 출력: ['bending_info' => array, 'context' => array] — bending_info + dynamic_bom 생성 컨텍스트 * * @see react/src/components/production/WorkOrders/documents/bending/types.ts */ @@ -18,6 +20,7 @@ class BendingInfoBuilder { // 표준 원자재 길이 버킷 (5130 레거시 write_form.php 기준) private const GUIDE_RAIL_LENGTHS = [2438, 3000, 3500, 4000, 4300]; + private const SHUTTER_BOX_LENGTHS = [1219, 2438, 3000, 3500, 4000, 4150]; /** @@ -65,7 +68,307 @@ public function build(Order $order, int $processId, ?array $nodeIds = null): ?ar $aggregated = $this->aggregateNodes($nodes); // 6. bending_info 조립 - return $this->assembleBendingInfo($productInfo, $materials, $aggregated); + $bendingInfo = $this->assembleBendingInfo($productInfo, $materials, $aggregated); + + // 7. 셔터박스 크기 추출 (dynamic_bom 컨텍스트용) + $caseBom = $aggregated['bomCategories']['shutterBox_case'] ?? null; + $motorBom = $aggregated['bomCategories']['motor'] ?? null; + $boxSize = null; + if ($caseBom) { + $motorCapacity = $this->extractMotorCapacity($motorBom); + $boxSize = $motorCapacity ? $this->getShutterBoxSize($motorCapacity) : null; + if (! $boxSize) { + $boxSize = str_replace('BD-케이스-', '', $caseBom['item_code'] ?? ''); + } + } + + return [ + 'bending_info' => $bendingInfo, + 'context' => [ + 'productCode' => $productInfo['productCode'], + 'guideType' => $productInfo['guideType'], + 'finishMaterial' => $productInfo['finishMaterial'], + 'materials' => $materials, + 'boxSize' => $boxSize, + 'hasSmokeRail' => isset($aggregated['bomCategories']['smokeBarrier_rail']), + 'hasSmokeCase' => isset($aggregated['bomCategories']['smokeBarrier_case']), + ], + ]; + } + + /** + * 개소(work_order_item) 단위 dynamic_bom 생성 + * + * @param array $context build() 반환값의 'context' + * @param int $width 개소의 오픈폭 (mm) + * @param int $height 개소의 오픈높이 (mm) + * @param int $qty 개소 수량 + * @param int $tenantId 테넌트 ID (item 조회용) + * @return array DynamicBomEntry::toArray() 배열 + */ + public function buildDynamicBomForItem(array $context, int $width, int $height, int $qty, int $tenantId = 287): array + { + $resolver = new PrefixResolver; + $entries = []; + + $productCode = $context['productCode']; + $guideType = $context['guideType']; + $finishMaterial = $context['finishMaterial']; + $materials = $context['materials']; + $boxSize = $context['boxSize']; + $hasExtraFinish = ! empty($materials['guideRailExtraFinish']); + + // ─── 1. 가이드레일 세부품목 ─── + $dimGroups = [['height' => $height, 'width' => $width, 'qty' => $qty]]; + $heightData = $this->heightLengthData($dimGroups); + + // 가이드레일은 개구부 양쪽 2개이므로 수량 ×2 + foreach ($heightData as &$entry) { + $entry['quantity'] *= 2; + } + unset($entry); + + $guideTypes = match ($guideType) { + '혼합형' => ['wall', 'side'], + '측면형' => ['side'], + default => ['wall'], + }; + + $guidePartTypes = ['finish', 'body', 'c_type', 'd_type']; + if ($hasExtraFinish) { + $guidePartTypes[] = 'extra_finish'; + } + $guidePartTypes[] = 'base'; + + foreach ($guideTypes as $gType) { + foreach ($heightData as $ld) { + foreach ($guidePartTypes as $partType) { + $prefix = $resolver->resolveGuideRailPrefix($partType, $gType, $productCode); + if (empty($prefix)) { + continue; + } + + $itemCode = $resolver->buildItemCode($prefix, $ld['length']); + if (! $itemCode) { + Log::warning('BendingInfoBuilder: lengthCode 변환 실패', ['prefix' => $prefix, 'length' => $ld['length']]); + + continue; + } + + $itemId = $resolver->resolveItemId($itemCode, $tenantId); + if (! $itemId) { + Log::warning('BendingInfoBuilder: 미등록 품목', ['code' => $itemCode]); + + continue; + } + + $entries[] = new DynamicBomEntry( + child_item_id: $itemId, + child_item_code: $itemCode, + lot_prefix: $prefix, + part_type: PrefixResolver::partTypeName($partType), + category: 'guideRail', + material_type: $this->resolvePartMaterial($partType, $gType, $materials), + length_mm: $ld['length'], + qty: $ld['quantity'], + ); + } + } + } + + // ─── 2. 하단마감재 세부품목 ─── + [$qty3000, $qty4000] = $this->bottomBarDistribution($width); + $bottomLengths = array_filter([3000 => $qty3000 * $qty, 4000 => $qty4000 * $qty]); + + $bottomPartTypes = ['main', 'lbar', 'reinforce']; + $hasBottomExtra = ! empty($materials['bottomBarExtraFinish']) && $materials['bottomBarExtraFinish'] !== '없음'; + if ($hasBottomExtra) { + $bottomPartTypes[] = 'extra'; + } + + foreach ($bottomLengths as $length => $lengthQty) { + if ($lengthQty <= 0) { + continue; + } + foreach ($bottomPartTypes as $partType) { + $prefix = $resolver->resolveBottomBarPrefix($partType, $productCode, $finishMaterial); + $itemCode = $resolver->buildItemCode($prefix, $length); + if (! $itemCode) { + continue; + } + $itemId = $resolver->resolveItemId($itemCode, $tenantId); + if (! $itemId) { + Log::warning('BendingInfoBuilder: 미등록 하단마감재', ['code' => $itemCode]); + + continue; + } + + $entries[] = new DynamicBomEntry( + child_item_id: $itemId, + child_item_code: $itemCode, + lot_prefix: $prefix, + part_type: PrefixResolver::partTypeName($partType), + category: 'bottomBar', + material_type: $materials['bottomBarFinish'], + length_mm: $length, + qty: $lengthQty, + ); + } + } + + // ─── 3. 셔터박스 세부품목 ─── + if ($boxSize) { + $isStandard = $boxSize === '500*380'; + $dist = $this->shutterBoxDistribution($width); + $shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover']; + + foreach ($dist as $length => $count) { + $totalCount = $count * $qty; + if ($totalCount <= 0) { + continue; + } + foreach ($shutterPartTypes as $partType) { + $prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard); + $itemCode = $resolver->buildItemCode($prefix, $length); + if (! $itemCode) { + continue; + } + $itemId = $resolver->resolveItemId($itemCode, $tenantId); + if (! $itemId) { + Log::warning('BendingInfoBuilder: 미등록 셔터박스', ['code' => $itemCode]); + + continue; + } + + $entries[] = new DynamicBomEntry( + child_item_id: $itemId, + child_item_code: $itemCode, + lot_prefix: $prefix, + part_type: PrefixResolver::partTypeName($partType), + category: 'shutterBox', + material_type: 'EGI', + length_mm: $length, + qty: $totalCount, + ); + } + } + + // 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위) + $coverQty = (int) ceil($width / 1219) * $qty; + if ($coverQty > 0) { + $coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard); + $coverCode = $resolver->buildItemCode($coverPrefix, 1219); + if ($coverCode) { + $coverId = $resolver->resolveItemId($coverCode, $tenantId); + if ($coverId) { + $entries[] = new DynamicBomEntry( + child_item_id: $coverId, + child_item_code: $coverCode, + lot_prefix: $coverPrefix, + part_type: PrefixResolver::partTypeName('top_cover'), + category: 'shutterBox', + material_type: 'EGI', + length_mm: 1219, + qty: $coverQty, + ); + } + } + } + + // 마구리 수량: qty × 2 + $finQty = $qty * 2; + if ($finQty > 0) { + $finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard); + // 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용 + $finCode = $resolver->buildItemCode($finPrefix, 1219); + if ($finCode) { + $finId = $resolver->resolveItemId($finCode, $tenantId); + if ($finId) { + $entries[] = new DynamicBomEntry( + child_item_id: $finId, + child_item_code: $finCode, + lot_prefix: $finPrefix, + part_type: PrefixResolver::partTypeName('fin_cover'), + category: 'shutterBox', + material_type: 'EGI', + length_mm: 1219, + qty: $finQty, + ); + } + } + } + } + + // ─── 4. 연기차단재 세부품목 ─── + if ($context['hasSmokeRail'] || $context['hasSmokeCase']) { + $smokePrefix = $resolver->resolveSmokeBarrierPrefix(); + + // W50 (레일용): open_height + 250 → 표준 길이 + if ($context['hasSmokeRail']) { + $col24 = $height + 250; + $w50Length = $this->bucketToStandardLength($col24, [2438, 3000, 3500, 4000, 4300]); + if ($w50Length && $col24 <= 4300) { + $w50Code = $resolver->buildItemCode($smokePrefix, $w50Length, 'w50'); + if ($w50Code) { + $w50Id = $resolver->resolveItemId($w50Code, $tenantId); + if ($w50Id) { + $entries[] = new DynamicBomEntry( + child_item_id: $w50Id, + child_item_code: $w50Code, + lot_prefix: $smokePrefix, + part_type: '연기차단재(W50)', + category: 'smokeBarrier', + material_type: 'GI', + length_mm: $w50Length, + qty: 2 * $qty, + ); + } + } + } + } + + // W80 (케이스용): floor((width+240)*2/3000 + 1) × qty + if ($context['hasSmokeCase']) { + $col38 = $width + 240; + $w80PerNode = (int) floor(($col38 * 2 / 3000) + 1); + $w80Qty = $w80PerNode * $qty; + if ($w80Qty > 0) { + // W80은 3000mm 기본 (레거시 동일) + $w80Code = $resolver->buildItemCode($smokePrefix, 3000, 'w80'); + if ($w80Code) { + $w80Id = $resolver->resolveItemId($w80Code, $tenantId); + if ($w80Id) { + $entries[] = new DynamicBomEntry( + child_item_id: $w80Id, + child_item_code: $w80Code, + lot_prefix: $smokePrefix, + part_type: '연기차단재(W80)', + category: 'smokeBarrier', + material_type: 'GI', + length_mm: 3000, + qty: $w80Qty, + ); + } + } + } + } + } + + return DynamicBomEntry::toArrayList($entries); + } + + /** + * 파트타입 + 가이드타입 → 실제 재질 결정 + */ + private function resolvePartMaterial(string $partType, string $guideType, array $materials): string + { + return match ($partType) { + 'finish' => $materials['guideRailFinish'], + 'extra_finish' => $materials['guideRailExtraFinish'], + 'body', 'c_type', 'd_type' => $materials['bodyMaterial'], + 'base' => 'EGI', + default => '', + }; } /** diff --git a/app/Services/Production/PrefixResolver.php b/app/Services/Production/PrefixResolver.php new file mode 100644 index 0000000..772b995 --- /dev/null +++ b/app/Services/Production/PrefixResolver.php @@ -0,0 +1,307 @@ + ['KSS' => 'RS', 'KQTS' => 'RS', 'KSE' => 'RE', 'KWE' => 'RE', 'KTE' => 'RS'], + 'body' => 'RM', + 'c_type' => 'RC', + 'd_type' => 'RD', + 'extra_finish' => 'YY', + 'base' => 'XX', + ]; + + /** 측면형(Side) prefix */ + private const SIDE_PREFIXES = [ + 'finish' => ['KSS' => 'SS', 'KQTS' => 'SS', 'KSE' => 'SE', 'KWE' => 'SE', 'KTE' => 'SS'], + 'body' => 'SM', + 'c_type' => 'SC', + 'd_type' => 'SD', + 'extra_finish' => 'YY', + 'base' => 'XX', + ]; + + /** 철재(KTE01) body 오버라이드 */ + private const STEEL_BODY_OVERRIDES = [ + 'wall' => 'RT', + 'side' => 'ST', + ]; + + // ───────────────────────────────────────────────── + // 하단마감재 Prefix 맵 + // ───────────────────────────────────────────────── + + /** 하단마감재 main prefix: finishMaterial 기반 */ + private const BOTTOM_BAR_MAIN = [ + 'EGI' => 'BE', + 'SUS' => 'BS', + 'STEEL' => 'TS', + ]; + + // ───────────────────────────────────────────────── + // 셔터박스 Prefix 맵 + // ───────────────────────────────────────────────── + + /** 표준 사이즈(500*380) 셔터박스 prefix */ + private const SHUTTER_STANDARD = [ + 'front' => 'CF', + 'lintel' => 'CL', + 'inspection' => 'CP', + 'rear_corner' => 'CB', + 'top_cover' => 'XX', + 'fin_cover' => 'XX', + ]; + + // ───────────────────────────────────────────────── + // 길이코드 매핑 + // ───────────────────────────────────────────────── + + private const LENGTH_TO_CODE = [ + 1219 => '12', + 2438 => '24', + 3000 => '30', + 3500 => '35', + 4000 => '40', + 4150 => '41', + 4200 => '42', + 4300 => '43', + ]; + + /** 연기차단재 전용 길이코드 */ + private const SMOKE_LENGTH_TO_CODE = [ + 'w50' => [3000 => '53', 4000 => '54'], + 'w80' => [3000 => '83', 4000 => '84'], + ]; + + /** 파트타입 한글명 */ + private const PART_TYPE_NAMES = [ + 'finish' => '마감재', + 'body' => '본체', + 'c_type' => 'C형', + 'd_type' => 'D형', + 'extra_finish' => '별도마감', + 'base' => '하부BASE', + 'main' => '메인', + 'lbar' => 'L-Bar', + 'reinforce' => '보강평철', + 'extra' => '별도마감', + 'front' => '전면부', + 'lintel' => '린텔부', + 'inspection' => '점검구', + 'rear_corner' => '후면코너부', + 'top_cover' => '상부덮개', + 'fin_cover' => '마구리', + 'smoke' => '연기차단재', + ]; + + /** items.id 캐시: code → id */ + private array $itemIdCache = []; + + // ───────────────────────────────────────────────── + // 가이드레일 + // ───────────────────────────────────────────────── + + /** + * 가이드레일 세부품목의 prefix 결정 + * + * @param string $partType 'finish', 'body', 'c_type', 'd_type', 'extra_finish', 'base' + * @param string $guideType 'wall', 'side' + * @param string $productCode 'KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01' + * @return string prefix (빈 문자열이면 해당 파트 없음) + */ + public function resolveGuideRailPrefix(string $partType, string $guideType, string $productCode): string + { + $prefixMap = $guideType === 'wall' ? self::WALL_PREFIXES : self::SIDE_PREFIXES; + $codePrefix = $this->extractCodePrefix($productCode); + $isSteel = $codePrefix === 'KTE'; + + // body: 철재 오버라이드 + if ($partType === 'body' && $isSteel) { + return self::STEEL_BODY_OVERRIDES[$guideType] ?? ''; + } + + // finish: productCode별 분기 + if ($partType === 'finish') { + $finishMap = $prefixMap['finish'] ?? []; + + return $finishMap[$codePrefix] ?? ''; + } + + // extra_finish, base, c_type, d_type, body: 고정 prefix + return $prefixMap[$partType] ?? ''; + } + + // ───────────────────────────────────────────────── + // 하단마감재 + // ───────────────────────────────────────────────── + + /** + * 하단마감재 세부품목의 prefix 결정 + * + * @param string $partType 'main', 'lbar', 'reinforce', 'extra' + * @param string $productCode 'KSS01', 'KSE01', etc. + * @param string $finishMaterial 'EGI마감', 'SUS마감' + * @return string prefix + */ + public function resolveBottomBarPrefix(string $partType, string $productCode, string $finishMaterial): string + { + if ($partType === 'lbar') { + return 'LA'; + } + if ($partType === 'reinforce') { + return 'HH'; + } + if ($partType === 'extra') { + return 'YY'; + } + + // main: 재질 기반 + $codePrefix = $this->extractCodePrefix($productCode); + $isSteel = $codePrefix === 'KTE'; + + if ($isSteel) { + return 'TS'; + } + + $isSUS = in_array($codePrefix, ['KSS', 'KQTS']); + + return $isSUS ? 'BS' : 'BE'; + } + + // ───────────────────────────────────────────────── + // 셔터박스 + // ───────────────────────────────────────────────── + + /** + * 셔터박스 세부품목의 prefix 결정 + * + * @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover' + * @param bool $isStandardSize 500*380인지 + * @return string prefix + */ + public function resolveShutterBoxPrefix(string $partType, bool $isStandardSize): string + { + if (! $isStandardSize) { + return 'XX'; + } + + return self::SHUTTER_STANDARD[$partType] ?? 'XX'; + } + + // ───────────────────────────────────────────────── + // 연기차단재 + // ───────────────────────────────────────────────── + + /** + * 연기차단재 세부품목의 prefix 결정 (항상 GI) + */ + public function resolveSmokeBarrierPrefix(): string + { + return 'GI'; + } + + // ───────────────────────────────────────────────── + // 코드 생성 및 조회 + // ───────────────────────────────────────────────── + + /** + * prefix + 길이(mm) → BD-XX-NN 코드 생성 + * + * @param string $prefix LOT prefix (RS, RM, etc.) + * @param int $lengthMm 길이 (mm) + * @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80') + * @return string|null BD 코드 (길이코드 변환 실패 시 null) + */ + public function buildItemCode(string $prefix, int $lengthMm, ?string $smokeCategory = null): ?string + { + $lengthCode = self::lengthToCode($lengthMm, $smokeCategory); + if ($lengthCode === null) { + return null; + } + + return "BD-{$prefix}-{$lengthCode}"; + } + + /** + * BD-XX-NN 코드 → items.id 조회 (캐시) + * + * @return int|null items.id (미등록 시 null) + */ + public function resolveItemId(string $itemCode, int $tenantId = 287): ?int + { + $cacheKey = "{$tenantId}:{$itemCode}"; + + if (isset($this->itemIdCache[$cacheKey])) { + return $this->itemIdCache[$cacheKey]; + } + + $id = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->value('id'); + + $this->itemIdCache[$cacheKey] = $id; + + return $id; + } + + /** + * 길이(mm) → 길이코드 변환 + * + * @param int $lengthMm 길이 (mm) + * @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80') + * @return string|null 길이코드 (변환 불가 시 null) + */ + public static function lengthToCode(int $lengthMm, ?string $smokeCategory = null): ?string + { + // 연기차단재 전용 코드 + if ($smokeCategory && isset(self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm])) { + return self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm]; + } + + return self::LENGTH_TO_CODE[$lengthMm] ?? null; + } + + /** + * 파트타입 한글명 반환 + */ + public static function partTypeName(string $partType): string + { + return self::PART_TYPE_NAMES[$partType] ?? $partType; + } + + /** + * 캐시 초기화 (테스트 용) + */ + public function clearCache(): void + { + $this->itemIdCache = []; + } + + // ───────────────────────────────────────────────── + // private + // ───────────────────────────────────────────────── + + /** + * 'KSS01' → 'KSS', 'KQTS01' → 'KQTS' 등 제품코드 prefix 추출 + */ + private function extractCodePrefix(string $productCode): string + { + return preg_replace('/\d+$/', '', $productCode); + } +} diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 2821606..3d925ce 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -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([ diff --git a/tests/Feature/Production/BendingLotPipelineTest.php b/tests/Feature/Production/BendingLotPipelineTest.php new file mode 100644 index 0000000..dbf0aac --- /dev/null +++ b/tests/Feature/Production/BendingLotPipelineTest.php @@ -0,0 +1,278 @@ +resolver = new PrefixResolver; + } + + // ───────────────────────────────────────────────── + // PrefixResolver → items.id 조회 통합 + // ───────────────────────────────────────────────── + + /** + * BD-* 품목이 items 테이블에 실제 존재하는지 확인 + */ + public function test_prefix_resolver_resolves_existing_bd_items(): void + { + $testCodes = [ + 'BD-RS-43', 'BD-RM-30', 'BD-RC-35', 'BD-RD-40', + 'BD-SS-43', 'BD-SM-30', 'BD-SC-35', 'BD-SD-40', + 'BD-BE-30', 'BD-BS-40', 'BD-LA-30', + 'BD-CF-30', 'BD-CL-24', 'BD-CP-30', 'BD-CB-30', + 'BD-GI-53', 'BD-GI-84', + 'BD-XX-30', 'BD-YY-43', 'BD-HH-30', + ]; + + $foundCount = 0; + $missingCodes = []; + + foreach ($testCodes as $code) { + $id = $this->resolver->resolveItemId($code, self::TENANT_ID); + if ($id !== null) { + $foundCount++; + $this->assertGreaterThan(0, $id, "Item ID for {$code} must be positive"); + } else { + $missingCodes[] = $code; + } + } + + // Phase 0에서 전부 등록했으므로 모두 존재해야 함 + $this->assertEmpty( + $missingCodes, + 'Missing BD items: '.implode(', ', $missingCodes) + ); + $this->assertCount(count($testCodes), array_diff($testCodes, $missingCodes)); + } + + /** + * resolveItemId 캐시 동작 확인 + */ + public function test_resolve_item_id_uses_cache(): void + { + $code = 'BD-RS-43'; + $id1 = $this->resolver->resolveItemId($code, self::TENANT_ID); + $id2 = $this->resolver->resolveItemId($code, self::TENANT_ID); + + $this->assertNotNull($id1); + $this->assertSame($id1, $id2, 'Cached result should be identical'); + } + + // ───────────────────────────────────────────────── + // dynamic_bom 생성 → JSON 구조 검증 + // ───────────────────────────────────────────────── + + /** + * DynamicBomEntry 배열이 올바른 JSON 구조로 변환되는지 확인 + */ + public function test_dynamic_bom_entries_produce_valid_json_structure(): void + { + $entries = []; + + // 가이드레일 벽면형 KSS01 (SUS) 4300mm + $testCombinations = [ + ['finish', 'wall', 'KSS01', 4300, 'guideRail', 'SUS'], + ['body', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ['c_type', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ['d_type', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ['base', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'], + ]; + + foreach ($testCombinations as [$partType, $guideType, $productCode, $lengthMm, $category, $materialType]) { + $prefix = $this->resolver->resolveGuideRailPrefix($partType, $guideType, $productCode); + $itemCode = $this->resolver->buildItemCode($prefix, $lengthMm); + $this->assertNotNull($itemCode, "buildItemCode failed for {$prefix}/{$lengthMm}"); + + $itemId = $this->resolver->resolveItemId($itemCode, self::TENANT_ID); + if ($itemId === null) { + $this->markTestSkipped("Item {$itemCode} not found in DB — run Phase 0 first"); + } + + $entries[] = DynamicBomEntry::fromArray([ + 'child_item_id' => $itemId, + 'child_item_code' => $itemCode, + 'lot_prefix' => $prefix, + 'part_type' => PrefixResolver::partTypeName($partType), + 'category' => $category, + 'material_type' => $materialType, + 'length_mm' => $lengthMm, + 'qty' => 1, + ]); + } + + $json = DynamicBomEntry::toArrayList($entries); + + $this->assertCount(5, $json); + $this->assertEquals('BD-RS-43', $json[0]['child_item_code']); + $this->assertEquals('BD-RM-43', $json[1]['child_item_code']); + $this->assertEquals('BD-RC-43', $json[2]['child_item_code']); + $this->assertEquals('BD-RD-43', $json[3]['child_item_code']); + $this->assertEquals('BD-XX-43', $json[4]['child_item_code']); + + // JSON 인코딩/디코딩 정합성 + $encoded = json_encode($json, JSON_UNESCAPED_UNICODE); + $decoded = json_decode($encoded, true); + $this->assertEquals($json, $decoded, 'JSON round-trip should be identical'); + } + + // ───────────────────────────────────────────────── + // getMaterials dynamic_bom 우선 체크 + // ───────────────────────────────────────────────── + + /** + * work_order_items.options.dynamic_bom이 있는 경우 + * getMaterials가 세부품목을 반환하는지 확인 + */ + public function test_get_materials_returns_dynamic_bom_items(): void + { + // 절곡 작업지시 찾기 (dynamic_bom이 있는) + $woItem = DB::table('work_order_items') + ->where('tenant_id', self::TENANT_ID) + ->whereNotNull('options') + ->whereRaw("JSON_EXTRACT(options, '$.dynamic_bom') IS NOT NULL") + ->first(); + + if (! $woItem) { + $this->markTestSkipped('No work_order_items with dynamic_bom found — create a bending work order first'); + } + + $options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []); + $dynamicBom = $options['dynamic_bom'] ?? []; + + $this->assertNotEmpty($dynamicBom, 'dynamic_bom should not be empty'); + + // dynamic_bom 각 항목 구조 검증 + foreach ($dynamicBom as $entry) { + $this->assertArrayHasKey('child_item_id', $entry); + $this->assertArrayHasKey('child_item_code', $entry); + $this->assertArrayHasKey('lot_prefix', $entry); + $this->assertArrayHasKey('part_type', $entry); + $this->assertArrayHasKey('category', $entry); + $this->assertGreaterThan(0, $entry['child_item_id']); + $this->assertMatchesRegularExpression('/^BD-[A-Z]{2}-\d{2}$/', $entry['child_item_code']); + } + } + + /** + * getMaterials API 응답에 work_order_item_id 필드가 포함되는지 확인 + */ + public function test_get_materials_api_includes_work_order_item_id(): void + { + // 절곡 작업지시 찾기 + $wo = DB::table('work_orders') + ->where('tenant_id', self::TENANT_ID) + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('work_order_items') + ->whereColumn('work_order_items.work_order_id', 'work_orders.id') + ->whereRaw("JSON_EXTRACT(options, '$.dynamic_bom') IS NOT NULL"); + }) + ->first(); + + if (! $wo) { + $this->markTestSkipped('No work order with dynamic_bom items found'); + } + + // WorkOrderService 직접 호출로 getMaterials 검증 + $service = app(WorkOrderService::class); + $service->setContext(self::TENANT_ID, 1); + + $materials = $service->getMaterials($wo->id); + + // dynamic_bom 품목에는 work_order_item_id가 포함되어야 함 + $dynamicBomMaterials = array_filter($materials, fn ($m) => isset($m['work_order_item_id'])); + + if (empty($dynamicBomMaterials)) { + $this->markTestSkipped('getMaterials returned no dynamic_bom materials'); + } + + foreach ($dynamicBomMaterials as $material) { + $this->assertArrayHasKey('work_order_item_id', $material); + $this->assertArrayHasKey('lot_prefix', $material); + $this->assertArrayHasKey('category', $material); + $this->assertGreaterThan(0, $material['work_order_item_id']); + } + } + + // ───────────────────────────────────────────────── + // 전체 prefix × lengthCode 마스터 검증 (Phase 0 검증 재확인) + // ───────────────────────────────────────────────── + + /** + * 19종 prefix × 해당 lengthCode 조합이 모두 items 테이블에 존재하는지 확인 + */ + public function test_all_prefix_length_combinations_exist_in_items(): void + { + $standardLengths = [30, 35, 40, 43]; + $boxLengths = [12, 24, 30, 35, 40, 41]; + + $prefixLengthMap = [ + // 가이드레일 벽면형 + 'RS' => $standardLengths, 'RM' => array_merge($standardLengths, [24, 35]), + 'RC' => array_merge($standardLengths, [24, 35]), 'RD' => array_merge($standardLengths, [24, 35]), + 'RT' => [30, 43], + // 가이드레일 측면형 + 'SS' => [30, 35, 40, 43], 'SM' => [30, 35, 40, 43, 24], + 'SC' => [30, 35, 40, 43, 24], 'SD' => [30, 35, 40, 43, 24], + 'ST' => [43], 'SU' => [30, 35, 40, 43], + // 하단마감재 + 'BE' => [30, 40], 'BS' => [30, 35, 40, 43, 24], + 'TS' => [40, 43], + 'LA' => [30, 40], + // 셔터박스 (표준 길이: 43 제외 — 4300mm는 가이드레일 전용) + 'CF' => $boxLengths, 'CL' => $boxLengths, + 'CP' => $boxLengths, 'CB' => $boxLengths, + // 연기차단재 + 'GI' => [53, 54, 83, 84, 30, 35, 40], + // 공통 + 'XX' => array_merge($boxLengths, [43]), 'YY' => $standardLengths, + 'HH' => [30, 40], + ]; + + $missing = []; + + foreach ($prefixLengthMap as $prefix => $codes) { + foreach ($codes as $code) { + $itemCode = "BD-{$prefix}-{$code}"; + $exists = DB::table('items') + ->where('tenant_id', self::TENANT_ID) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->exists(); + + if (! $exists) { + $missing[] = $itemCode; + } + } + } + + $this->assertEmpty( + $missing, + 'Missing BD items in items table: '.implode(', ', $missing) + ); + } +} diff --git a/tests/Unit/Production/DynamicBomEntryTest.php b/tests/Unit/Production/DynamicBomEntryTest.php new file mode 100644 index 0000000..e787d93 --- /dev/null +++ b/tests/Unit/Production/DynamicBomEntryTest.php @@ -0,0 +1,173 @@ + 15812, + 'child_item_code' => 'BD-RS-43', + 'lot_prefix' => 'RS', + 'part_type' => '마감재', + 'category' => 'guideRail', + 'material_type' => 'SUS', + 'length_mm' => 4300, + 'qty' => 2, + ]; + } + + // ───────────────────────────────────────────────── + // fromArray + toArray 라운드트립 + // ───────────────────────────────────────────────── + + public function test_from_array_creates_dto(): void + { + $entry = DynamicBomEntry::fromArray($this->validData()); + + $this->assertEquals(15812, $entry->child_item_id); + $this->assertEquals('BD-RS-43', $entry->child_item_code); + $this->assertEquals('RS', $entry->lot_prefix); + $this->assertEquals('마감재', $entry->part_type); + $this->assertEquals('guideRail', $entry->category); + $this->assertEquals('SUS', $entry->material_type); + $this->assertEquals(4300, $entry->length_mm); + $this->assertEquals(2, $entry->qty); + } + + public function test_to_array_round_trip(): void + { + $data = $this->validData(); + $entry = DynamicBomEntry::fromArray($data); + $this->assertEquals($data, $entry->toArray()); + } + + public function test_to_array_list(): void + { + $entries = [ + DynamicBomEntry::fromArray($this->validData()), + DynamicBomEntry::fromArray(array_merge($this->validData(), [ + 'child_item_id' => 15813, + 'child_item_code' => 'BD-RM-43', + 'lot_prefix' => 'RM', + 'part_type' => '본체', + ])), + ]; + + $list = DynamicBomEntry::toArrayList($entries); + $this->assertCount(2, $list); + $this->assertEquals('BD-RS-43', $list[0]['child_item_code']); + $this->assertEquals('BD-RM-43', $list[1]['child_item_code']); + } + + // ───────────────────────────────────────────────── + // 유효한 카테고리 + // ───────────────────────────────────────────────── + + /** + * @dataProvider validCategoryProvider + */ + public function test_valid_categories(string $category): void + { + $data = array_merge($this->validData(), ['category' => $category]); + $entry = DynamicBomEntry::fromArray($data); + $this->assertEquals($category, $entry->category); + } + + public static function validCategoryProvider(): array + { + return [ + 'guideRail' => ['guideRail'], + 'bottomBar' => ['bottomBar'], + 'shutterBox' => ['shutterBox'], + 'smokeBarrier' => ['smokeBarrier'], + ]; + } + + // ───────────────────────────────────────────────── + // 필수 필드 누락 검증 + // ───────────────────────────────────────────────── + + /** + * @dataProvider requiredFieldProvider + */ + public function test_missing_required_field_throws(string $field): void + { + $data = $this->validData(); + unset($data[$field]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("'{$field}' is required"); + DynamicBomEntry::fromArray($data); + } + + public static function requiredFieldProvider(): array + { + return [ + 'child_item_id' => ['child_item_id'], + 'child_item_code' => ['child_item_code'], + 'lot_prefix' => ['lot_prefix'], + 'part_type' => ['part_type'], + 'category' => ['category'], + 'material_type' => ['material_type'], + 'length_mm' => ['length_mm'], + 'qty' => ['qty'], + ]; + } + + // ───────────────────────────────────────────────── + // 값 제약 검증 + // ───────────────────────────────────────────────── + + public function test_invalid_child_item_id_throws(): void + { + $data = array_merge($this->validData(), ['child_item_id' => 0]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('child_item_id must be positive'); + DynamicBomEntry::fromArray($data); + } + + public function test_invalid_category_throws(): void + { + $data = array_merge($this->validData(), ['category' => 'invalidCategory']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('category must be one of'); + DynamicBomEntry::fromArray($data); + } + + public function test_zero_qty_throws(): void + { + $data = array_merge($this->validData(), ['qty' => 0]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('qty must be positive'); + DynamicBomEntry::fromArray($data); + } + + public function test_negative_qty_throws(): void + { + $data = array_merge($this->validData(), ['qty' => -1]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('qty must be positive'); + DynamicBomEntry::fromArray($data); + } + + // ───────────────────────────────────────────────── + // float qty 허용 + // ───────────────────────────────────────────────── + + public function test_float_qty_allowed(): void + { + $data = array_merge($this->validData(), ['qty' => 1.5]); + $entry = DynamicBomEntry::fromArray($data); + $this->assertEquals(1.5, $entry->qty); + } +} diff --git a/tests/Unit/Production/PrefixResolverTest.php b/tests/Unit/Production/PrefixResolverTest.php new file mode 100644 index 0000000..5551730 --- /dev/null +++ b/tests/Unit/Production/PrefixResolverTest.php @@ -0,0 +1,263 @@ +resolver = new PrefixResolver; + } + + // ───────────────────────────────────────────────── + // 가이드레일 벽면형(Wall) Prefix + // ───────────────────────────────────────────────── + + /** + * @dataProvider wallFinishProvider + */ + public function test_wall_finish_prefix(string $productCode, string $expected): void + { + $this->assertEquals( + $expected, + $this->resolver->resolveGuideRailPrefix('finish', 'wall', $productCode) + ); + } + + public static function wallFinishProvider(): array + { + return [ + 'KSS01 → RS' => ['KSS01', 'RS'], + 'KQTS01 → RS' => ['KQTS01', 'RS'], + 'KSE01 → RE' => ['KSE01', 'RE'], + 'KWE01 → RE' => ['KWE01', 'RE'], + 'KTE01 → RS' => ['KTE01', 'RS'], + ]; + } + + public function test_wall_body_prefix(): void + { + $this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KSS01')); + $this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KSE01')); + $this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KWE01')); + } + + public function test_wall_body_steel_override(): void + { + $this->assertEquals('RT', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KTE01')); + } + + public function test_wall_fixed_prefixes(): void + { + foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'] as $code) { + $this->assertEquals('RC', $this->resolver->resolveGuideRailPrefix('c_type', 'wall', $code)); + $this->assertEquals('RD', $this->resolver->resolveGuideRailPrefix('d_type', 'wall', $code)); + $this->assertEquals('YY', $this->resolver->resolveGuideRailPrefix('extra_finish', 'wall', $code)); + $this->assertEquals('XX', $this->resolver->resolveGuideRailPrefix('base', 'wall', $code)); + } + } + + // ───────────────────────────────────────────────── + // 가이드레일 측면형(Side) Prefix + // ───────────────────────────────────────────────── + + /** + * @dataProvider sideFinishProvider + */ + public function test_side_finish_prefix(string $productCode, string $expected): void + { + $this->assertEquals( + $expected, + $this->resolver->resolveGuideRailPrefix('finish', 'side', $productCode) + ); + } + + public static function sideFinishProvider(): array + { + return [ + 'KSS01 → SS' => ['KSS01', 'SS'], + 'KQTS01 → SS' => ['KQTS01', 'SS'], + 'KSE01 → SE' => ['KSE01', 'SE'], + 'KWE01 → SE' => ['KWE01', 'SE'], + 'KTE01 → SS' => ['KTE01', 'SS'], + ]; + } + + public function test_side_body_prefix(): void + { + $this->assertEquals('SM', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KSS01')); + $this->assertEquals('SM', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KSE01')); + } + + public function test_side_body_steel_override(): void + { + $this->assertEquals('ST', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KTE01')); + } + + public function test_side_fixed_prefixes(): void + { + foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'] as $code) { + $this->assertEquals('SC', $this->resolver->resolveGuideRailPrefix('c_type', 'side', $code)); + $this->assertEquals('SD', $this->resolver->resolveGuideRailPrefix('d_type', 'side', $code)); + $this->assertEquals('YY', $this->resolver->resolveGuideRailPrefix('extra_finish', 'side', $code)); + $this->assertEquals('XX', $this->resolver->resolveGuideRailPrefix('base', 'side', $code)); + } + } + + // ───────────────────────────────────────────────── + // 하단마감재 Prefix + // ───────────────────────────────────────────────── + + public function test_bottom_bar_main_prefix(): void + { + // EGI 제품 + $this->assertEquals('BE', $this->resolver->resolveBottomBarPrefix('main', 'KSE01', 'EGI마감')); + $this->assertEquals('BE', $this->resolver->resolveBottomBarPrefix('main', 'KWE01', 'EGI마감')); + + // SUS 제품 + $this->assertEquals('BS', $this->resolver->resolveBottomBarPrefix('main', 'KSS01', 'SUS마감')); + $this->assertEquals('BS', $this->resolver->resolveBottomBarPrefix('main', 'KQTS01', 'SUS마감')); + + // 철재 + $this->assertEquals('TS', $this->resolver->resolveBottomBarPrefix('main', 'KTE01', 'EGI마감')); + } + + public function test_bottom_bar_fixed_prefixes(): void + { + foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01'] as $code) { + $this->assertEquals('LA', $this->resolver->resolveBottomBarPrefix('lbar', $code, 'EGI마감')); + $this->assertEquals('HH', $this->resolver->resolveBottomBarPrefix('reinforce', $code, 'EGI마감')); + $this->assertEquals('YY', $this->resolver->resolveBottomBarPrefix('extra', $code, 'SUS마감')); + } + } + + // ───────────────────────────────────────────────── + // 셔터박스 Prefix + // ───────────────────────────────────────────────── + + public function test_shutter_box_standard_prefixes(): void + { + $this->assertEquals('CF', $this->resolver->resolveShutterBoxPrefix('front', true)); + $this->assertEquals('CL', $this->resolver->resolveShutterBoxPrefix('lintel', true)); + $this->assertEquals('CP', $this->resolver->resolveShutterBoxPrefix('inspection', true)); + $this->assertEquals('CB', $this->resolver->resolveShutterBoxPrefix('rear_corner', true)); + $this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix('top_cover', true)); + $this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix('fin_cover', true)); + } + + public function test_shutter_box_nonstandard_all_xx(): void + { + foreach (['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'] as $part) { + $this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix($part, false)); + } + } + + // ───────────────────────────────────────────────── + // 연기차단재 Prefix + // ───────────────────────────────────────────────── + + public function test_smoke_barrier_always_gi(): void + { + $this->assertEquals('GI', $this->resolver->resolveSmokeBarrierPrefix()); + } + + // ───────────────────────────────────────────────── + // lengthToCode 변환 + // ───────────────────────────────────────────────── + + /** + * @dataProvider lengthCodeProvider + */ + public function test_length_to_code(int $lengthMm, ?string $smokeCategory, ?string $expected): void + { + $this->assertSame($expected, PrefixResolver::lengthToCode($lengthMm, $smokeCategory)); + } + + public static function lengthCodeProvider(): array + { + return [ + '1219 → 12' => [1219, null, '12'], + '2438 → 24' => [2438, null, '24'], + '3000 → 30' => [3000, null, '30'], + '3500 → 35' => [3500, null, '35'], + '4000 → 40' => [4000, null, '40'], + '4150 → 41' => [4150, null, '41'], + '4200 → 42' => [4200, null, '42'], + '4300 → 43' => [4300, null, '43'], + 'smoke w50 3000 → 53' => [3000, 'w50', '53'], + 'smoke w50 4000 → 54' => [4000, 'w50', '54'], + 'smoke w80 3000 → 83' => [3000, 'w80', '83'], + 'smoke w80 4000 → 84' => [4000, 'w80', '84'], + 'unknown length → null' => [9999, null, null], + ]; + } + + // ───────────────────────────────────────────────── + // buildItemCode + // ───────────────────────────────────────────────── + + public function test_build_item_code(): void + { + $this->assertEquals('BD-RS-43', $this->resolver->buildItemCode('RS', 4300)); + $this->assertEquals('BD-RM-30', $this->resolver->buildItemCode('RM', 3000)); + $this->assertEquals('BD-GI-53', $this->resolver->buildItemCode('GI', 3000, 'w50')); + $this->assertEquals('BD-GI-84', $this->resolver->buildItemCode('GI', 4000, 'w80')); + } + + public function test_build_item_code_invalid_length_returns_null(): void + { + $this->assertNull($this->resolver->buildItemCode('RS', 9999)); + } + + // ───────────────────────────────────────────────── + // partTypeName + // ───────────────────────────────────────────────── + + public function test_part_type_name(): void + { + $this->assertEquals('마감재', PrefixResolver::partTypeName('finish')); + $this->assertEquals('본체', PrefixResolver::partTypeName('body')); + $this->assertEquals('C형', PrefixResolver::partTypeName('c_type')); + $this->assertEquals('D형', PrefixResolver::partTypeName('d_type')); + $this->assertEquals('별도마감', PrefixResolver::partTypeName('extra_finish')); + $this->assertEquals('하부BASE', PrefixResolver::partTypeName('base')); + $this->assertEquals('L-Bar', PrefixResolver::partTypeName('lbar')); + $this->assertEquals('보강평철', PrefixResolver::partTypeName('reinforce')); + $this->assertEquals('전면부', PrefixResolver::partTypeName('front')); + $this->assertEquals('unknown_type', PrefixResolver::partTypeName('unknown_type')); + } + + // ───────────────────────────────────────────────── + // 전체 조합 커버리지 (productCode × guideType × partType) + // ───────────────────────────────────────────────── + + public function test_all_product_code_guide_type_combinations_produce_non_empty_prefix(): void + { + $productCodes = ['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01']; + $guideTypes = ['wall', 'side']; + $partTypes = ['finish', 'body', 'c_type', 'd_type', 'base']; + + foreach ($productCodes as $code) { + foreach ($guideTypes as $guide) { + foreach ($partTypes as $part) { + $prefix = $this->resolver->resolveGuideRailPrefix($part, $guide, $code); + $this->assertNotEmpty( + $prefix, + "Empty prefix for {$code}/{$guide}/{$part}" + ); + $this->assertMatchesRegularExpression( + '/^[A-Z]{2}$/', + $prefix, + "Invalid prefix '{$prefix}' for {$code}/{$guide}/{$part}" + ); + } + } + } + } +}