refactor: 견적 산출 서비스 DB 기반으로 재작성
- Quote 수식 모델 추가 (mng 패턴 적용) - QuoteFormula: 수식 정의 (input/calculation/range/mapping) - QuoteFormulaCategory: 카테고리 정의 - QuoteFormulaItem: 품목 출력 정의 - QuoteFormulaRange: 범위별 값 정의 - QuoteFormulaMapping: 매핑 값 정의 - FormulaEvaluatorService 확장 - executeAll(): 카테고리별 수식 실행 - evaluateRangeFormula/evaluateMappingFormula: QuoteFormula 기반 평가 - getItemPrice(): prices 테이블 연동 - QuoteCalculationService DB 기반으로 재작성 - 하드코딩된 품목 코드/로직 제거 - quote_formulas 테이블 기반 동적 계산 - getInputSchema(): DB 기반 입력 스키마 생성 - Price 모델 수정 - items 테이블 연동 (products/materials 대체) - ITEM_TYPE 상수 업데이트 (FG/PT/RM/SM/CS)
This commit is contained in:
@@ -2,91 +2,78 @@
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Products\Price;
|
||||
use App\Models\Quote\Quote;
|
||||
use App\Models\Quote\QuoteFormula;
|
||||
use App\Models\Quote\QuoteFormulaCategory;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 견적 자동산출 서비스
|
||||
*
|
||||
* 입력 파라미터(W0, H0 등)를 기반으로 견적 품목과 금액을 자동 계산합니다.
|
||||
* DB에 저장된 수식(quote_formulas)을 기반으로 견적 품목과 금액을 자동 계산합니다.
|
||||
* 제품 카테고리(스크린/철재)별 계산 로직을 지원합니다.
|
||||
*/
|
||||
class QuoteCalculationService extends Service
|
||||
{
|
||||
private ?int $tenantId = null;
|
||||
|
||||
public function __construct(
|
||||
private FormulaEvaluatorService $formulaEvaluator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 테넌트 ID 설정
|
||||
*/
|
||||
public function setTenantId(int $tenantId): self
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 코드로 단가 조회 (prices 테이블 연동)
|
||||
*
|
||||
* @param string $itemCode 품목 코드
|
||||
* @param float $fallback 조회 실패 시 기본값
|
||||
*/
|
||||
private function getUnitPrice(string $itemCode, float $fallback = 0): float
|
||||
{
|
||||
if (! $this->tenantId) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$price = Price::getSalesPriceByItemCode($this->tenantId, $itemCode);
|
||||
|
||||
return $price > 0 ? $price : $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 자동산출 실행
|
||||
*
|
||||
* @param array $inputs 입력 파라미터
|
||||
* @param string|null $productCategory 제품 카테고리
|
||||
* @param int|null $productId 제품 ID (제품별 전용 수식 조회용)
|
||||
* @return array 산출 결과
|
||||
*/
|
||||
public function calculate(array $inputs, ?string $productCategory = null): array
|
||||
public function calculate(array $inputs, ?string $productCategory = null, ?int $productId = null): array
|
||||
{
|
||||
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
|
||||
|
||||
// 기본 변수 초기화
|
||||
$this->formulaEvaluator->reset();
|
||||
|
||||
// 입력값 검증 및 변수 설정
|
||||
$validatedInputs = $this->validateInputs($inputs, $category);
|
||||
$this->formulaEvaluator->setVariables($validatedInputs);
|
||||
|
||||
// 카테고리별 산출 로직 실행
|
||||
$result = match ($category) {
|
||||
Quote::CATEGORY_SCREEN => $this->calculateScreen($validatedInputs),
|
||||
Quote::CATEGORY_STEEL => $this->calculateSteel($validatedInputs),
|
||||
default => $this->calculateScreen($validatedInputs),
|
||||
};
|
||||
// DB에서 수식 조회
|
||||
$formulasByCategory = $this->getFormulasByCategory($productId);
|
||||
|
||||
if ($formulasByCategory->isEmpty()) {
|
||||
return [
|
||||
'inputs' => $validatedInputs,
|
||||
'outputs' => [],
|
||||
'items' => [],
|
||||
'costs' => [
|
||||
'material_cost' => 0,
|
||||
'labor_cost' => 0,
|
||||
'install_cost' => 0,
|
||||
'subtotal' => 0,
|
||||
],
|
||||
'errors' => [__('error.no_formulas_configured')],
|
||||
];
|
||||
}
|
||||
|
||||
// 수식 실행
|
||||
$result = $this->formulaEvaluator->executeAll($formulasByCategory, $validatedInputs);
|
||||
|
||||
// 비용 계산
|
||||
$costs = $this->calculateCosts($result['items']);
|
||||
|
||||
return [
|
||||
'inputs' => $validatedInputs,
|
||||
'outputs' => $result['outputs'],
|
||||
'outputs' => $result['variables'],
|
||||
'items' => $result['items'],
|
||||
'costs' => $result['costs'],
|
||||
'errors' => $this->formulaEvaluator->getErrors(),
|
||||
'costs' => $costs,
|
||||
'errors' => $result['errors'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 미리보기 (저장 없이 계산만)
|
||||
*/
|
||||
public function preview(array $inputs, ?string $productCategory = null): array
|
||||
public function preview(array $inputs, ?string $productCategory = null, ?int $productId = null): array
|
||||
{
|
||||
return $this->calculate($inputs, $productCategory);
|
||||
return $this->calculate($inputs, $productCategory, $productId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +83,44 @@ public function recalculate(Quote $quote): array
|
||||
{
|
||||
$inputs = $quote->calculation_inputs ?? [];
|
||||
$category = $quote->product_category;
|
||||
$productId = $quote->product_id;
|
||||
|
||||
return $this->calculate($inputs, $category);
|
||||
return $this->calculate($inputs, $category, $productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 수식 조회
|
||||
*
|
||||
* @param int|null $productId 제품 ID (공통 수식 + 제품별 수식 조회)
|
||||
* @return Collection 카테고리 코드 => 수식 목록
|
||||
*/
|
||||
private function getFormulasByCategory(?int $productId = null): Collection
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
if (! $tenantId) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// 카테고리 조회 (정렬순)
|
||||
$categories = QuoteFormulaCategory::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->ordered()
|
||||
->with([
|
||||
'formulas' => function ($query) use ($productId) {
|
||||
$query->active()
|
||||
->forProduct($productId)
|
||||
->ordered()
|
||||
->with(['ranges', 'mappings', 'items']);
|
||||
},
|
||||
])
|
||||
->get();
|
||||
|
||||
// 카테고리 코드 => 수식 목록으로 변환
|
||||
return $categories->mapWithKeys(function ($category) {
|
||||
return [$category->code => $category->formulas];
|
||||
})->filter(fn ($formulas) => $formulas->isNotEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,285 +152,16 @@ private function validateInputs(array $inputs, string $category): array
|
||||
]);
|
||||
}
|
||||
|
||||
// 추가 사용자 입력값 병합 (DB 수식에서 사용할 수 있도록)
|
||||
foreach ($inputs as $key => $value) {
|
||||
if (! isset($validated[$key])) {
|
||||
$validated[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크린 제품 산출
|
||||
*/
|
||||
private function calculateScreen(array $inputs): array
|
||||
{
|
||||
$w = $inputs['W0'];
|
||||
$h = $inputs['H0'];
|
||||
$qty = $inputs['QTY'];
|
||||
|
||||
// 파생 계산값
|
||||
$outputs = [];
|
||||
|
||||
// W1: 실제 폭 (케이스 마진 포함)
|
||||
$outputs['W1'] = $this->formulaEvaluator->evaluate('W0 + 100', ['W0' => $w]);
|
||||
|
||||
// H1: 실제 높이 (브라켓 마진 포함)
|
||||
$outputs['H1'] = $this->formulaEvaluator->evaluate('H0 + 150', ['H0' => $h]);
|
||||
|
||||
// 면적 (m²)
|
||||
$outputs['AREA'] = $this->formulaEvaluator->evaluate('(W1 * H1) / 1000000', [
|
||||
'W1' => $outputs['W1'],
|
||||
'H1' => $outputs['H1'],
|
||||
]);
|
||||
|
||||
// 무게 (kg) - 대략 계산
|
||||
$outputs['WEIGHT'] = $this->formulaEvaluator->evaluate('AREA * 5', [
|
||||
'AREA' => $outputs['AREA'],
|
||||
]);
|
||||
|
||||
// 모터 용량 결정 (면적 기준)
|
||||
$outputs['MOTOR_CAPACITY'] = $this->formulaEvaluator->evaluateRange($outputs['AREA'], [
|
||||
['min' => 0, 'max' => 5, 'result' => '50W'],
|
||||
['min' => 5, 'max' => 10, 'result' => '100W'],
|
||||
['min' => 10, 'max' => 20, 'result' => '200W'],
|
||||
['min' => 20, 'max' => null, 'result' => '300W'],
|
||||
], '100W');
|
||||
|
||||
// 품목 생성
|
||||
$items = $this->generateScreenItems($inputs, $outputs, $qty);
|
||||
|
||||
// 비용 계산
|
||||
$costs = $this->calculateCosts($items);
|
||||
|
||||
return [
|
||||
'outputs' => $outputs,
|
||||
'items' => $items,
|
||||
'costs' => $costs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 철재 제품 산출
|
||||
*/
|
||||
private function calculateSteel(array $inputs): array
|
||||
{
|
||||
$w = $inputs['W0'];
|
||||
$h = $inputs['H0'];
|
||||
$qty = $inputs['QTY'];
|
||||
$thickness = $inputs['THICKNESS'];
|
||||
|
||||
// 파생 계산값
|
||||
$outputs = [];
|
||||
|
||||
// 실제 크기 (용접 마진 포함)
|
||||
$outputs['W1'] = $this->formulaEvaluator->evaluate('W0 + 50', ['W0' => $w]);
|
||||
$outputs['H1'] = $this->formulaEvaluator->evaluate('H0 + 50', ['H0' => $h]);
|
||||
|
||||
// 면적 (m²)
|
||||
$outputs['AREA'] = $this->formulaEvaluator->evaluate('(W1 * H1) / 1000000', [
|
||||
'W1' => $outputs['W1'],
|
||||
'H1' => $outputs['H1'],
|
||||
]);
|
||||
|
||||
// 중량 (kg) - 재질별 밀도 적용
|
||||
$density = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [
|
||||
['source' => 'ss304', 'result' => 7.93],
|
||||
['source' => 'ss316', 'result' => 8.0],
|
||||
['source' => 'galvanized', 'result' => 7.85],
|
||||
], 7.85);
|
||||
|
||||
$outputs['WEIGHT'] = $this->formulaEvaluator->evaluate('AREA * THICKNESS * DENSITY', [
|
||||
'AREA' => $outputs['AREA'],
|
||||
'THICKNESS' => $thickness / 1000, // mm to m
|
||||
'DENSITY' => $density * 1000, // kg/m³
|
||||
]);
|
||||
|
||||
// 품목 생성
|
||||
$items = $this->generateSteelItems($inputs, $outputs, $qty);
|
||||
|
||||
// 비용 계산
|
||||
$costs = $this->calculateCosts($items);
|
||||
|
||||
return [
|
||||
'outputs' => $outputs,
|
||||
'items' => $items,
|
||||
'costs' => $costs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크린 품목 생성
|
||||
*/
|
||||
private function generateScreenItems(array $inputs, array $outputs, int $qty): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
// 1. 스크린 원단
|
||||
$fabricPrice = $this->getUnitPrice('SCR-FABRIC-001', 25000);
|
||||
$items[] = [
|
||||
'item_code' => 'SCR-FABRIC-001',
|
||||
'item_name' => '스크린 원단',
|
||||
'specification' => sprintf('%.0f x %.0f mm', $outputs['W1'], $outputs['H1']),
|
||||
'unit' => 'm²',
|
||||
'base_quantity' => 1,
|
||||
'calculated_quantity' => $outputs['AREA'] * $qty,
|
||||
'unit_price' => $fabricPrice,
|
||||
'total_price' => $outputs['AREA'] * $qty * $fabricPrice,
|
||||
'formula' => 'AREA * QTY',
|
||||
'formula_category' => 'material',
|
||||
];
|
||||
|
||||
// 2. 케이스
|
||||
$casePrice = $this->getUnitPrice('SCR-CASE-001', 85000);
|
||||
$items[] = [
|
||||
'item_code' => 'SCR-CASE-001',
|
||||
'item_name' => '알루미늄 케이스',
|
||||
'specification' => sprintf('%.0f mm', $outputs['W1']),
|
||||
'unit' => 'EA',
|
||||
'base_quantity' => 1,
|
||||
'calculated_quantity' => $qty,
|
||||
'unit_price' => $casePrice,
|
||||
'total_price' => $qty * $casePrice,
|
||||
'formula' => 'QTY',
|
||||
'formula_category' => 'material',
|
||||
];
|
||||
|
||||
// 3. 모터
|
||||
$motorPrice = $this->getMotorPrice($outputs['MOTOR_CAPACITY']);
|
||||
$items[] = [
|
||||
'item_code' => 'SCR-MOTOR-001',
|
||||
'item_name' => '튜블러 모터',
|
||||
'specification' => $outputs['MOTOR_CAPACITY'],
|
||||
'unit' => 'EA',
|
||||
'base_quantity' => 1,
|
||||
'calculated_quantity' => $qty,
|
||||
'unit_price' => $motorPrice,
|
||||
'total_price' => $qty * $motorPrice,
|
||||
'formula' => 'QTY',
|
||||
'formula_category' => 'material',
|
||||
];
|
||||
|
||||
// 4. 브라켓
|
||||
$bracketPrice = $this->getUnitPrice('SCR-BRACKET-001', 15000);
|
||||
$items[] = [
|
||||
'item_code' => 'SCR-BRACKET-001',
|
||||
'item_name' => '설치 브라켓',
|
||||
'specification' => $inputs['INSTALL_TYPE'],
|
||||
'unit' => 'SET',
|
||||
'base_quantity' => 2,
|
||||
'calculated_quantity' => 2 * $qty,
|
||||
'unit_price' => $bracketPrice,
|
||||
'total_price' => 2 * $qty * $bracketPrice,
|
||||
'formula' => '2 * QTY',
|
||||
'formula_category' => 'material',
|
||||
];
|
||||
|
||||
// 5. 인건비
|
||||
$laborHours = $this->formulaEvaluator->evaluateRange($outputs['AREA'], [
|
||||
['min' => 0, 'max' => 5, 'result' => 2],
|
||||
['min' => 5, 'max' => 10, 'result' => 3],
|
||||
['min' => 10, 'max' => null, 'result' => 4],
|
||||
], 2);
|
||||
|
||||
$laborPrice = $this->getUnitPrice('LAB-INSTALL-001', 50000);
|
||||
$items[] = [
|
||||
'item_code' => 'LAB-INSTALL-001',
|
||||
'item_name' => '설치 인건비',
|
||||
'specification' => sprintf('%.1f시간', $laborHours * $qty),
|
||||
'unit' => 'HR',
|
||||
'base_quantity' => $laborHours,
|
||||
'calculated_quantity' => $laborHours * $qty,
|
||||
'unit_price' => $laborPrice,
|
||||
'total_price' => $laborHours * $qty * $laborPrice,
|
||||
'formula' => 'LABOR_HOURS * QTY',
|
||||
'formula_category' => 'labor',
|
||||
];
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 철재 품목 생성
|
||||
*/
|
||||
private function generateSteelItems(array $inputs, array $outputs, int $qty): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
// 재질별 품목코드 및 단가 조회
|
||||
$materialCode = 'STL-PLATE-'.strtoupper($inputs['MATERIAL']);
|
||||
$fallbackMaterialPrice = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [
|
||||
['source' => 'ss304', 'result' => 4500],
|
||||
['source' => 'ss316', 'result' => 6500],
|
||||
['source' => 'galvanized', 'result' => 3000],
|
||||
], 4500);
|
||||
$materialPrice = $this->getUnitPrice($materialCode, $fallbackMaterialPrice);
|
||||
|
||||
// 1. 철판
|
||||
$items[] = [
|
||||
'item_code' => $materialCode,
|
||||
'item_name' => '철판 ('.$inputs['MATERIAL'].')',
|
||||
'specification' => sprintf('%.0f x %.0f x %.1f mm', $outputs['W1'], $outputs['H1'], $inputs['THICKNESS']),
|
||||
'unit' => 'kg',
|
||||
'base_quantity' => $outputs['WEIGHT'],
|
||||
'calculated_quantity' => $outputs['WEIGHT'] * $qty,
|
||||
'unit_price' => $materialPrice,
|
||||
'total_price' => $outputs['WEIGHT'] * $qty * $materialPrice,
|
||||
'formula' => 'WEIGHT * QTY * MATERIAL_PRICE',
|
||||
'formula_category' => 'material',
|
||||
];
|
||||
|
||||
// 2. 용접
|
||||
$weldLength = ($outputs['W1'] + $outputs['H1']) * 2 / 1000; // m
|
||||
$weldPrice = $this->getUnitPrice('STL-WELD-001', 15000);
|
||||
$items[] = [
|
||||
'item_code' => 'STL-WELD-001',
|
||||
'item_name' => '용접 ('.$inputs['WELDING'].')',
|
||||
'specification' => sprintf('%.2f m', $weldLength * $qty),
|
||||
'unit' => 'm',
|
||||
'base_quantity' => $weldLength,
|
||||
'calculated_quantity' => $weldLength * $qty,
|
||||
'unit_price' => $weldPrice,
|
||||
'total_price' => $weldLength * $qty * $weldPrice,
|
||||
'formula' => 'WELD_LENGTH * QTY',
|
||||
'formula_category' => 'labor',
|
||||
];
|
||||
|
||||
// 3. 표면처리
|
||||
$finishCode = 'STL-FINISH-'.strtoupper($inputs['FINISH']);
|
||||
$fallbackFinishPrice = $this->formulaEvaluator->evaluateMapping($inputs['FINISH'], [
|
||||
['source' => 'hairline', 'result' => 8000],
|
||||
['source' => 'mirror', 'result' => 15000],
|
||||
['source' => 'matte', 'result' => 5000],
|
||||
], 8000);
|
||||
$finishPrice = $this->getUnitPrice($finishCode, $fallbackFinishPrice);
|
||||
|
||||
$items[] = [
|
||||
'item_code' => $finishCode,
|
||||
'item_name' => '표면처리 ('.$inputs['FINISH'].')',
|
||||
'specification' => sprintf('%.2f m²', $outputs['AREA'] * $qty),
|
||||
'unit' => 'm²',
|
||||
'base_quantity' => $outputs['AREA'],
|
||||
'calculated_quantity' => $outputs['AREA'] * $qty,
|
||||
'unit_price' => $finishPrice,
|
||||
'total_price' => $outputs['AREA'] * $qty * $finishPrice,
|
||||
'formula' => 'AREA * QTY',
|
||||
'formula_category' => 'labor',
|
||||
];
|
||||
|
||||
// 4. 가공비
|
||||
$processPrice = $this->getUnitPrice('STL-PROCESS-001', 50000);
|
||||
$items[] = [
|
||||
'item_code' => 'STL-PROCESS-001',
|
||||
'item_name' => '가공비',
|
||||
'specification' => '절단, 벤딩, 천공',
|
||||
'unit' => 'EA',
|
||||
'base_quantity' => 1,
|
||||
'calculated_quantity' => $qty,
|
||||
'unit_price' => $processPrice,
|
||||
'total_price' => $qty * $processPrice,
|
||||
'formula' => 'QTY',
|
||||
'formula_category' => 'labor',
|
||||
];
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용 계산
|
||||
*/
|
||||
@@ -440,27 +194,48 @@ private function calculateCosts(array $items): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 모터 단가 조회 (prices 테이블 연동)
|
||||
* 입력 스키마 반환 (프론트엔드용)
|
||||
* DB에서 input 타입 수식을 조회하여 동적으로 생성
|
||||
*/
|
||||
private function getMotorPrice(string $capacity): float
|
||||
public function getInputSchema(?string $productCategory = null, ?int $productId = null): array
|
||||
{
|
||||
// 용량별 품목코드 및 기본 단가
|
||||
$motorCode = 'SCR-MOTOR-'.$capacity;
|
||||
$fallbackPrice = match ($capacity) {
|
||||
'50W' => 120000,
|
||||
'100W' => 150000,
|
||||
'200W' => 200000,
|
||||
'300W' => 280000,
|
||||
default => 150000,
|
||||
};
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return $this->getUnitPrice($motorCode, $fallbackPrice);
|
||||
if (! $tenantId) {
|
||||
return $this->getDefaultInputSchema($productCategory);
|
||||
}
|
||||
|
||||
// DB에서 input 타입 수식 조회
|
||||
$inputFormulas = QuoteFormula::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->active()
|
||||
->forProduct($productId)
|
||||
->ofType(QuoteFormula::TYPE_INPUT)
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
if ($inputFormulas->isEmpty()) {
|
||||
return $this->getDefaultInputSchema($productCategory);
|
||||
}
|
||||
|
||||
$schema = [];
|
||||
|
||||
foreach ($inputFormulas as $formula) {
|
||||
$schema[$formula->variable] = [
|
||||
'label' => $formula->name,
|
||||
'type' => 'number', // 기본값, 추후 formula.description에서 메타 정보 파싱 가능
|
||||
'required' => true,
|
||||
'description' => $formula->description,
|
||||
];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 스키마 반환 (프론트엔드용)
|
||||
* 기본 입력 스키마 (DB에 수식이 없을 때 사용)
|
||||
*/
|
||||
public function getInputSchema(?string $productCategory = null): array
|
||||
private function getDefaultInputSchema(?string $productCategory = null): array
|
||||
{
|
||||
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user