- 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>
279 lines
11 KiB
PHP
279 lines
11 KiB
PHP
<?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)
|
||
);
|
||
}
|
||
}
|