fix: 경동 BOM 계산 수정 및 품목-공정 매핑

- KyungdongFormulaHandler: product_type 자동 추론(item_category 기반), 철재 주자재 EGI코일로 변경, 조인트바 steel 공통 지원
- FormulaEvaluatorService: FG item_category에서 product_type 자동 판별
- MapItemsToProcesses: 경동 품목-공정 매핑 커맨드 정비
- KyungdongItemMasterSeeder: BOM child_item_id code 기반 재매핑
- ItemsBomController: ghost ID 유효성 검증 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 20:58:47 +09:00
parent 55270198d4
commit 10b1b26c1b
8 changed files with 18184 additions and 18048 deletions

View File

@@ -41,31 +41,37 @@ class MapItemsToProcesses extends Command
* - G: 연기차단재
* - L: L-Bar
*/
/**
* FG(완제품), RM(원자재) 제외 - 공정별 생산 품목만 매핑
* EST-INSPECTION(검사비), EST-MOTOR/EST-CTRL(구매품)도 제외
*/
private array $globalExcludes = ['FG-%', 'RM-%', 'EST-INSPECTION'];
private array $mappingRules = [
'P-001' => [
'name' => '슬랫',
'code_patterns' => [],
'name_keywords' => ['철재용', '철재', '슬랫'],
'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외
'code_patterns' => ['EST-RAW-슬랫-%'], // 슬랫 원자재 (방화/방범/조인트바)
'name_keywords' => ['슬랫'],
'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'],
],
'P-002' => [
'name' => '스크린',
'code_patterns' => [],
'code_patterns' => ['EST-RAW-스크린-%'], // 스크린 원자재 (실리카/와이어 등)
'name_keywords' => ['스크린용', '스크린', '원단', '실리카', '방충', '와이어'],
'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외
'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'],
],
'P-003' => [
'name' => '절곡',
'code_patterns' => ['BD-%'], // BD 코드는 절곡
'name_keywords' => ['절곡'], // 절곡 키워드만 (나머지는 P-004로)
'name_keywords' => ['절곡', '연기차단재'], // 연기차단재는 절곡 공정에서 조립
'name_excludes' => [],
],
'P-004' => [
'name' => '재고생산',
'code_patterns' => ['PT-%'], // PT 코드는 재고생산 부품
'name_keywords' => ['가이드레일', '케이스', '연기차단', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바'],
'name_keywords' => ['가이드레일', '케이스', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바', '환봉', '감기샤프트', '각파이프', '앵글'],
'name_excludes' => [],
'code_excludes' => ['BD-%'], // BD 코드는 P-003으로
'code_excludes' => ['BD-%', 'EST-SMOKE-%'], // BD는 P-003, EST-SMOKE는 P-003
],
];
@@ -175,7 +181,7 @@ public function handle(): int
// 미분류 샘플
if ($unmappedItems->isNotEmpty()) {
$this->info("[미분류] 샘플 (최대 10개):");
$this->info('[미분류] 샘플 (최대 10개):');
foreach ($unmappedItems->take(10) as $item) {
$this->line(" - {$item->code}: {$item->name}");
}
@@ -233,28 +239,78 @@ private function classifyItem(Item $item): ?string
$code = $item->code ?? '';
$name = $item->name ?? '';
// B. BD 코드 → P-003 절곡 (최우선)
// 0. 글로벌 제외 (FG 완제품, RM 원자재, EST-INSPECTION 서비스)
foreach ($this->globalExcludes as $excludePattern) {
$prefix = rtrim($excludePattern, '-%');
if (str_starts_with($code, $prefix)) {
return null;
}
}
// 1. 코드 패턴 우선 매핑 (정확한 매칭)
// EST-RAW-슬랫-* → P-001
if (str_starts_with($code, 'EST-RAW-슬랫-')) {
return 'P-001';
}
// EST-RAW-스크린-* → P-002
if (str_starts_with($code, 'EST-RAW-스크린-')) {
return 'P-002';
}
// BD-* → P-003 절곡
if (str_starts_with($code, 'BD-')) {
return 'P-003';
}
// C. PT 코드 → P-004 재고생산 (코드 기반 우선)
// EST-SMOKE-* → P-003 절곡 (연기차단재는 절곡 공정에서 조립)
if (str_starts_with($code, 'EST-SMOKE-')) {
return 'P-003';
}
// PT-* → P-004 재고생산
if (str_starts_with($code, 'PT-')) {
return 'P-004';
}
// C. P-004 재고생산 키워드 체크 (가이드레일, 케이스, 연기차단재, L-Bar, 하단마감, 린텔)
// EST-MOTOR/EST-CTRL → 구매품, 공정 없음
if (str_starts_with($code, 'EST-MOTOR-') || str_starts_with($code, 'EST-CTRL-')) {
return null;
}
// EST-SHAFT/EST-PIPE/EST-ANGLE → P-004 재고생산 (조달 품목)
if (str_starts_with($code, 'EST-SHAFT-') || str_starts_with($code, 'EST-PIPE-') || str_starts_with($code, 'EST-ANGLE-')) {
return 'P-004';
}
// 2. P-004 재고생산 키워드 체크
foreach ($this->mappingRules['P-004']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return 'P-004';
// code_excludes 체크
$excluded = false;
foreach ($this->mappingRules['P-004']['code_excludes'] ?? [] as $excludePattern) {
$prefix = rtrim($excludePattern, '-%');
if (str_starts_with($code, $prefix)) {
$excluded = true;
break;
}
}
if (! $excluded) {
return 'P-004';
}
}
}
// A. 품목명 키워드 기반 분류
// P-002 스크린 먼저 체크 (스크린용, 스크린 키워드)
// 3. P-003 절곡 키워드 체크
foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return 'P-003';
}
}
// 4. P-002 스크린 키워드 체크
foreach ($this->mappingRules['P-002']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
// 재고생산 품목 제외
$excluded = false;
foreach ($this->mappingRules['P-002']['name_excludes'] as $exclude) {
if (mb_stripos($name, $exclude) !== false) {
@@ -268,10 +324,9 @@ private function classifyItem(Item $item): ?string
}
}
// P-001 슬랫 체크 (철재용, 철재, 슬랫 키워드)
// 5. P-001 슬랫 키워드 체크
foreach ($this->mappingRules['P-001']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
// 재고생산 품목 제외
$excluded = false;
foreach ($this->mappingRules['P-001']['name_excludes'] as $exclude) {
if (mb_stripos($name, $exclude) !== false) {
@@ -285,13 +340,6 @@ private function classifyItem(Item $item): ?string
}
}
// P-003 절곡 키워드 체크 (BD 코드 외에 키워드로도 분류)
foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return 'P-003';
}
}
return null;
}
}

