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:
173
tests/Unit/Production/DynamicBomEntryTest.php
Normal file
173
tests/Unit/Production/DynamicBomEntryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
263
tests/Unit/Production/PrefixResolverTest.php
Normal file
263
tests/Unit/Production/PrefixResolverTest.php
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user