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:
101
app/DTOs/Production/DynamicBomEntry.php
Normal file
101
app/DTOs/Production/DynamicBomEntry.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs\Production;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* dynamic_bom JSON 항목 DTO
|
||||
*
|
||||
* work_order_items.options.dynamic_bom 배열의 각 엔트리를 표현
|
||||
*/
|
||||
class DynamicBomEntry
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $child_item_id,
|
||||
public readonly string $child_item_code,
|
||||
public readonly string $lot_prefix,
|
||||
public readonly string $part_type,
|
||||
public readonly string $category,
|
||||
public readonly string $material_type,
|
||||
public readonly int $length_mm,
|
||||
public readonly int|float $qty,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 배열에서 DTO 생성
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
self::validate($data);
|
||||
|
||||
return new self(
|
||||
child_item_id: (int) $data['child_item_id'],
|
||||
child_item_code: (string) $data['child_item_code'],
|
||||
lot_prefix: (string) $data['lot_prefix'],
|
||||
part_type: (string) $data['part_type'],
|
||||
category: (string) $data['category'],
|
||||
material_type: (string) $data['material_type'],
|
||||
length_mm: (int) $data['length_mm'],
|
||||
qty: $data['qty'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO → 배열 변환 (JSON 저장용)
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'child_item_id' => $this->child_item_id,
|
||||
'child_item_code' => $this->child_item_code,
|
||||
'lot_prefix' => $this->lot_prefix,
|
||||
'part_type' => $this->part_type,
|
||||
'category' => $this->category,
|
||||
'material_type' => $this->material_type,
|
||||
'length_mm' => $this->length_mm,
|
||||
'qty' => $this->qty,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 필수 필드 검증
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function validate(array $data): bool
|
||||
{
|
||||
$required = ['child_item_id', 'child_item_code', 'lot_prefix', 'part_type', 'category', 'material_type', 'length_mm', 'qty'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (! array_key_exists($field, $data) || $data[$field] === null) {
|
||||
throw new InvalidArgumentException("DynamicBomEntry: '{$field}' is required");
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) $data['child_item_id'] <= 0) {
|
||||
throw new InvalidArgumentException('DynamicBomEntry: child_item_id must be positive');
|
||||
}
|
||||
|
||||
$validCategories = ['guideRail', 'bottomBar', 'shutterBox', 'smokeBarrier'];
|
||||
if (! in_array($data['category'], $validCategories, true)) {
|
||||
throw new InvalidArgumentException('DynamicBomEntry: category must be one of: '.implode(', ', $validCategories));
|
||||
}
|
||||
|
||||
if ($data['qty'] <= 0) {
|
||||
throw new InvalidArgumentException('DynamicBomEntry: qty must be positive');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicBomEntry 배열 → JSON 저장용 배열 변환
|
||||
*
|
||||
* @param DynamicBomEntry[] $entries
|
||||
*/
|
||||
public static function toArrayList(array $entries): array
|
||||
{
|
||||
return array_map(fn (self $e) => $e->toArray(), $entries);
|
||||
}
|
||||
}
|
||||
@@ -1336,9 +1336,9 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
|
||||
if ($bendingInfo) {
|
||||
$workOrderOptions = ['bending_info' => $bendingInfo];
|
||||
$buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
|
||||
if ($buildResult) {
|
||||
$workOrderOptions = ['bending_info' => $buildResult['bending_info']];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1405,17 +1405,33 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
$slatInfo['joint_bar'] = (2 + (int) floor(((float) $woWidth - 500) / 1000)) * $qty;
|
||||
}
|
||||
|
||||
$woHeight = $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null;
|
||||
|
||||
$woItemOptions = array_filter([
|
||||
'floor' => $orderItem->floor_code,
|
||||
'code' => $orderItem->symbol_code,
|
||||
'width' => $woWidth,
|
||||
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
|
||||
'height' => $woHeight,
|
||||
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
|
||||
'slat_info' => $slatInfo,
|
||||
'bending_info' => $nodeOptions['bending_info'] ?? null,
|
||||
'wip_info' => $nodeOptions['wip_info'] ?? null,
|
||||
], fn ($v) => $v !== null);
|
||||
|
||||
// 절곡 공정: 개소별 dynamic_bom 생성
|
||||
if (! empty($buildResult['context']) && $woWidth && $woHeight) {
|
||||
$dynamicBom = app(BendingInfoBuilder::class)->buildDynamicBomForItem(
|
||||
$buildResult['context'],
|
||||
(int) $woWidth,
|
||||
(int) $woHeight,
|
||||
(int) ($orderItem->quantity ?? 1),
|
||||
$tenantId,
|
||||
);
|
||||
if (! empty($dynamicBom)) {
|
||||
$woItemOptions['dynamic_bom'] = $dynamicBom;
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('work_order_items')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'work_order_id' => $workOrder->id,
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
namespace App\Services\Production;
|
||||
|
||||
use App\DTOs\Production\DynamicBomEntry;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Process;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 수주 → 생산지시 시 절곡 공정용 bending_info JSON 자동 생성
|
||||
*
|
||||
* 입력: Order (rootNodes eager loaded) + processId
|
||||
* 출력: BendingInfoExtended 구조의 array (work_orders.options.bending_info에 저장)
|
||||
* 출력: ['bending_info' => array, 'context' => array] — bending_info + dynamic_bom 생성 컨텍스트
|
||||
*
|
||||
* @see react/src/components/production/WorkOrders/documents/bending/types.ts
|
||||
*/
|
||||
@@ -18,6 +20,7 @@ class BendingInfoBuilder
|
||||
{
|
||||
// 표준 원자재 길이 버킷 (5130 레거시 write_form.php 기준)
|
||||
private const GUIDE_RAIL_LENGTHS = [2438, 3000, 3500, 4000, 4300];
|
||||
|
||||
private const SHUTTER_BOX_LENGTHS = [1219, 2438, 3000, 3500, 4000, 4150];
|
||||
|
||||
/**
|
||||
@@ -65,7 +68,307 @@ public function build(Order $order, int $processId, ?array $nodeIds = null): ?ar
|
||||
$aggregated = $this->aggregateNodes($nodes);
|
||||
|
||||
// 6. bending_info 조립
|
||||
return $this->assembleBendingInfo($productInfo, $materials, $aggregated);
|
||||
$bendingInfo = $this->assembleBendingInfo($productInfo, $materials, $aggregated);
|
||||
|
||||
// 7. 셔터박스 크기 추출 (dynamic_bom 컨텍스트용)
|
||||
$caseBom = $aggregated['bomCategories']['shutterBox_case'] ?? null;
|
||||
$motorBom = $aggregated['bomCategories']['motor'] ?? null;
|
||||
$boxSize = null;
|
||||
if ($caseBom) {
|
||||
$motorCapacity = $this->extractMotorCapacity($motorBom);
|
||||
$boxSize = $motorCapacity ? $this->getShutterBoxSize($motorCapacity) : null;
|
||||
if (! $boxSize) {
|
||||
$boxSize = str_replace('BD-케이스-', '', $caseBom['item_code'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'bending_info' => $bendingInfo,
|
||||
'context' => [
|
||||
'productCode' => $productInfo['productCode'],
|
||||
'guideType' => $productInfo['guideType'],
|
||||
'finishMaterial' => $productInfo['finishMaterial'],
|
||||
'materials' => $materials,
|
||||
'boxSize' => $boxSize,
|
||||
'hasSmokeRail' => isset($aggregated['bomCategories']['smokeBarrier_rail']),
|
||||
'hasSmokeCase' => isset($aggregated['bomCategories']['smokeBarrier_case']),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 개소(work_order_item) 단위 dynamic_bom 생성
|
||||
*
|
||||
* @param array $context build() 반환값의 'context'
|
||||
* @param int $width 개소의 오픈폭 (mm)
|
||||
* @param int $height 개소의 오픈높이 (mm)
|
||||
* @param int $qty 개소 수량
|
||||
* @param int $tenantId 테넌트 ID (item 조회용)
|
||||
* @return array DynamicBomEntry::toArray() 배열
|
||||
*/
|
||||
public function buildDynamicBomForItem(array $context, int $width, int $height, int $qty, int $tenantId = 287): array
|
||||
{
|
||||
$resolver = new PrefixResolver;
|
||||
$entries = [];
|
||||
|
||||
$productCode = $context['productCode'];
|
||||
$guideType = $context['guideType'];
|
||||
$finishMaterial = $context['finishMaterial'];
|
||||
$materials = $context['materials'];
|
||||
$boxSize = $context['boxSize'];
|
||||
$hasExtraFinish = ! empty($materials['guideRailExtraFinish']);
|
||||
|
||||
// ─── 1. 가이드레일 세부품목 ───
|
||||
$dimGroups = [['height' => $height, 'width' => $width, 'qty' => $qty]];
|
||||
$heightData = $this->heightLengthData($dimGroups);
|
||||
|
||||
// 가이드레일은 개구부 양쪽 2개이므로 수량 ×2
|
||||
foreach ($heightData as &$entry) {
|
||||
$entry['quantity'] *= 2;
|
||||
}
|
||||
unset($entry);
|
||||
|
||||
$guideTypes = match ($guideType) {
|
||||
'혼합형' => ['wall', 'side'],
|
||||
'측면형' => ['side'],
|
||||
default => ['wall'],
|
||||
};
|
||||
|
||||
$guidePartTypes = ['finish', 'body', 'c_type', 'd_type'];
|
||||
if ($hasExtraFinish) {
|
||||
$guidePartTypes[] = 'extra_finish';
|
||||
}
|
||||
$guidePartTypes[] = 'base';
|
||||
|
||||
foreach ($guideTypes as $gType) {
|
||||
foreach ($heightData as $ld) {
|
||||
foreach ($guidePartTypes as $partType) {
|
||||
$prefix = $resolver->resolveGuideRailPrefix($partType, $gType, $productCode);
|
||||
if (empty($prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemCode = $resolver->buildItemCode($prefix, $ld['length']);
|
||||
if (! $itemCode) {
|
||||
Log::warning('BendingInfoBuilder: lengthCode 변환 실패', ['prefix' => $prefix, 'length' => $ld['length']]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
|
||||
if (! $itemId) {
|
||||
Log::warning('BendingInfoBuilder: 미등록 품목', ['code' => $itemCode]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = new DynamicBomEntry(
|
||||
child_item_id: $itemId,
|
||||
child_item_code: $itemCode,
|
||||
lot_prefix: $prefix,
|
||||
part_type: PrefixResolver::partTypeName($partType),
|
||||
category: 'guideRail',
|
||||
material_type: $this->resolvePartMaterial($partType, $gType, $materials),
|
||||
length_mm: $ld['length'],
|
||||
qty: $ld['quantity'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. 하단마감재 세부품목 ───
|
||||
[$qty3000, $qty4000] = $this->bottomBarDistribution($width);
|
||||
$bottomLengths = array_filter([3000 => $qty3000 * $qty, 4000 => $qty4000 * $qty]);
|
||||
|
||||
$bottomPartTypes = ['main', 'lbar', 'reinforce'];
|
||||
$hasBottomExtra = ! empty($materials['bottomBarExtraFinish']) && $materials['bottomBarExtraFinish'] !== '없음';
|
||||
if ($hasBottomExtra) {
|
||||
$bottomPartTypes[] = 'extra';
|
||||
}
|
||||
|
||||
foreach ($bottomLengths as $length => $lengthQty) {
|
||||
if ($lengthQty <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach ($bottomPartTypes as $partType) {
|
||||
$prefix = $resolver->resolveBottomBarPrefix($partType, $productCode, $finishMaterial);
|
||||
$itemCode = $resolver->buildItemCode($prefix, $length);
|
||||
if (! $itemCode) {
|
||||
continue;
|
||||
}
|
||||
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
|
||||
if (! $itemId) {
|
||||
Log::warning('BendingInfoBuilder: 미등록 하단마감재', ['code' => $itemCode]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = new DynamicBomEntry(
|
||||
child_item_id: $itemId,
|
||||
child_item_code: $itemCode,
|
||||
lot_prefix: $prefix,
|
||||
part_type: PrefixResolver::partTypeName($partType),
|
||||
category: 'bottomBar',
|
||||
material_type: $materials['bottomBarFinish'],
|
||||
length_mm: $length,
|
||||
qty: $lengthQty,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3. 셔터박스 세부품목 ───
|
||||
if ($boxSize) {
|
||||
$isStandard = $boxSize === '500*380';
|
||||
$dist = $this->shutterBoxDistribution($width);
|
||||
$shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'];
|
||||
|
||||
foreach ($dist as $length => $count) {
|
||||
$totalCount = $count * $qty;
|
||||
if ($totalCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach ($shutterPartTypes as $partType) {
|
||||
$prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard);
|
||||
$itemCode = $resolver->buildItemCode($prefix, $length);
|
||||
if (! $itemCode) {
|
||||
continue;
|
||||
}
|
||||
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
|
||||
if (! $itemId) {
|
||||
Log::warning('BendingInfoBuilder: 미등록 셔터박스', ['code' => $itemCode]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = new DynamicBomEntry(
|
||||
child_item_id: $itemId,
|
||||
child_item_code: $itemCode,
|
||||
lot_prefix: $prefix,
|
||||
part_type: PrefixResolver::partTypeName($partType),
|
||||
category: 'shutterBox',
|
||||
material_type: 'EGI',
|
||||
length_mm: $length,
|
||||
qty: $totalCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위)
|
||||
$coverQty = (int) ceil($width / 1219) * $qty;
|
||||
if ($coverQty > 0) {
|
||||
$coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard);
|
||||
$coverCode = $resolver->buildItemCode($coverPrefix, 1219);
|
||||
if ($coverCode) {
|
||||
$coverId = $resolver->resolveItemId($coverCode, $tenantId);
|
||||
if ($coverId) {
|
||||
$entries[] = new DynamicBomEntry(
|
||||
child_item_id: $coverId,
|
||||
child_item_code: $coverCode,
|
||||
lot_prefix: $coverPrefix,
|
||||
part_type: PrefixResolver::partTypeName('top_cover'),
|
||||
category: 'shutterBox',
|
||||
material_type: 'EGI',
|
||||
length_mm: 1219,
|
||||
qty: $coverQty,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 마구리 수량: qty × 2
|
||||
$finQty = $qty * 2;
|
||||
if ($finQty > 0) {
|
||||
$finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard);
|
||||
// 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용
|
||||
$finCode = $resolver->buildItemCode($finPrefix, 1219);
|
||||
if ($finCode) {
|
||||
$finId = $resolver->resolveItemId($finCode, $tenantId);
|
||||
if ($finId) {
|
||||
$entries[] = new DynamicBomEntry(
|
||||
child_item_id: $finId,
|
||||
child_item_code: $finCode,
|
||||
lot_prefix: $finPrefix,
|
||||
part_type: PrefixResolver::partTypeName('fin_cover'),
|
||||
category: 'shutterBox',
|
||||
material_type: 'EGI',
|
||||
length_mm: 1219,
|
||||
qty: $finQty,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. 연기차단재 세부품목 ───
|
||||
if ($context['hasSmokeRail'] || $context['hasSmokeCase']) {
|
||||
$smokePrefix = $resolver->resolveSmokeBarrierPrefix();
|
||||
|
||||
// W50 (레일용): open_height + 250 → 표준 길이
|
||||
if ($context['hasSmokeRail']) {
|
||||
$col24 = $height + 250;
|
||||
$w50Length = $this->bucketToStandardLength($col24, [2438, 3000, 3500, 4000, 4300]);
|
||||
if ($w50Length && $col24 <= 4300) {
|
||||
$w50Code = $resolver->buildItemCode($smokePrefix, $w50Length, 'w50');
|
||||
if ($w50Code) {
|
||||
$w50Id = $resolver->resolveItemId($w50Code, $tenantId);
|
||||
if ($w50Id) {
|
||||
$entries[] = new DynamicBomEntry(
|
||||
child_item_id: $w50Id,
|
||||
child_item_code: $w50Code,
|
||||
lot_prefix: $smokePrefix,
|
||||
part_type: '연기차단재(W50)',
|
||||
category: 'smokeBarrier',
|
||||
material_type: 'GI',
|
||||
length_mm: $w50Length,
|
||||
qty: 2 * $qty,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// W80 (케이스용): floor((width+240)*2/3000 + 1) × qty
|
||||
if ($context['hasSmokeCase']) {
|
||||
$col38 = $width + 240;
|
||||
$w80PerNode = (int) floor(($col38 * 2 / 3000) + 1);
|
||||
$w80Qty = $w80PerNode * $qty;
|
||||
if ($w80Qty > 0) {
|
||||
// W80은 3000mm 기본 (레거시 동일)
|
||||
$w80Code = $resolver->buildItemCode($smokePrefix, 3000, 'w80');
|
||||
if ($w80Code) {
|
||||
$w80Id = $resolver->resolveItemId($w80Code, $tenantId);
|
||||
if ($w80Id) {
|
||||
$entries[] = new DynamicBomEntry(
|
||||
child_item_id: $w80Id,
|
||||
child_item_code: $w80Code,
|
||||
lot_prefix: $smokePrefix,
|
||||
part_type: '연기차단재(W80)',
|
||||
category: 'smokeBarrier',
|
||||
material_type: 'GI',
|
||||
length_mm: 3000,
|
||||
qty: $w80Qty,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DynamicBomEntry::toArrayList($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트타입 + 가이드타입 → 실제 재질 결정
|
||||
*/
|
||||
private function resolvePartMaterial(string $partType, string $guideType, array $materials): string
|
||||
{
|
||||
return match ($partType) {
|
||||
'finish' => $materials['guideRailFinish'],
|
||||
'extra_finish' => $materials['guideRailExtraFinish'],
|
||||
'body', 'c_type', 'd_type' => $materials['bodyMaterial'],
|
||||
'base' => 'EGI',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
307
app/Services/Production/PrefixResolver.php
Normal file
307
app/Services/Production/PrefixResolver.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Production;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 절곡 세부품목 LOT Prefix 결정 및 BD-XX-NN 코드 생성
|
||||
*
|
||||
* 제품코드 + 마감재질 + 가이드타입 → LOT prefix → BD-XX-NN 코드 → items.id
|
||||
*/
|
||||
class PrefixResolver
|
||||
{
|
||||
// ─────────────────────────────────────────────────
|
||||
// 가이드레일 Prefix 맵
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/** 벽면형(Wall) prefix: partType → prefix (finish는 productCode별 분기) */
|
||||
private const WALL_PREFIXES = [
|
||||
'finish' => ['KSS' => 'RS', 'KQTS' => 'RS', 'KSE' => 'RE', 'KWE' => 'RE', 'KTE' => 'RS'],
|
||||
'body' => 'RM',
|
||||
'c_type' => 'RC',
|
||||
'd_type' => 'RD',
|
||||
'extra_finish' => 'YY',
|
||||
'base' => 'XX',
|
||||
];
|
||||
|
||||
/** 측면형(Side) prefix */
|
||||
private const SIDE_PREFIXES = [
|
||||
'finish' => ['KSS' => 'SS', 'KQTS' => 'SS', 'KSE' => 'SE', 'KWE' => 'SE', 'KTE' => 'SS'],
|
||||
'body' => 'SM',
|
||||
'c_type' => 'SC',
|
||||
'd_type' => 'SD',
|
||||
'extra_finish' => 'YY',
|
||||
'base' => 'XX',
|
||||
];
|
||||
|
||||
/** 철재(KTE01) body 오버라이드 */
|
||||
private const STEEL_BODY_OVERRIDES = [
|
||||
'wall' => 'RT',
|
||||
'side' => 'ST',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 하단마감재 Prefix 맵
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/** 하단마감재 main prefix: finishMaterial 기반 */
|
||||
private const BOTTOM_BAR_MAIN = [
|
||||
'EGI' => 'BE',
|
||||
'SUS' => 'BS',
|
||||
'STEEL' => 'TS',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 셔터박스 Prefix 맵
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/** 표준 사이즈(500*380) 셔터박스 prefix */
|
||||
private const SHUTTER_STANDARD = [
|
||||
'front' => 'CF',
|
||||
'lintel' => 'CL',
|
||||
'inspection' => 'CP',
|
||||
'rear_corner' => 'CB',
|
||||
'top_cover' => 'XX',
|
||||
'fin_cover' => 'XX',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 길이코드 매핑
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
private const LENGTH_TO_CODE = [
|
||||
1219 => '12',
|
||||
2438 => '24',
|
||||
3000 => '30',
|
||||
3500 => '35',
|
||||
4000 => '40',
|
||||
4150 => '41',
|
||||
4200 => '42',
|
||||
4300 => '43',
|
||||
];
|
||||
|
||||
/** 연기차단재 전용 길이코드 */
|
||||
private const SMOKE_LENGTH_TO_CODE = [
|
||||
'w50' => [3000 => '53', 4000 => '54'],
|
||||
'w80' => [3000 => '83', 4000 => '84'],
|
||||
];
|
||||
|
||||
/** 파트타입 한글명 */
|
||||
private const PART_TYPE_NAMES = [
|
||||
'finish' => '마감재',
|
||||
'body' => '본체',
|
||||
'c_type' => 'C형',
|
||||
'd_type' => 'D형',
|
||||
'extra_finish' => '별도마감',
|
||||
'base' => '하부BASE',
|
||||
'main' => '메인',
|
||||
'lbar' => 'L-Bar',
|
||||
'reinforce' => '보강평철',
|
||||
'extra' => '별도마감',
|
||||
'front' => '전면부',
|
||||
'lintel' => '린텔부',
|
||||
'inspection' => '점검구',
|
||||
'rear_corner' => '후면코너부',
|
||||
'top_cover' => '상부덮개',
|
||||
'fin_cover' => '마구리',
|
||||
'smoke' => '연기차단재',
|
||||
];
|
||||
|
||||
/** items.id 캐시: code → id */
|
||||
private array $itemIdCache = [];
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 가이드레일
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 가이드레일 세부품목의 prefix 결정
|
||||
*
|
||||
* @param string $partType 'finish', 'body', 'c_type', 'd_type', 'extra_finish', 'base'
|
||||
* @param string $guideType 'wall', 'side'
|
||||
* @param string $productCode 'KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'
|
||||
* @return string prefix (빈 문자열이면 해당 파트 없음)
|
||||
*/
|
||||
public function resolveGuideRailPrefix(string $partType, string $guideType, string $productCode): string
|
||||
{
|
||||
$prefixMap = $guideType === 'wall' ? self::WALL_PREFIXES : self::SIDE_PREFIXES;
|
||||
$codePrefix = $this->extractCodePrefix($productCode);
|
||||
$isSteel = $codePrefix === 'KTE';
|
||||
|
||||
// body: 철재 오버라이드
|
||||
if ($partType === 'body' && $isSteel) {
|
||||
return self::STEEL_BODY_OVERRIDES[$guideType] ?? '';
|
||||
}
|
||||
|
||||
// finish: productCode별 분기
|
||||
if ($partType === 'finish') {
|
||||
$finishMap = $prefixMap['finish'] ?? [];
|
||||
|
||||
return $finishMap[$codePrefix] ?? '';
|
||||
}
|
||||
|
||||
// extra_finish, base, c_type, d_type, body: 고정 prefix
|
||||
return $prefixMap[$partType] ?? '';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 하단마감재
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 하단마감재 세부품목의 prefix 결정
|
||||
*
|
||||
* @param string $partType 'main', 'lbar', 'reinforce', 'extra'
|
||||
* @param string $productCode 'KSS01', 'KSE01', etc.
|
||||
* @param string $finishMaterial 'EGI마감', 'SUS마감'
|
||||
* @return string prefix
|
||||
*/
|
||||
public function resolveBottomBarPrefix(string $partType, string $productCode, string $finishMaterial): string
|
||||
{
|
||||
if ($partType === 'lbar') {
|
||||
return 'LA';
|
||||
}
|
||||
if ($partType === 'reinforce') {
|
||||
return 'HH';
|
||||
}
|
||||
if ($partType === 'extra') {
|
||||
return 'YY';
|
||||
}
|
||||
|
||||
// main: 재질 기반
|
||||
$codePrefix = $this->extractCodePrefix($productCode);
|
||||
$isSteel = $codePrefix === 'KTE';
|
||||
|
||||
if ($isSteel) {
|
||||
return 'TS';
|
||||
}
|
||||
|
||||
$isSUS = in_array($codePrefix, ['KSS', 'KQTS']);
|
||||
|
||||
return $isSUS ? 'BS' : 'BE';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 셔터박스
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 셔터박스 세부품목의 prefix 결정
|
||||
*
|
||||
* @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'
|
||||
* @param bool $isStandardSize 500*380인지
|
||||
* @return string prefix
|
||||
*/
|
||||
public function resolveShutterBoxPrefix(string $partType, bool $isStandardSize): string
|
||||
{
|
||||
if (! $isStandardSize) {
|
||||
return 'XX';
|
||||
}
|
||||
|
||||
return self::SHUTTER_STANDARD[$partType] ?? 'XX';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 연기차단재
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 연기차단재 세부품목의 prefix 결정 (항상 GI)
|
||||
*/
|
||||
public function resolveSmokeBarrierPrefix(): string
|
||||
{
|
||||
return 'GI';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// 코드 생성 및 조회
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* prefix + 길이(mm) → BD-XX-NN 코드 생성
|
||||
*
|
||||
* @param string $prefix LOT prefix (RS, RM, etc.)
|
||||
* @param int $lengthMm 길이 (mm)
|
||||
* @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80')
|
||||
* @return string|null BD 코드 (길이코드 변환 실패 시 null)
|
||||
*/
|
||||
public function buildItemCode(string $prefix, int $lengthMm, ?string $smokeCategory = null): ?string
|
||||
{
|
||||
$lengthCode = self::lengthToCode($lengthMm, $smokeCategory);
|
||||
if ($lengthCode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "BD-{$prefix}-{$lengthCode}";
|
||||
}
|
||||
|
||||
/**
|
||||
* BD-XX-NN 코드 → items.id 조회 (캐시)
|
||||
*
|
||||
* @return int|null items.id (미등록 시 null)
|
||||
*/
|
||||
public function resolveItemId(string $itemCode, int $tenantId = 287): ?int
|
||||
{
|
||||
$cacheKey = "{$tenantId}:{$itemCode}";
|
||||
|
||||
if (isset($this->itemIdCache[$cacheKey])) {
|
||||
return $this->itemIdCache[$cacheKey];
|
||||
}
|
||||
|
||||
$id = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $itemCode)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
$this->itemIdCache[$cacheKey] = $id;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 길이(mm) → 길이코드 변환
|
||||
*
|
||||
* @param int $lengthMm 길이 (mm)
|
||||
* @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80')
|
||||
* @return string|null 길이코드 (변환 불가 시 null)
|
||||
*/
|
||||
public static function lengthToCode(int $lengthMm, ?string $smokeCategory = null): ?string
|
||||
{
|
||||
// 연기차단재 전용 코드
|
||||
if ($smokeCategory && isset(self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm])) {
|
||||
return self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm];
|
||||
}
|
||||
|
||||
return self::LENGTH_TO_CODE[$lengthMm] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트타입 한글명 반환
|
||||
*/
|
||||
public static function partTypeName(string $partType): string
|
||||
{
|
||||
return self::PART_TYPE_NAMES[$partType] ?? $partType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화 (테스트 용)
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->itemIdCache = [];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// private
|
||||
// ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 'KSS01' → 'KSS', 'KQTS01' → 'KQTS' 등 제품코드 prefix 추출
|
||||
*/
|
||||
private function extractCodePrefix(string $productCode): string
|
||||
{
|
||||
return preg_replace('/\d+$/', '', $productCode);
|
||||
}
|
||||
}
|
||||
@@ -1192,13 +1192,67 @@ public function getMaterials(int $workOrderId): array
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산)
|
||||
// ── Step 1: dynamic_bom 대상 item_id 일괄 수집 (N+1 방지) ──
|
||||
$allDynamicItemIds = [];
|
||||
foreach ($workOrder->items as $woItem) {
|
||||
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
|
||||
$dynamicBom = $options['dynamic_bom'] ?? null;
|
||||
if ($dynamicBom && is_array($dynamicBom)) {
|
||||
$allDynamicItemIds = array_merge($allDynamicItemIds, array_column($dynamicBom, 'child_item_id'));
|
||||
}
|
||||
}
|
||||
|
||||
// 배치 조회 (dynamic_bom 품목)
|
||||
$dynamicItems = [];
|
||||
if (! empty($allDynamicItemIds)) {
|
||||
$dynamicItems = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', array_unique($allDynamicItemIds))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
}
|
||||
|
||||
// ── Step 2: 유니크 자재 목록 수집 ──
|
||||
// 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}"
|
||||
$uniqueMaterials = [];
|
||||
|
||||
foreach ($workOrder->items as $woItem) {
|
||||
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
|
||||
$dynamicBom = $options['dynamic_bom'] ?? null;
|
||||
|
||||
// dynamic_bom 우선 — 있으면 BOM 무시
|
||||
if ($dynamicBom && is_array($dynamicBom)) {
|
||||
foreach ($dynamicBom as $bomEntry) {
|
||||
$childItemId = $bomEntry['child_item_id'] ?? null;
|
||||
if (! $childItemId || ! isset($dynamicItems[$childItemId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 합산 키: (item_id, work_order_item_id) 쌍
|
||||
$key = $childItemId.'_'.$woItem->id;
|
||||
$bomQty = (float) ($bomEntry['qty'] ?? 1);
|
||||
$requiredQty = $bomQty * ($woItem->quantity ?? 1);
|
||||
|
||||
if (isset($uniqueMaterials[$key])) {
|
||||
$uniqueMaterials[$key]['required_qty'] += $requiredQty;
|
||||
} else {
|
||||
$uniqueMaterials[$key] = [
|
||||
'item' => $dynamicItems[$childItemId],
|
||||
'bom_qty' => $bomQty,
|
||||
'required_qty' => $requiredQty,
|
||||
'work_order_item_id' => $woItem->id,
|
||||
'lot_prefix' => $bomEntry['lot_prefix'] ?? null,
|
||||
'part_type' => $bomEntry['part_type'] ?? null,
|
||||
'category' => $bomEntry['category'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀
|
||||
}
|
||||
|
||||
// 기존 BOM 로직 (하위 호환)
|
||||
$materialItems = [];
|
||||
|
||||
// BOM이 있으면 자식 품목들을 자재로 사용
|
||||
if ($woItem->item_id) {
|
||||
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->find($woItem->item_id);
|
||||
@@ -1237,7 +1291,7 @@ public function getMaterials(int $workOrderId): array
|
||||
];
|
||||
}
|
||||
|
||||
// 유니크 자재 수집 (같은 item_id면 required_qty 합산)
|
||||
// 기존 방식: item_id 기준 합산
|
||||
foreach ($materialItems as $matInfo) {
|
||||
$itemId = $matInfo['item']->id;
|
||||
if (isset($uniqueMaterials[$itemId])) {
|
||||
@@ -1248,30 +1302,67 @@ public function getMaterials(int $workOrderId): array
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: 유니크 자재별로 StockLot 조회
|
||||
// ── Step 3: 유니크 자재별로 StockLot 조회 ──
|
||||
// 배치 조회를 위해 전체 item_id 수집
|
||||
$allItemIds = [];
|
||||
foreach ($uniqueMaterials as $matInfo) {
|
||||
$allItemIds[] = $matInfo['item']->id;
|
||||
}
|
||||
$allItemIds = array_unique($allItemIds);
|
||||
|
||||
// Stock 배치 조회 (N+1 방지)
|
||||
$stockMap = [];
|
||||
if (! empty($allItemIds)) {
|
||||
$stocks = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
|
||||
->whereIn('item_id', $allItemIds)
|
||||
->get();
|
||||
|
||||
foreach ($stocks as $stock) {
|
||||
$stockMap[$stock->item_id] = $stock;
|
||||
}
|
||||
}
|
||||
|
||||
// StockLot 배치 조회 (N+1 방지)
|
||||
$lotsByStockId = [];
|
||||
$stockIds = array_map(fn ($s) => $s->id, $stockMap);
|
||||
if (! empty($stockIds)) {
|
||||
$allLots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
|
||||
->whereIn('stock_id', $stockIds)
|
||||
->where('status', 'available')
|
||||
->where('available_qty', '>', 0)
|
||||
->orderBy('fifo_order', 'asc')
|
||||
->get();
|
||||
|
||||
foreach ($allLots as $lot) {
|
||||
$lotsByStockId[$lot->stock_id][] = $lot;
|
||||
}
|
||||
}
|
||||
|
||||
$materials = [];
|
||||
$rank = 1;
|
||||
|
||||
foreach ($uniqueMaterials as $matInfo) {
|
||||
$materialItem = $matInfo['item'];
|
||||
|
||||
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
|
||||
->where('item_id', $materialItem->id)
|
||||
->first();
|
||||
|
||||
$stock = $stockMap[$materialItem->id] ?? null;
|
||||
$lotsFound = false;
|
||||
|
||||
// 공통 필드 (dynamic_bom 추가 필드 포함)
|
||||
$extraFields = [];
|
||||
if (isset($matInfo['work_order_item_id'])) {
|
||||
$extraFields = [
|
||||
'work_order_item_id' => $matInfo['work_order_item_id'],
|
||||
'lot_prefix' => $matInfo['lot_prefix'],
|
||||
'part_type' => $matInfo['part_type'],
|
||||
'category' => $matInfo['category'],
|
||||
];
|
||||
}
|
||||
|
||||
if ($stock) {
|
||||
$lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
|
||||
->where('stock_id', $stock->id)
|
||||
->where('status', 'available')
|
||||
->where('available_qty', '>', 0)
|
||||
->orderBy('fifo_order', 'asc')
|
||||
->get();
|
||||
$lots = $lotsByStockId[$stock->id] ?? [];
|
||||
|
||||
foreach ($lots as $lot) {
|
||||
$lotsFound = true;
|
||||
$materials[] = [
|
||||
$materials[] = array_merge([
|
||||
'stock_lot_id' => $lot->id,
|
||||
'item_id' => $materialItem->id,
|
||||
'lot_no' => $lot->lot_no,
|
||||
@@ -1287,13 +1378,13 @@ public function getMaterials(int $workOrderId): array
|
||||
'receipt_date' => $lot->receipt_date,
|
||||
'supplier' => $lot->supplier,
|
||||
'fifo_rank' => $rank++,
|
||||
];
|
||||
], $extraFields);
|
||||
}
|
||||
}
|
||||
|
||||
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
|
||||
if (! $lotsFound) {
|
||||
$materials[] = [
|
||||
$materials[] = array_merge([
|
||||
'stock_lot_id' => null,
|
||||
'item_id' => $materialItem->id,
|
||||
'lot_no' => null,
|
||||
@@ -1309,7 +1400,7 @@ public function getMaterials(int $workOrderId): array
|
||||
'receipt_date' => null,
|
||||
'supplier' => null,
|
||||
'fifo_rank' => $rank++,
|
||||
];
|
||||
], $extraFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1337,11 +1428,50 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId) {
|
||||
// work_order_item_id가 있는 항목은 registerMaterialInputForItem()으로 위임
|
||||
$groupedByItem = [];
|
||||
$noItemInputs = [];
|
||||
|
||||
foreach ($inputs as $input) {
|
||||
$woItemId = $input['work_order_item_id'] ?? null;
|
||||
if ($woItemId) {
|
||||
$groupedByItem[$woItemId][] = $input;
|
||||
} else {
|
||||
$noItemInputs[] = $input;
|
||||
}
|
||||
}
|
||||
|
||||
// work_order_item_id가 있는 항목 → 개소별 투입으로 위임
|
||||
$delegatedResults = [];
|
||||
foreach ($groupedByItem as $woItemId => $itemInputs) {
|
||||
$delegatedResults[] = $this->registerMaterialInputForItem($workOrderId, $woItemId, $itemInputs);
|
||||
}
|
||||
|
||||
// work_order_item_id가 없는 항목 → 기존 방식 + WorkOrderMaterialInput 레코드 생성
|
||||
if (empty($noItemInputs)) {
|
||||
// 전부 위임된 경우
|
||||
$totalCount = array_sum(array_column($delegatedResults, 'material_count'));
|
||||
$allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults));
|
||||
|
||||
return [
|
||||
'work_order_id' => $workOrderId,
|
||||
'material_count' => $totalCount,
|
||||
'input_results' => $allResults,
|
||||
'input_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
// fallback: 첫 번째 work_order_item_id로 매핑
|
||||
$fallbackWoItemId = WorkOrderItem::where('tenant_id', $tenantId)
|
||||
->where('work_order_id', $workOrderId)
|
||||
->orderBy('id')
|
||||
->value('id');
|
||||
|
||||
return DB::transaction(function () use ($noItemInputs, $tenantId, $userId, $workOrderId, $fallbackWoItemId, $delegatedResults) {
|
||||
$stockService = app(StockService::class);
|
||||
$inputResults = [];
|
||||
|
||||
foreach ($inputs as $input) {
|
||||
foreach ($noItemInputs as $input) {
|
||||
$stockLotId = $input['stock_lot_id'] ?? null;
|
||||
$qty = (float) ($input['qty'] ?? 0);
|
||||
|
||||
@@ -1357,6 +1487,21 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
|
||||
referenceId: $workOrderId
|
||||
);
|
||||
|
||||
// WorkOrderMaterialInput 레코드 생성 (이력 통일)
|
||||
$lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId);
|
||||
$lotItemId = $lot?->stock?->item_id;
|
||||
|
||||
WorkOrderMaterialInput::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'work_order_id' => $workOrderId,
|
||||
'work_order_item_id' => $fallbackWoItemId,
|
||||
'stock_lot_id' => $stockLotId,
|
||||
'item_id' => $lotItemId ?? 0,
|
||||
'qty' => $qty,
|
||||
'input_by' => $userId,
|
||||
'input_at' => now(),
|
||||
]);
|
||||
|
||||
$inputResults[] = [
|
||||
'stock_lot_id' => $stockLotId,
|
||||
'qty' => $qty,
|
||||
@@ -1373,17 +1518,23 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
|
||||
'material_input',
|
||||
null,
|
||||
[
|
||||
'inputs' => $inputs,
|
||||
'inputs' => $noItemInputs,
|
||||
'input_results' => $inputResults,
|
||||
'input_by' => $userId,
|
||||
'input_at' => now()->toDateTimeString(),
|
||||
]
|
||||
);
|
||||
|
||||
// 위임된 결과와 합산
|
||||
$allResults = $inputResults;
|
||||
foreach ($delegatedResults as $dr) {
|
||||
$allResults = array_merge($allResults, $dr['input_results']);
|
||||
}
|
||||
|
||||
return [
|
||||
'work_order_id' => $workOrderId,
|
||||
'material_count' => count($inputResults),
|
||||
'input_results' => $inputResults,
|
||||
'material_count' => count($allResults),
|
||||
'input_results' => $allResults,
|
||||
'input_at' => now()->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
@@ -2856,9 +3007,9 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra
|
||||
referenceId: $workOrderId
|
||||
);
|
||||
|
||||
// 로트의 품목 ID 조회
|
||||
$lot = \App\Models\Tenants\StockLot::find($stockLotId);
|
||||
$lotItemId = $lot ? ($lot->stock->item_id ?? null) : null;
|
||||
// 로트의 품목 ID 조회 (Eager Loading으로 N+1 방지)
|
||||
$lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId);
|
||||
$lotItemId = $lot?->stock?->item_id;
|
||||
|
||||
// 개소별 매핑 레코드 생성
|
||||
WorkOrderMaterialInput::create([
|
||||
|
||||
278
tests/Feature/Production/BendingLotPipelineTest.php
Normal file
278
tests/Feature/Production/BendingLotPipelineTest.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
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