Files
sam-api/tests/Feature/Production/BendingLotPipelineTest.php
권혁성 5a3d6c2243 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>
2026-02-22 04:19:47 +09:00

279 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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