diff --git a/app/Services/Quote/Contracts/TenantFormulaHandler.php b/app/Services/Quote/Contracts/TenantFormulaHandler.php new file mode 100644 index 0000000..ff59dd7 --- /dev/null +++ b/app/Services/Quote/Contracts/TenantFormulaHandler.php @@ -0,0 +1,40 @@ += 4 && $parts[0] === 'FG') { + return [ + 'model' => $parts[1], + 'installation' => $parts[2], + 'finishing' => $parts[3], + ]; + } + + return []; + } + private function addDebugStep(int $step, string $name, array $data): void { if (! $this->debugMode) { @@ -605,9 +619,10 @@ public function calculateBomWithDebug( ]; } - // 경동기업(tenant_id=287) 전용 계산 로직 분기 - if ($tenantId === self::KYUNGDONG_TENANT_ID) { - return $this->calculateKyungdongBom($finishedGoodsCode, $inputVariables, $tenantId); + // 테넌트 전용 핸들러 분기 (Zero Config: class_exists 자동 발견) + $tenantHandler = FormulaHandlerFactory::make($tenantId); + if ($tenantHandler !== null) { + return $this->calculateTenantBom($tenantHandler, $finishedGoodsCode, $inputVariables, $tenantId); } // Step 1: 입력값 수집 (React 동기화 변수 포함) @@ -1558,27 +1573,29 @@ private function mergeLeafMaterials(array $leafMaterials, int $tenantId): array // ========================================================================= /** - * 경동기업 전용 BOM 계산 + * 테넌트 전용 핸들러 기반 BOM 계산 * - * 5130 레거시 시스템의 견적 로직을 구현한 KyungdongFormulaHandler 사용 - * - 3차원 조건 모터 용량 계산 (제품타입 × 인치 × 중량) - * - 브라켓 크기 결정 - * - 10종 절곡품 계산 - * - 3종 부자재 계산 + * FormulaHandlerFactory가 발견한 TenantFormulaHandler 구현체를 사용. + * 핸들러가 제공하는 calculateMotorCapacity, calculateBracketSize, + * calculateDynamicItems 메서드로 BOM을 계산한다. * + * @param TenantFormulaHandler $handler 테넌트 전용 핸들러 * @param string $finishedGoodsCode 완제품 코드 * @param array $inputVariables 입력 변수 (W0, H0, QTY 등) * @param int $tenantId 테넌트 ID * @return array 계산 결과 */ - private function calculateKyungdongBom( + private function calculateTenantBom( + TenantFormulaHandler $handler, string $finishedGoodsCode, array $inputVariables, int $tenantId ): array { - $this->addDebugStep(0, '경동전용계산', [ + $handlerClass = get_class($handler); + $this->addDebugStep(0, '테넌트전용계산', [ 'tenant_id' => $tenantId, - 'handler' => 'KyungdongFormulaHandler', + 'handler' => class_basename($handlerClass), + 'handler_class' => $handlerClass, 'finished_goods' => $finishedGoodsCode, ]); @@ -1617,7 +1634,7 @@ private function calculateKyungdongBom( 'item_category' => $finishedGoods['item_category'] ?? 'N/A', ]); } else { - // 경동 전용: 완제품 미등록 상태에서도 견적 계산 진행 + // 테넌트 전용: 완제품 미등록 상태에서도 견적 계산 진행 $finishedGoods = [ 'code' => $finishedGoodsCode, 'name' => $finishedGoodsCode, @@ -1625,12 +1642,11 @@ private function calculateKyungdongBom( ]; $this->addDebugStep(2, '완제품선택', [ 'code' => $finishedGoodsCode, - 'note' => '경동 전용 계산 - 완제품 미등록 상태로 진행', + 'note' => '테넌트 전용 계산 - 완제품 미등록 상태로 진행', ]); } - // KyungdongFormulaHandler 인스턴스 생성 - $handler = new KyungdongFormulaHandler; + // 핸들러는 파라미터로 이미 전달됨 (FormulaHandlerFactory가 생성) // Step 3: 경동 전용 변수 계산 (제품타입별 면적/중량 공식) $W1 = $W0 + 160; @@ -1672,12 +1688,14 @@ private function calculateKyungdongBom( ?? $inputVariables['bracket_size'] ?? $handler->calculateBracketSize($weight, $bracketInch); - // 핸들러가 필요한 키 보장 (inputVariables에서 전달되지 않으면 기본값) - $productModel = $inputVariables['product_model'] ?? 'KSS01'; - $finishingType = $inputVariables['finishing_type'] ?? 'SUS'; + // 핸들러가 필요한 키 보장 + // 우선순위: 입력값 > FG 코드 파싱 > 기본값 + $fgParsed = $this->parseFgCode($finishedGoodsCode); + $productModel = $inputVariables['product_model'] ?? $fgParsed['model'] ?? 'KSS01'; + $finishingType = $inputVariables['finishing_type'] ?? $fgParsed['finishing'] ?? 'SUS'; - // 가이드레일 설치타입: 프론트 GT(wall/floor/mixed) → installation_type(벽면형/측면형/혼합형) 매핑 - $installationType = $inputVariables['installation_type'] ?? match ($inputVariables['GT'] ?? 'wall') { + // 가이드레일 설치타입: 입력값 > FG파싱 > GT매핑 > 기본값 + $installationType = $inputVariables['installation_type'] ?? $fgParsed['installation'] ?? match ($inputVariables['GT'] ?? 'wall') { 'floor' => '측면형', 'mixed' => '혼합형', default => '벽면형', @@ -1760,7 +1778,7 @@ private function calculateKyungdongBom( ], ]); - // Step 4-7: 동적 항목 계산 (KyungdongFormulaHandler 사용) + // Step 4-7: 동적 항목 계산 (TenantFormulaHandler 사용) $dynamicItems = $handler->calculateDynamicItems($calculatedVariables); $this->addDebugStep(4, 'BOM전개', [ @@ -1800,7 +1818,7 @@ private function calculateKyungdongBom( 'total_price' => $item['total_price'], 'category_group' => $item['category'], 'process_group' => $item['category'], - 'calculation_note' => '경동기업 전용 계산', + 'calculation_note' => '테넌트 전용 계산', ]; } @@ -1818,7 +1836,7 @@ private function calculateKyungdongBom( $category = $item['category_group']; if (! isset($groupedItems[$category])) { $groupedItems[$category] = [ - 'name' => $this->getKyungdongCategoryName($category), + 'name' => $this->getTenantCategoryName($category), 'items' => [], 'subtotal' => 0, ]; @@ -1876,14 +1894,14 @@ private function calculateKyungdongBom( 'subtotals' => $subtotals, 'grand_total' => $grandTotal, 'debug_steps' => $this->debugSteps, - 'calculation_type' => 'kyungdong', + 'calculation_type' => 'tenant_handler', ]; } /** - * 경동기업 카테고리명 반환 + * 테넌트 핸들러용 카테고리명 반환 */ - private function getKyungdongCategoryName(string $category): string + private function getTenantCategoryName(string $category): string { return match ($category) { 'material' => '주자재', diff --git a/app/Services/Quote/FormulaHandlerFactory.php b/app/Services/Quote/FormulaHandlerFactory.php new file mode 100644 index 0000000..4d9ed5c --- /dev/null +++ b/app/Services/Quote/FormulaHandlerFactory.php @@ -0,0 +1,38 @@ +priceService = $priceService ?? new EstimatePriceService(self::TENANT_ID); + } + + // ========================================================================= + // 아이템 매핑 헬퍼 메서드 + // ========================================================================= + + /** + * items master에서 코드로 아이템 조회 (캐싱 적용, id + name) + * seeder 재실행 시 ID가 변경될 수 있으므로 항상 DB에서 동적 조회 + * + * @param string $code 아이템 코드 + * @return array{id: int|null, name: string|null} + */ + private function lookupItem(string $code): array + { + static $cache = []; + if (isset($cache[$code])) { + return $cache[$code]; + } + + $item = \App\Models\Items\Item::where('tenant_id', self::TENANT_ID) + ->where('code', $code) + ->first(['id', 'name']); + + if ($item === null) { + \Illuminate\Support\Facades\Log::warning('FormulaHandler: 미등록 품목 참조', [ + 'tenant_id' => self::TENANT_ID, + 'code' => $code, + ]); + } + + $cache[$code] = ['id' => $item?->id, 'name' => $item?->name]; + + return $cache[$code]; + } + + /** + * items master에서 코드로 아이템 ID 조회 (캐싱 적용) + * + * @param string $code 아이템 코드 + * @return int|null 아이템 ID (없으면 null) + */ + private function lookupItemId(string $code): ?int + { + return $this->lookupItem($code)['id']; + } + + /** + * 아이템 배열에 item_code/item_id 매핑 추가 + 마스터 품목명 적용 + * + * items 마스터에 등록된 품목이면 마스터의 name을 item_name으로 사용하고, + * 미등록 품목이면 하드코딩된 item_name을 그대로 유지 + * + * @param array $item 아이템 배열 + * @param string $code 아이템 코드 + * @return array 매핑이 추가된 아이템 배열 + */ + private function withItemMapping(array $item, string $code): array + { + $looked = $this->lookupItem($code); + + $merged = array_merge($item, [ + 'item_code' => $code, + 'item_id' => $looked['id'], + ]); + + // 마스터에 등록된 품목이면 마스터 name 사용 + if ($looked['name']) { + $merged['item_name'] = $looked['name']; + } + + return $merged; + } + + /** + * 모터 용량에 따른 기본 전압 결정 + * 800K 이상은 380V, 그 외는 220V + * + * @param string $motorCapacity 모터 용량 (예: '300K', '800K') + * @return string 전압 (220V 또는 380V) + */ + private function getMotorVoltage(string $motorCapacity): string + { + $capacity = (int) str_replace(['K', '(S)'], '', $motorCapacity); + + return $capacity >= 800 ? '380V' : '220V'; + } + + // ========================================================================= + // 모터 용량 계산 + // ========================================================================= + + /** + * 모터 용량 계산 (3차원 조건: 제품타입 × 인치 × 중량) + * + * @param string $productType 제품 타입 (screen, steel) + * @param float $weight 중량 (kg) + * @param string $bracketInch 브라켓 인치 (4, 5, 6, 8) + * @return string 모터 용량 (150K, 300K, 400K, 500K, 600K, 800K, 1000K) + */ + public function calculateMotorCapacity(string $productType, float $weight, string $bracketInch): string + { + $inch = (int) $bracketInch; + + if ($productType === 'screen') { + return $this->calculateScreenMotor($weight, $inch); + } + + return $this->calculateSteelMotor($weight, $inch); + } + + /** + * 스크린 모터 용량 계산 + */ + private function calculateScreenMotor(float $weight, int $inch): string + { + if ($inch === 4) { + if ($weight <= 150) { + return '150K'; + } + if ($weight <= 300) { + return '300K'; + } + + return '400K'; + } + + if ($inch === 5) { + if ($weight <= 123) { + return '150K'; + } + if ($weight <= 246) { + return '300K'; + } + if ($weight <= 327) { + return '400K'; + } + if ($weight <= 500) { + return '500K'; + } + + return '600K'; + } + + if ($inch === 6) { + if ($weight <= 104) { + return '150K'; + } + if ($weight <= 208) { + return '300K'; + } + if ($weight <= 300) { + return '400K'; + } + if ($weight <= 424) { + return '500K'; + } + + return '600K'; + } + + // 기본값 + return '300K'; + } + + /** + * 철재 모터 용량 계산 + */ + private function calculateSteelMotor(float $weight, int $inch): string + { + if ($inch === 4) { + if ($weight <= 300) { + return '300K'; + } + + return '400K'; + } + + if ($inch === 5) { + if ($weight <= 246) { + return '300K'; + } + if ($weight <= 327) { + return '400K'; + } + if ($weight <= 500) { + return '500K'; + } + + return '600K'; + } + + if ($inch === 6) { + if ($weight <= 208) { + return '300K'; + } + if ($weight <= 277) { + return '400K'; + } + if ($weight <= 424) { + return '500K'; + } + if ($weight <= 508) { + return '600K'; + } + if ($weight <= 800) { + return '800K'; + } + + return '1000K'; + } + + if ($inch === 8) { + if ($weight <= 324) { + return '500K'; + } + if ($weight <= 388) { + return '600K'; + } + if ($weight <= 611) { + return '800K'; + } + + return '1000K'; + } + + // 기본값 + return '300K'; + } + + // ========================================================================= + // 브라켓 크기 계산 + // ========================================================================= + + /** + * 브라켓 크기 결정 + * + * @param float $weight 중량 (kg) + * @param string|null $bracketInch 브라켓 인치 (선택) + * @return string 브라켓 크기 (530*320, 600*350, 690*390) + */ + public function calculateBracketSize(float $weight, ?string $bracketInch = null): string + { + $motorCapacity = $this->getMotorCapacityByWeight($weight, $bracketInch); + + return match ($motorCapacity) { + '300K', '400K' => '530*320', + '500K', '600K' => '600*350', + '800K', '1000K' => '690*390', + default => '530*320', + }; + } + + /** + * 중량으로 모터 용량 판단 (인치 없을 때) + */ + private function getMotorCapacityByWeight(float $weight, ?string $bracketInch = null): string + { + if ($bracketInch) { + // 인치가 있으면 철재 기준으로 계산 + return $this->calculateSteelMotor($weight, (int) $bracketInch); + } + + // 인치 없으면 중량만으로 판단 + if ($weight <= 300) { + return '300K'; + } + if ($weight <= 400) { + return '400K'; + } + if ($weight <= 500) { + return '500K'; + } + if ($weight <= 600) { + return '600K'; + } + if ($weight <= 800) { + return '800K'; + } + + return '1000K'; + } + + // ========================================================================= + // 주자재(스크린) 계산 + // ========================================================================= + + /** + * 스크린 주자재 가격 계산 + * + * @param float $width 폭 (mm) + * @param float $height 높이 (mm) + * @return array [unit_price, area, total_price] + */ + public function calculateScreenPrice(float $width, float $height): array + { + // 면적 계산: W0 × (H0 + 550) / 1,000,000 + // 5130 공식: col10 × (col11 + 550) / 1,000,000 + $calculateHeight = $height + 550; + $area = ($width * $calculateHeight) / 1000000; + + // 원자재 단가 조회 (실리카/스크린) + $unitPrice = $this->getRawMaterialPrice('실리카'); + + // 5130 동일: round(area, 2) 후 단가 곱셈 + $roundedArea = round($area, 2); + + return [ + 'unit_price' => $unitPrice, + 'area' => $roundedArea, + 'total_price' => round($unitPrice * $roundedArea), + ]; + } + + /** + * 슬랫(철재) 주자재 가격 계산 + * 5130 공식: W0 × (H0 + 50) / 1,000,000 × 단가 + * + * @return array [unit_price, area, total_price] + */ + public function calculateSlatPrice(float $width, float $height): array + { + $calculateHeight = $height + 50; + $area = ($width * $calculateHeight) / 1000000; + + // 원자재 단가 조회 (방화/슬랫) + $unitPrice = $this->getRawMaterialPrice('방화'); + + $roundedArea = round($area, 2); + + return [ + 'unit_price' => $unitPrice, + 'area' => $roundedArea, + 'total_price' => round($unitPrice * $roundedArea), + ]; + } + + // ========================================================================= + // 단가 조회 메서드 (EstimatePriceService 사용) + // ========================================================================= + + /** + * 원자재 단가 조회 + */ + public function getRawMaterialPrice(string $materialName): float + { + return $this->priceService->getRawMaterialPrice($materialName); + } + + /** + * 모터 단가 조회 + */ + public function getMotorPrice(string $motorCapacity): float + { + return $this->priceService->getMotorPrice($motorCapacity); + } + + /** + * 제어기 단가 조회 + */ + public function getControllerPrice(string $controllerType): float + { + return $this->priceService->getControllerPrice($controllerType); + } + + /** + * 샤프트 단가 조회 + */ + public function getShaftPrice(string $size, float $length): float + { + return $this->priceService->getShaftPrice($size, $length); + } + + /** + * 5130 고정 샤프트 제품 규격 매핑 + * col59~65: 3인치 300, 4인치 3000/4500/6000, 5인치 6000/7000/8200 + * + * @param string $size 인치 (3, 4, 5) + * @param float $lengthMm W0 올림값 (mm) + * @return float 매핑된 길이 (m 단위), 0이면 매핑 불가 + */ + private function mapShaftToFixedProduct(string $size, float $lengthMm): float + { + $products = match ($size) { + '3' => [300], + '4' => [3000, 4500, 6000], + '5' => [6000, 7000, 8200], + default => [6000, 7000, 8200], // 기본 5인치 + }; + + // 올림값 이상인 제품 중 가장 작은 것 선택 + foreach ($products as $productMm) { + if ($lengthMm <= $productMm) { + return $productMm / 1000; // mm → m + } + } + + return 0; // 매핑 불가 (초과) + } + + /** + * 파이프 단가 조회 + */ + public function getPipePrice(string $thickness, int $length): float + { + return $this->priceService->getPipePrice($thickness, $length); + } + + /** + * 모터 받침용 앵글 단가 조회 + * + * @param string $searchOption 검색옵션 (스크린용, 철제300K 등) + */ + public function getAnglePrice(string $searchOption): float + { + return $this->priceService->getAnglePrice($searchOption); + } + + /** + * 부자재용 앵글 단가 조회 + * + * @param string $angleType 앵글타입 (앵글3T, 앵글4T) + * @param string $size 길이 (2.5, 10) + */ + public function getMainAnglePrice(string $angleType, string $size): float + { + return $this->priceService->getMainAnglePrice($angleType, $size); + } + + // ========================================================================= + // 절곡품 계산 (10종) + // ========================================================================= + + public function calculateSteelItems(array $params): array + { + $items = []; + + // 기본 파라미터 + $width = (float) ($params['W0'] ?? 0); + $height = (float) ($params['H0'] ?? 0); + $quantity = (int) ($params['QTY'] ?? 1); + $productType = $params['product_type'] ?? 'screen'; + $modelName = $params['model_name'] ?? $params['product_model'] ?? 'KSS01'; + $rawFinish = $params['finishing_type'] ?? 'SUS'; + // DB에는 'SUS', 'EGI'로 저장 → 'SUS마감' → 'SUS' 변환 + $finishingType = str_replace('마감', '', $rawFinish); + + // 절곡품 관련 파라미터 + $caseSpec = $params['case_spec'] ?? '500*380'; + $caseLength = (float) ($params['case_length'] ?? ($width + 220)); // mm 단위 (레거시: W0+220) + $guideType = $this->normalizeGuideType($params['guide_type'] ?? '벽면형'); + $guideSpec = $params['guide_spec'] ?? $params['guide_rail_spec'] ?? '120*70'; + $guideLength = (float) ($params['guide_length'] ?? ($height + 250)) / 1000; // m 단위 (레거시: H0+250) + $bottomBarLength = (float) ($params['bottombar_length'] ?? $width) / 1000; // m 단위 (레거시: W0) + $lbarLength = (float) ($params['lbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220) + $flatBarLength = (float) ($params['flatbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220) + $weightPlateQty = (int) ($params['weight_plate_qty'] ?? 0); // 무게평철 수량 + // 환봉 수량: 5130 자동계산 (col10=폭 기준) + // ≤3000→1, ≤6000→2, ≤9000→3, ≤12000→4 (× 수량) + $roundBarQty = (int) ($params['round_bar_qty'] ?? -1); + if ($roundBarQty < 0) { + if ($width <= 3000) { + $roundBarQty = 1 * $quantity; + } elseif ($width <= 6000) { + $roundBarQty = 2 * $quantity; + } elseif ($width <= 9000) { + $roundBarQty = 3 * $quantity; + } elseif ($width <= 12000) { + $roundBarQty = 4 * $quantity; + } else { + $roundBarQty = 0; + } + } + + // 1. 케이스 (단가/1000 × 길이mm × 수량) + $casePrice = $this->priceService->getCasePrice($caseSpec); + if ($casePrice > 0 && $caseLength > 0) { + // 5130: round($shutter_price * $total_length * 1000) * $su → 단건 반올림 후 × QTY + $perUnitPrice = round(($casePrice / 1000) * $caseLength); + $itemCode = "BD-케이스-{$caseSpec}"; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '케이스', + 'specification' => "{$caseSpec} {$caseLength}mm", + 'unit' => 'm', + 'quantity' => $caseLength / 1000 * $quantity, + 'unit_price' => $casePrice, + 'total_price' => $perUnitPrice * $quantity, + ], $itemCode); + } + + // 2. 케이스용 연기차단재 - 5130: round(단가 × 길이m) × QTY + $caseSmokePrice = $this->priceService->getCaseSmokeBlockPrice(); + if ($caseSmokePrice > 0 && $caseLength > 0) { + $lengthM = $caseLength / 1000; + $perUnitSmoke = round($caseSmokePrice * $lengthM); + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '케이스용 연기차단재', + 'specification' => "{$lengthM}m", + 'unit' => 'm', + 'quantity' => $lengthM * $quantity, + 'unit_price' => $caseSmokePrice, + 'total_price' => $perUnitSmoke * $quantity, + ], 'BD-케이스용 연기차단재'); + } + + // 3. 케이스 마구리 - 5130: round(단가 × QTY) + $caseCapSpec = $this->convertToCaseCapSpec($caseSpec); + $caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec); + if ($caseCapPrice > 0) { + $capQty = $quantity; + $itemCode = "BD-마구리-{$caseCapSpec}"; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '케이스 마구리', + 'specification' => $caseCapSpec, + 'unit' => 'EA', + 'quantity' => $capQty, + 'unit_price' => $caseCapPrice, + 'total_price' => round($caseCapPrice * $capQty), + ], $itemCode); + } + + // 4. 가이드레일 (단가 × 길이m × 수량) - 타입별 처리 + $guideItems = $this->calculateGuideRails($modelName, $finishingType, $guideType, $guideSpec, $guideLength, $quantity); + $items = array_merge($items, $guideItems); + + // 5. 레일용 연기차단재 - 5130: round(단가 × 길이m) × multiplier × QTY + $railSmokePrice = $this->priceService->getRailSmokeBlockPrice(); + if ($railSmokePrice > 0 && $guideLength > 0) { + $railSmokeMultiplier = ($productType === 'slat') ? 1 : 2; + $railSmokeQty = $railSmokeMultiplier * $quantity; + $perUnitRailSmoke = round($railSmokePrice * $guideLength); + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '레일용 연기차단재', + 'specification' => ($railSmokeMultiplier > 1) ? "{$guideLength}m × 2" : "{$guideLength}m", + 'unit' => 'm', + 'quantity' => $guideLength * $railSmokeQty, + 'unit_price' => $railSmokePrice, + 'total_price' => $perUnitRailSmoke * $railSmokeQty, + ], 'BD-가이드레일용 연기차단재'); + } + + // 6. 하장바 (단가 × 길이m × 수량) + $bottomBarPrice = $this->priceService->getBottomBarPrice($modelName, $finishingType); + if ($bottomBarPrice > 0 && $bottomBarLength > 0) { + // 하장바 코드: SUS→00035, EGI→00036 + $bottomBarCode = ($finishingType === 'EGI') ? '00036' : '00035'; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '하장바', + 'specification' => "{$modelName} {$finishingType} {$bottomBarLength}m", + 'unit' => 'm', + 'quantity' => $bottomBarLength * $quantity, + 'unit_price' => $bottomBarPrice, + 'total_price' => round($bottomBarPrice * $bottomBarLength * $quantity), + ], $bottomBarCode); + } + + // 7. L바 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용 + $lbarPrice = ($productType !== 'slat') ? $this->priceService->getLBarPrice($modelName) : 0; + if ($lbarPrice > 0 && $lbarLength > 0) { + // L바 코드: BD-L-BAR-{모델}-{규격} (예: BD-L-BAR-KSS01-17*60) + // L바 규격은 모델별로 다르지만 대부분 17*60 또는 17*100 + $lbarSpec = (str_contains($modelName, 'KDSS') || str_contains($modelName, 'KQT')) ? '17*100' : '17*60'; + $itemCode = "BD-L-BAR-{$modelName}-{$lbarSpec}"; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => 'L바', + 'specification' => "{$modelName} {$lbarLength}m", + 'unit' => 'm', + 'quantity' => $lbarLength * $quantity, + 'unit_price' => $lbarPrice, + 'total_price' => round($lbarPrice * $lbarLength * $quantity), + ], $itemCode); + } + + // 8. 보강평철 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용 + $flatBarPrice = ($productType !== 'slat') ? $this->priceService->getFlatBarPrice() : 0; + if ($flatBarPrice > 0 && $flatBarLength > 0) { + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '보강평철', + 'specification' => "{$flatBarLength}m", + 'unit' => 'm', + 'quantity' => $flatBarLength * $quantity, + 'unit_price' => $flatBarPrice, + 'total_price' => round($flatBarPrice * $flatBarLength * $quantity), + ], 'BD-보강평철-50'); + } + + // 9. 무게평철12T (고정 12,000원 × 수량) + if ($weightPlateQty > 0) { + $weightPlatePrice = 12000; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '무게평철12T', + 'specification' => '12T', + 'unit' => 'EA', + 'quantity' => $weightPlateQty * $quantity, + 'unit_price' => $weightPlatePrice, + 'total_price' => $weightPlatePrice * $weightPlateQty * $quantity, + ], '00021'); + } + + // 10. 환봉 (고정 2,000원 × 수량) - 스크린 전용, 슬랫 미사용 + if ($roundBarQty > 0 && $productType !== 'slat') { + $roundBarPrice = 2000; + // 환봉 코드: 파이 규격에 따라 분기 (기본 30파이) + $roundBarPhi = (int) ($params['round_bar_phi'] ?? 30); + $roundBarCode = match ($roundBarPhi) { + 35 => '90202', + 45 => '90203', + 50 => '90204', + default => '90201', // 30파이 + }; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '환봉', + 'specification' => "{$roundBarPhi}파이", + 'unit' => 'EA', + 'quantity' => $roundBarQty, + 'unit_price' => $roundBarPrice, + 'total_price' => $roundBarPrice * $roundBarQty, + ], $roundBarCode); + } + + return $items; + } + + /** + * 가이드레일 계산 (타입별 처리) + * + * @param string $modelName 모델코드 + * @param string $finishingType 마감재질 + * @param string $guideType 가이드레일 타입 (벽면형, 측면형, 혼합형) + * @param string $guideSpec 가이드레일 규격 (120*70, 120*100) + * @param float $guideLength 가이드레일 길이 (m) + * @param int $quantity 수량 + * @return array 가이드레일 항목 배열 + */ + /** + * 모델별 가이드레일 규격 매핑 + * + * BDmodels 테이블 기준: + * KSS01/02, KSE01, KWE01 → 120*70 / 120*120 + * KTE01, KQTS01 → 130*75 / 130*125 + * KDSS01 → 150*150 / 150*212 + */ + private function getGuideRailSpecs(string $modelName): array + { + return match ($modelName) { + 'KTE01', 'KQTS01' => ['wall' => '130*75', 'side' => '130*125'], + 'KDSS01' => ['wall' => '150*150', 'side' => '150*212'], + default => ['wall' => '120*70', 'side' => '120*120'], + }; + } + + private function calculateGuideRails( + string $modelName, + string $finishingType, + string $guideType, + string $guideSpec, + float $guideLength, + int $quantity + ): array { + $items = []; + + if ($guideLength <= 0) { + return $items; + } + + $specs = $this->getGuideRailSpecs($modelName); + $wallSpec = $specs['wall']; + $sideSpec = $specs['side']; + + // 5130: 세트가격(단가×2 또는 wall+side) → round(세트가격 × 길이m) × QTY + switch ($guideType) { + case '벽면형': + $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, $wallSpec); + if ($price > 0) { + $setPrice = $price * 2; // 5130: 2개 세트 가격 + $perSetTotal = round($setPrice * $guideLength); + $itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$wallSpec}"; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '가이드레일', + 'specification' => "{$modelName} {$finishingType} {$wallSpec} {$guideLength}m × 2", + 'unit' => 'm', + 'quantity' => $guideLength * 2 * $quantity, + 'unit_price' => $price, + 'total_price' => $perSetTotal * $quantity, + ], $itemCode); + } + break; + + case '측면형': + $price = $this->priceService->getGuideRailPrice($modelName, $finishingType, $sideSpec); + if ($price > 0) { + $setPrice = $price * 2; // 5130: 2개 세트 가격 + $perSetTotal = round($setPrice * $guideLength); + $itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$sideSpec}"; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '가이드레일', + 'specification' => "{$modelName} {$finishingType} {$sideSpec} {$guideLength}m × 2", + 'unit' => 'm', + 'quantity' => $guideLength * 2 * $quantity, + 'unit_price' => $price, + 'total_price' => $perSetTotal * $quantity, + ], $itemCode); + } + break; + + case '혼합형': + $priceWall = $this->priceService->getGuideRailPrice($modelName, $finishingType, $wallSpec); + $priceSide = $this->priceService->getGuideRailPrice($modelName, $finishingType, $sideSpec); + + // 5130: (wallPrice + sidePrice) → round(합산가격 × 길이m) × QTY (단일 항목) + $setPrice = ($priceWall ?: 0) + ($priceSide ?: 0); + if ($setPrice > 0) { + $perSetTotal = round($setPrice * $guideLength); + $spec = "{$modelName} {$finishingType} {$wallSpec}/{$sideSpec} {$guideLength}m"; + // 혼합형은 벽면형 코드 사용 (주 가이드레일) + $itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$wallSpec}"; + $items[] = $this->withItemMapping([ + 'category' => 'steel', + 'item_name' => '가이드레일', + 'specification' => $spec, + 'unit' => 'm', + 'quantity' => $guideLength * 2 * $quantity, + 'unit_price' => $setPrice, + 'total_price' => $perSetTotal * $quantity, + ], $itemCode); + } + break; + } + + return $items; + } + + /** + * 가이드타입 정규화 (5130 ↔ SAM 호환) + * + * 5130: '벽면', '측면', '혼합' (col6 필드) + * SAM: '벽면형', '측면형', '혼합형' (switch case) + */ + private function normalizeGuideType(string $type): string + { + return match ($type) { + '벽면', '벽면형' => '벽면형', + '측면', '측면형' => '측면형', + '혼합', '혼합형' => '혼합형', + default => $type, + }; + } + + // ========================================================================= + // 부자재 계산 (3종) + // ========================================================================= + + public function calculatePartItems(array $params): array + { + $items = []; + + $width = (float) ($params['W0'] ?? 0); + $bracketInch = $params['bracket_inch'] ?? '5'; + $bracketSize = $params['BRACKET_SIZE'] ?? $this->calculateBracketSize(100, $bracketInch); + $productType = $params['product_type'] ?? 'screen'; + $quantity = (int) ($params['QTY'] ?? 1); + + // 1. 감기샤프트 (5130: col59~65 고정 제품) + // 5130 고정 규격: 3인치→0.3m, 4인치→3/4.5/6m, 5인치→6/7/8.2m + $shaftSize = $bracketInch; + $shaftLengthMm = ceil($width / 1000) * 1000; // W0 → 올림 (mm) + $shaftLength = $this->mapShaftToFixedProduct($shaftSize, $shaftLengthMm); + $shaftPrice = $shaftLength > 0 ? $this->getShaftPrice($shaftSize, $shaftLength) : 0; + if ($shaftPrice > 0) { + $itemCode = "EST-SHAFT-{$shaftSize}-{$shaftLength}"; + $items[] = $this->withItemMapping([ + 'category' => 'parts', + 'item_name' => "감기샤프트 {$shaftSize}인치", + 'specification' => "{$shaftLength}m", + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $shaftPrice, + 'total_price' => $shaftPrice * $quantity, + ], $itemCode); + } + + // 2. 각파이프 (5130: col67 = col37 + 3000 × col66, col68/col69 자동계산) + $pipeThickness = '1.4'; + $caseLength = (float) ($params['case_length'] ?? ($width + 220)); // col37 (mm) + $connectionCount = (int) ($params['connection_count'] ?? 0); // col66 (연결 수) + $pipeBaseLength = $caseLength + 3000 * $connectionCount; // col67 + + // 5130 자동계산 공식: col67 기준 + $pipe3000Qty = (int) ($params['pipe_3000_qty'] ?? 0); + $pipe6000Qty = (int) ($params['pipe_6000_qty'] ?? 0); + + if ($pipe3000Qty === 0 && $pipe6000Qty === 0) { + // col68: 3000mm 파이프 수량 + if ($pipeBaseLength <= 9000) { + $pipe3000Qty = 3 * $quantity; + } elseif ($pipeBaseLength <= 12000) { + $pipe3000Qty = 4 * $quantity; + } elseif ($pipeBaseLength <= 15000) { + $pipe3000Qty = 5 * $quantity; + } elseif ($pipeBaseLength <= 18000) { + $pipe3000Qty = 6 * $quantity; + } + + // col69: 6000mm 파이프 수량 (18000 초과 시) + if ($pipeBaseLength > 18000 && $pipeBaseLength <= 24000) { + $pipe6000Qty = 4 * $quantity; + } elseif ($pipeBaseLength > 24000 && $pipeBaseLength <= 30000) { + $pipe6000Qty = 5 * $quantity; + } elseif ($pipeBaseLength > 30000 && $pipeBaseLength <= 36000) { + $pipe6000Qty = 6 * $quantity; + } elseif ($pipeBaseLength > 36000 && $pipeBaseLength <= 42000) { + $pipe6000Qty = 7 * $quantity; + } elseif ($pipeBaseLength > 42000 && $pipeBaseLength <= 48000) { + $pipe6000Qty = 8 * $quantity; + } + } + + if ($pipe3000Qty > 0) { + $pipe3000Price = $this->getPipePrice($pipeThickness, 3000); + if ($pipe3000Price > 0) { + $items[] = $this->withItemMapping([ + 'category' => 'parts', + 'item_name' => '각파이프', + 'specification' => "{$pipeThickness}T 3000mm", + 'unit' => 'EA', + 'quantity' => $pipe3000Qty, + 'unit_price' => $pipe3000Price, + 'total_price' => $pipe3000Price * $pipe3000Qty, + ], 'EST-PIPE-1.4-3000'); + } + } + if ($pipe6000Qty > 0) { + $pipe6000Price = $this->getPipePrice($pipeThickness, 6000); + if ($pipe6000Price > 0) { + $items[] = $this->withItemMapping([ + 'category' => 'parts', + 'item_name' => '각파이프', + 'specification' => "{$pipeThickness}T 6000mm", + 'unit' => 'EA', + 'quantity' => $pipe6000Qty, + 'unit_price' => $pipe6000Price, + 'total_price' => $pipe6000Price * $pipe6000Qty, + ], 'EST-PIPE-1.4-6000'); + } + } + + // 3. 모터 받침용 앵글 (bracket angle) + // 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색, qty × $su × 4 + // 5130 슬랫: col23(앵글사이즈) 비어있으면 생략 + $motorCapacity = $params['MOTOR_CAPACITY'] ?? '300K'; + $bracketAngleEnabled = (bool) ($params['bracket_angle_enabled'] ?? ($productType !== 'slat')); + if ($productType === 'screen') { + $angleSearchOption = '스크린용'; + } else { + // 철재/슬랫: bracketSize로 매핑 + $angleSearchOption = match ($bracketSize) { + '530*320' => '철제300K', + '600*350' => '철제400K', + '690*390' => '철제800K', + default => '철제300K', + }; + } + $anglePrice = $bracketAngleEnabled ? $this->getAnglePrice($angleSearchOption) : 0; + if ($anglePrice > 0) { + $angleQty = 4 * $quantity; // 5130: $su * 4 + $itemCode = "EST-ANGLE-BRACKET-{$angleSearchOption}"; + $items[] = $this->withItemMapping([ + 'category' => 'parts', + 'item_name' => '모터 받침용 앵글', + 'specification' => $angleSearchOption, + 'unit' => 'EA', + 'quantity' => $angleQty, + 'unit_price' => $anglePrice, + 'total_price' => $anglePrice * $angleQty, + ], $itemCode); + } + + // 4. 부자재 앵글 (main angle) + // 스크린 5130: calculateMainAngle(1, $itemList, '앵글3T', '2.5') × col71 + // 슬랫 5130: calculateMainAngle(1, $itemList, '앵글4T', '2.5') × col77 + $mainAngleType = ($productType === 'slat') ? '앵글4T' : ($bracketSize === '690*390' ? '앵글4T' : '앵글3T'); + $mainAngleSize = '2.5'; + $mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71/col77, default 2 (좌우) + $mainAnglePrice = $this->getMainAnglePrice($mainAngleType, $mainAngleSize); + if ($mainAnglePrice > 0 && $mainAngleQty > 0) { + // 앵글 코드: EST-ANGLE-MAIN-{타입}-{길이} (예: EST-ANGLE-MAIN-앵글3T-2.5) + $itemCode = "EST-ANGLE-MAIN-{$mainAngleType}-{$mainAngleSize}"; + $items[] = $this->withItemMapping([ + 'category' => 'parts', + 'item_name' => "앵글 {$mainAngleType}", + 'specification' => "{$mainAngleSize}m", + 'unit' => 'EA', + 'quantity' => $mainAngleQty, + 'unit_price' => $mainAnglePrice, + 'total_price' => $mainAnglePrice * $mainAngleQty, + ], $itemCode); + } + + // 5. 조인트바 (슬랫/철재 공통, 5130: price × col76, QTY 미적용) + // 5130 레거시: 철재(KQTS01)도 슬랫 공정에서 조인트바 사용 + if (in_array($productType, ['slat', 'steel'])) { + $jointBarQty = (int) ($params['joint_bar_qty'] ?? 0); + + // 프론트에서 미전달 시 레거시 5130 자동 계산 공식 적용 + // 5130/estimate/common/common_addrowJS.php Slat_updateCo76(): + // col76 = (2 + floor((제작가로 - 500) / 1000)) * 셔터수량 + if ($jointBarQty <= 0) { + $width = (float) ($params['W0'] ?? 0); + $quantity = (int) ($params['QTY'] ?? 1); + if ($width > 0) { + $jointBarQty = (2 + (int) floor(($width - 500) / 1000)) * $quantity; + } + } + + if ($jointBarQty > 0) { + $jointBarPrice = $this->getRawMaterialPrice('조인트바'); + if ($jointBarPrice > 0) { + $items[] = $this->withItemMapping([ + 'category' => 'parts', + 'item_name' => '조인트바', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $jointBarQty, + 'unit_price' => $jointBarPrice, + 'total_price' => round($jointBarPrice * $jointBarQty), + ], '800361'); + } + } + } + + return $items; + } + + // ========================================================================= + // 전체 동적 항목 계산 + // ========================================================================= + + public function calculateDynamicItems(array $inputs): array + { + $items = []; + + $width = (float) ($inputs['W0'] ?? 0); + $height = (float) ($inputs['H0'] ?? 0); + $quantity = (int) ($inputs['QTY'] ?? 1); + $bracketInch = $inputs['bracket_inch'] ?? '5'; + $productType = $inputs['product_type'] ?? 'screen'; + + // 중량 계산 (5130 로직) - 제품타입별 면적/중량 공식 + if ($productType === 'slat') { + // 슬랫: W0 × (H0 + 50) / 1M, 중량 = 면적 × 25 + $area = ($width * ($height + 50)) / 1000000; + $weight = $area * 25; + } else { + // 스크린/철재: W1 × (H1 + 550) / 1M + $W1 = $width + 160; + $H1 = $height + 350; + $area = ($W1 * ($H1 + 550)) / 1000000; + if ($productType === 'steel') { + $weight = $area * 25; + } else { + $weight = $area * 2 + ($width / 1000) * 14.17; + } + } + + // 모터 용량/브라켓 크기 계산 (입력값 우선, 없으면 자동계산) + $motorCapacity = $inputs['MOTOR_CAPACITY'] ?? $this->calculateMotorCapacity($productType, $weight, $bracketInch); + $bracketSize = $inputs['BRACKET_SIZE'] ?? $this->calculateBracketSize($weight, $bracketInch); + + // 입력값에 계산된 값 추가 (부자재 계산용) + $inputs['WEIGHT'] = $weight; + $inputs['MOTOR_CAPACITY'] = $motorCapacity; + $inputs['BRACKET_SIZE'] = $bracketSize; + + // 0. 검사비 (5130: inspectionFee × col14, 기본 50,000원) + $inspectionFee = (int) ($inputs['inspection_fee'] ?? 50000); + if ($inspectionFee > 0) { + $items[] = $this->withItemMapping([ + 'category' => 'inspection', + 'item_name' => '검사비', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $inspectionFee, + 'total_price' => $inspectionFee * $quantity, + ], 'EST-INSPECTION'); + } + + // 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, + 'specification' => "면적 {$materialResult['area']}㎡", + 'unit' => '㎡', + 'quantity' => $materialResult['area'] * $quantity, + 'unit_price' => $materialResult['unit_price'], + 'total_price' => $materialResult['total_price'] * $quantity, + ], $materialCode); + + // 2. 모터 + $motorPrice = $this->getMotorPrice($motorCapacity); + // 모터 전압 (기본: 220V, 대용량은 380V) + $motorVoltage = $inputs['motor_voltage'] ?? $this->getMotorVoltage($motorCapacity); + // 모터 코드: 150K는 150K(S)만 존재 + $motorCapacityCode = ($motorCapacity === '150K') ? '150K(S)' : $motorCapacity; + $motorCode = "EST-MOTOR-{$motorVoltage}-{$motorCapacityCode}"; + $items[] = $this->withItemMapping([ + 'category' => 'motor', + 'item_name' => "모터 {$motorCapacity}", + 'specification' => $motorCapacity, + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $motorPrice, + 'total_price' => $motorPrice * $quantity, + ], $motorCode); + + // 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17) + // 5130: 제어기 = price_매립 × col15 + price_노출 × col16 + price_뒷박스 × col17 + // col15/col16/col17은 고정 수량 (QTY와 무관, $su를 곱하지 않음) + $controllerType = $inputs['controller_type'] ?? '매립형'; + $controllerQty = (int) ($inputs['controller_qty'] ?? 1); + $controllerPrice = $this->getControllerPrice($controllerType); + if ($controllerPrice > 0 && $controllerQty > 0) { + $ctrlCode = "EST-CTRL-{$controllerType}"; + $items[] = $this->withItemMapping([ + 'category' => 'controller', + 'item_name' => "제어기 {$controllerType}", + 'specification' => $controllerType, + 'unit' => 'EA', + 'quantity' => $controllerQty, + 'unit_price' => $controllerPrice, + 'total_price' => $controllerPrice * $controllerQty, + ], $ctrlCode); + } + + // 뒷박스 (5130: col17 수량, QTY와 무관) + $backboxQty = (int) ($inputs['backbox_qty'] ?? 1); + if ($backboxQty > 0) { + $backboxPrice = $this->getControllerPrice('뒷박스'); + if ($backboxPrice > 0) { + $items[] = $this->withItemMapping([ + 'category' => 'controller', + 'item_name' => '뒷박스', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $backboxQty, + 'unit_price' => $backboxPrice, + 'total_price' => $backboxPrice * $backboxQty, + ], 'EST-CTRL-뒷박스'); + } + } + + // 4. 절곡품 + // installation_type → guide_type 매핑 (calculateSteelItems는 guide_type 사용) + if (isset($inputs['installation_type']) && ! isset($inputs['guide_type'])) { + $inputs['guide_type'] = $this->normalizeGuideType($inputs['installation_type']); + } + $steelItems = $this->calculateSteelItems($inputs); + $items = array_merge($items, $steelItems); + + // 5. 부자재 + $partItems = $this->calculatePartItems($inputs); + $items = array_merge($items, $partItems); + + return $items; + } + + /** + * 케이스 규격 → 마구리 규격 변환 + * + * 레거시 updateCol45/Slat_updateCol46 공식: + * 마구리 규격 = (케이스 가로 + 5) × (케이스 세로 + 5) + * 예: 500*380 → 505*385 + */ + private function convertToCaseCapSpec(string $caseSpec): string + { + if (str_contains($caseSpec, '*')) { + $parts = explode('*', $caseSpec); + $width = (int) trim($parts[0]) + 5; + $height = (int) trim($parts[1]) + 5; + + return "{$width}*{$height}"; + } + + return $caseSpec; + } +}