feat(WEB): 절곡 자재투입 LOT 매핑 파이프라인 구현

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 02:13:08 +09:00
parent 9c88138de8
commit 5a3d6c2243
8 changed files with 1625 additions and 33 deletions

View File

@@ -0,0 +1,278 @@
<?php
namespace Tests\Feature\Production;
use App\DTOs\Production\DynamicBomEntry;
use App\Services\Production\PrefixResolver;
use App\Services\WorkOrderService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* 절곡 자재투입 LOT 매핑 파이프라인 통합 테스트
*
* getMaterials() → dynamic_bom 우선 체크 → 세부품목 반환 → 자재투입 플로우 검증
*
* 실행 조건: Docker 환경 + 로컬 DB 접속 필요
*/
class BendingLotPipelineTest extends TestCase
{
use DatabaseTransactions;
private const TENANT_ID = 287;
private PrefixResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->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)
);
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace Tests\Unit\Production;
use App\DTOs\Production\DynamicBomEntry;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class DynamicBomEntryTest extends TestCase
{
private function validData(): array
{
return [
'child_item_id' => 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);
}
}

View File

@@ -0,0 +1,263 @@
<?php
namespace Tests\Unit\Production;
use App\Services\Production\PrefixResolver;
use PHPUnit\Framework\TestCase;
class PrefixResolverTest extends TestCase
{
private PrefixResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->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}"
);
}
}
}
}
}