View File

@@ -98,6 +98,22 @@ public function store(int $id, Request $request)
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$inputItems = $request->input('items', []);
$tenantId = app('tenant_id');
// child_item_id 존재 검증
$childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray();
if (! empty($childIds)) {
$validIds = Item::where('tenant_id', $tenantId)
->whereIn('id', $childIds)
->pluck('id')
->toArray();
$invalidIds = array_diff($childIds, $validIds);
if (! empty($invalidIds)) {
throw new \InvalidArgumentException(
__('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)])
);
}
}
$existingBom = $item->bom ?? [];
$existingMap = collect($existingBom)->keyBy('child_item_id')->toArray();
@@ -273,6 +289,22 @@ public function replace(int $id, Request $request)
return ApiResponse::handle(function () use ($id, $request) {
$item = $this->getItem($id);
$inputItems = $request->input('items', []);
$tenantId = app('tenant_id');
// child_item_id 존재 검증
$childIds = collect($inputItems)->pluck('child_item_id')->filter()->unique()->values()->toArray();
if (! empty($childIds)) {
$validIds = Item::where('tenant_id', $tenantId)
->whereIn('id', $childIds)
->pluck('id')
->toArray();
$invalidIds = array_diff($childIds, $validIds);
if (! empty($invalidIds)) {
throw new \InvalidArgumentException(
__('error.bom.invalid_child_items', ['ids' => implode(', ', $invalidIds)])
);
}
}
$newBom = [];
foreach ($inputItems as $inputItem) {

View File

@@ -1587,7 +1587,14 @@ private function calculateKyungdongBom(
$H0 = (float) ($inputVariables['H0'] ?? 0);
$QTY = (int) ($inputVariables['QTY'] ?? 1);
$bracketInch = $inputVariables['bracket_inch'] ?? '5';
$productType = $inputVariables['product_type'] ?? 'screen';
// product_type: 프론트 입력값 우선, 없으면 FG item_category에서 자동 추론
$finishedGoods = $this->getItemDetails($finishedGoodsCode, $tenantId);
$itemCategory = $finishedGoods['item_category'] ?? null;
$productType = $inputVariables['product_type'] ?? match (true) {
$itemCategory === '철재' => 'steel',
str_contains($itemCategory ?? '', '슬랫') => 'slat',
default => 'screen',
};
$this->addDebugStep(1, '입력값수집', [
'formulas' => [
@@ -1602,9 +1609,7 @@ private function calculateKyungdongBom(
],
]);
// Step 2: 완제품 조회 (경동 전용 계산은 완제품 없이도 동작)
$finishedGoods = $this->getItemDetails($finishedGoodsCode, $tenantId);
// Step 2: 완제품 정보 활용 (Step 1에서 이미 조회됨)
if ($finishedGoods) {
$this->addDebugStep(2, '완제품선택', [
'code' => $finishedGoods['code'],
@@ -1672,6 +1677,12 @@ private function calculateKyungdongBom(
$finishingType = $inputVariables['finishing_type'] ?? 'SUS';
$installationType = $inputVariables['installation_type'] ?? '벽면형';
// 모터 전압: 프론트 MP(single/three) → motor_voltage(220V/380V) 매핑
$motorVoltage = $inputVariables['motor_voltage'] ?? match ($inputVariables['MP'] ?? 'single') {
'three' => '380V',
default => '220V',
};
$calculatedVariables = array_merge($inputVariables, [
'W0' => $W0,
'H0' => $H0,
@@ -1687,6 +1698,7 @@ private function calculateKyungdongBom(
'product_model' => $productModel,
'finishing_type' => $finishingType,
'installation_type' => $installationType,
'motor_voltage' => $motorVoltage,
]);
$this->addDebugStep(3, '변수계산', [

View File

@@ -22,49 +22,12 @@ public function __construct(?EstimatePriceService $priceService = null)
}
// =========================================================================
// 아이템 매핑 헬퍼 메서드 및 상수
// 아이템 매핑 헬퍼 메서드
// =========================================================================
/**
* 고정 매핑 아이템 코드 → ID 테이블
* items master에서 조회한 고정 값들
*/
private const FIXED_ITEM_MAPPINGS = [
// 연기차단재
'EST-SMOKE-케이스용' => 14912,
'EST-SMOKE-레일용' => 14911,
// 하장바
'00035' => 14158, // SUS
'00036' => 14159, // EGI
// 보강평철
'BD-보강평철-50' => 14790,
// 무게평철12T (평철12T와 동일)
'00021' => 14147,
// 환봉 (파이별)
'90201' => 14407, // 30파이
'90202' => 14408, // 35파이
'90203' => 14409, // 45파이
'90204' => 14410, // 50파이
// 조인트바
'800361' => 14733,
// 검사비
'EST-INSPECTION' => 14913,
// 제어기
'EST-CTRL-노출형' => 14861,
'EST-CTRL-매립형' => 14862,
'EST-CTRL-뒷박스' => 14863,
// 파이프
'EST-PIPE-1.4-3000' => 14900,
'EST-PIPE-1.4-6000' => 14901,
// 모터받침 앵글
'EST-ANGLE-BRACKET-스크린용' => 14907,
'EST-ANGLE-BRACKET-철제300K' => 14908,
'EST-ANGLE-BRACKET-철제400K' => 14909,
'EST-ANGLE-BRACKET-철제800K' => 14910,
];
/**
* items master에서 코드로 아이템 조회 (캐싱 적용, id + name)
* seeder 재실행 시 ID가 변경될 수 있으므로 항상 DB에서 동적 조회
*
* @param string $code 아이템 코드
* @return array{id: int|null, name: string|null}
@@ -76,16 +39,6 @@ private function lookupItem(string $code): array
return $cache[$code];
}
// 1. 고정 매핑에 ID만 있는 경우 → DB에서 name 조회
if (isset(self::FIXED_ITEM_MAPPINGS[$code])) {
$fixedId = self::FIXED_ITEM_MAPPINGS[$code];
$name = \App\Models\Items\Item::where('id', $fixedId)->value('name');
$cache[$code] = ['id' => $fixedId, 'name' => $name];
return $cache[$code];
}
// 2. DB에서 동적 조회 (BD-*, EST-* 패턴)
$item = \App\Models\Items\Item::where('tenant_id', self::TENANT_ID)
->where('code', $code)
->first(['id', 'name']);
@@ -968,8 +921,9 @@ public function calculatePartItems(array $params): array
], $itemCode);
}
// 5. 조인트바 (슬랫 전용, 5130: price × col76, QTY 미적용)
if ($productType === 'slat') {
// 5. 조인트바 (슬랫/철재 공통, 5130: price × col76, QTY 미적용)
// 5130 레거시: 철재(KQTS01)도 슬랫 공정에서 조인트바 사용
if (in_array($productType, ['slat', 'steel'])) {
$jointBarQty = (int) ($params['joint_bar_qty'] ?? 0);
if ($jointBarQty > 0) {
$jointBarPrice = $this->getRawMaterialPrice('조인트바');
@@ -1044,20 +998,21 @@ public function calculateDynamicItems(array $inputs): array
], 'EST-INSPECTION');
}
// 1. 주자재 (스크린 또는 슬랫)
if ($productType === 'slat') {
$materialResult = $this->calculateSlatPrice($width, $height);
$materialName = '주자재(슬랫)';
// 슬랫 타입에 따른 코드 (기본: 방화)
$slatType = $inputs['slat_type'] ?? '방화';
$materialCode = "EST-RAW-슬랫-{$slatType}";
} else {
// 1. 주자재 (스크린 = 실리카, 철재/슬랫 = EGI 코일 슬랫)
// 5130: KQTS01(철재)도 슬랫 공정에서 EGI 코일로 슬랫 생산 (viewSlatWork.php 참조)
if ($productType === 'screen') {
$materialResult = $this->calculateScreenPrice($width, $height);
$materialName = '주자재(스크린)';
// 스크린 타입에 따른 코드 (기본: 실리카)
$screenType = $inputs['screen_type'] ?? '실리카';
$materialCode = "EST-RAW-스크린-{$screenType}";
} else {
// steel, slat 모두 슬랫(EGI 코일) 사용
$materialResult = $this->calculateSlatPrice($width, $height);
$materialName = '주자재(슬랫)';
$slatType = $inputs['slat_type'] ?? '방화';
$materialCode = "EST-RAW-슬랫-{$slatType}";
}
$items[] = $this->withItemMapping([
'category' => 'material',
'item_name' => $materialName,