fix:FG 수식 산출 시 제품모델/설치타입/마감타입 올바르게 적용

- parseFgCode() 추가: FG 코드에서 모델/설치타입/마감타입 파싱
- calculateTenantBom() 폴백 순서: 입력값 > FG코드 파싱 > 기본값(KSS01/벽면형/SUS)
- KQTS01 제품이 KSS01 가이드레일 규격(120*70)으로 잘못 산출되던 문제 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:41:21 +09:00
parent ee72af10b4
commit b0547c425f
4 changed files with 1262 additions and 33 deletions

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Quote\Contracts;
/**
* 테넌트별 수식 핸들러 인터페이스
*
* FormulaEvaluatorService에서 테넌트 전용 계산 로직을 위임받는 핸들러 계약.
* 각 테넌트 핸들러는 Handlers/Tenant{id}/FormulaHandler.php에 위치하며,
* FormulaHandlerFactory가 class_exists()로 자동 발견한다.
*/
interface TenantFormulaHandler
{
/**
* 모터 용량 결정
*
* @param string $productType 제품 타입 (screen/steel/slat)
* @param float $weight 중량 (kg)
* @param string $bracketInch 브라켓 인치
* @return string 모터 용량 코드 (예: '300K', '800K')
*/
public function calculateMotorCapacity(string $productType, float $weight, string $bracketInch): string;
/**
* 브라켓 크기 결정
*
* @param float $weight 중량 (kg)
* @param string|null $bracketInch 브라켓 인치
* @return string 브라켓 크기 (예: '530*320', '690*390')
*/
public function calculateBracketSize(float $weight, ?string $bracketInch = null): string;
/**
* 동적 BOM 항목 계산 (주자재, 모터, 제어기, 절곡품, 부자재)
*
* @param array $inputs 계산 변수 (W0, H0, QTY, product_type, product_model 등)
* @return array BOM 항목 배열 [{item_id, item_code, item_name, category, quantity, unit_price, total_price, ...}]
*/
public function calculateDynamicItems(array $inputs): array;
}

View File

@@ -6,7 +6,7 @@
use App\Models\Items\Item;
use App\Models\Products\Price;
use App\Models\Quote\QuoteFormula;
use App\Services\Quote\Handlers\KyungdongFormulaHandler;
use App\Services\Quote\Contracts\TenantFormulaHandler;
use App\Services\Service;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -29,11 +29,6 @@ class FormulaEvaluatorService extends Service
'SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT',
];
/**
* 경동기업 테넌트 ID
*/
private const KYUNGDONG_TENANT_ID = 287;
private array $variables = [];
private array $errors = [];
@@ -548,6 +543,25 @@ public function enableDebugMode(bool $enabled = true): self
/**
* 디버그 단계 기록
*/
/**
* FG 코드에서 모델/설치타입/마감타입 파싱
* 형식: FG-{MODEL}-{INSTALLATION}-{FINISHING} (예: FG-KQTS01-벽면형-SUS)
* 파싱 실패 시 null 반환 (추후 FG 코드 형식 변경 대비)
*/
private function parseFgCode(string $code): array
{
$parts = explode('-', $code);
if (count($parts) >= 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' => '주자재',

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Services\Quote;
use App\Services\Quote\Contracts\TenantFormulaHandler;
/**
* 테넌트별 수식 핸들러 팩토리 (Zero Config)
*
* tenant_id 기반 자동 발견: Handlers/Tenant{id}/FormulaHandler.php 존재 여부로 라우팅.
* 설정 파일, DB 매핑, options 없이 클래스 존재 여부만으로 핸들러를 결정한다.
*
* 새 업체 추가: Handlers/Tenant{id}/FormulaHandler.php 1개만 생성하면 자동 인식.
*/
class FormulaHandlerFactory
{
/**
* 테넌트 전용 핸들러 생성 (없으면 null → Generic DB 경로)
*/
public static function make(int $tenantId): ?TenantFormulaHandler
{
$class = "App\\Services\\Quote\\Handlers\\Tenant{$tenantId}\\FormulaHandler";
if (! class_exists($class)) {
return null;
}
$handler = new $class;
if (! $handler instanceof TenantFormulaHandler) {
throw new \RuntimeException(
"Handler [{$class}] must implement " . TenantFormulaHandler::class
);
}
return $handler;
}
}

File diff suppressed because it is too large Load Diff