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