Files
sam-api/app/Services/Quote/QuoteCalculationService.php
hskwon 0d49e4cc75 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)
2025-12-19 16:49:26 +09:00

358 lines
12 KiB
PHP

<?php
namespace App\Services\Quote;
use App\Models\Quote\Quote;
use App\Models\Quote\QuoteFormula;
use App\Models\Quote\QuoteFormulaCategory;
use App\Services\Service;
use Illuminate\Support\Collection;
/**
* 견적 자동산출 서비스
*
* DB에 저장된 수식(quote_formulas)을 기반으로 견적 품목과 금액을 자동 계산합니다.
* 제품 카테고리(스크린/철재)별 계산 로직을 지원합니다.
*/
class QuoteCalculationService extends Service
{
public function __construct(
private FormulaEvaluatorService $formulaEvaluator
) {}
/**
* 견적 자동산출 실행
*
* @param array $inputs 입력 파라미터
* @param string|null $productCategory 제품 카테고리
* @param int|null $productId 제품 ID (제품별 전용 수식 조회용)
* @return array 산출 결과
*/
public function calculate(array $inputs, ?string $productCategory = null, ?int $productId = null): array
{
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
// 입력값 검증 및 변수 설정
$validatedInputs = $this->validateInputs($inputs, $category);
// 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['variables'],
'items' => $result['items'],
'costs' => $costs,
'errors' => $result['errors'],
];
}
/**
* 견적 미리보기 (저장 없이 계산만)
*/
public function preview(array $inputs, ?string $productCategory = null, ?int $productId = null): array
{
return $this->calculate($inputs, $productCategory, $productId);
}
/**
* 견적 품목 재계산 (기존 견적 기준)
*/
public function recalculate(Quote $quote): array
{
$inputs = $quote->calculation_inputs ?? [];
$category = $quote->product_category;
$productId = $quote->product_id;
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());
}
/**
* 입력값 검증 및 기본값 설정
*/
private function validateInputs(array $inputs, string $category): array
{
// 공통 입력값
$validated = [
'W0' => (float) ($inputs['W0'] ?? $inputs['open_size_width'] ?? 0),
'H0' => (float) ($inputs['H0'] ?? $inputs['open_size_height'] ?? 0),
'QTY' => (int) ($inputs['QTY'] ?? $inputs['quantity'] ?? 1),
];
// 카테고리별 추가 입력값
if ($category === Quote::CATEGORY_SCREEN) {
$validated = array_merge($validated, [
'INSTALL_TYPE' => $inputs['INSTALL_TYPE'] ?? 'wall', // wall, ceiling, floor
'MOTOR_TYPE' => $inputs['MOTOR_TYPE'] ?? 'standard', // standard, heavy
'CONTROL_TYPE' => $inputs['CONTROL_TYPE'] ?? 'switch', // switch, remote, smart
'CHAIN_SIDE' => $inputs['CHAIN_SIDE'] ?? 'left', // left, right
]);
} elseif ($category === Quote::CATEGORY_STEEL) {
$validated = array_merge($validated, [
'MATERIAL' => $inputs['MATERIAL'] ?? 'ss304', // ss304, ss316, galvanized
'THICKNESS' => (float) ($inputs['THICKNESS'] ?? 1.5),
'FINISH' => $inputs['FINISH'] ?? 'hairline', // hairline, mirror, matte
'WELDING' => $inputs['WELDING'] ?? 'tig', // tig, mig, spot
]);
}
// 추가 사용자 입력값 병합 (DB 수식에서 사용할 수 있도록)
foreach ($inputs as $key => $value) {
if (! isset($validated[$key])) {
$validated[$key] = $value;
}
}
return $validated;
}
/**
* 비용 계산
*/
private function calculateCosts(array $items): array
{
$materialCost = 0;
$laborCost = 0;
$installCost = 0;
foreach ($items as $item) {
$category = $item['formula_category'] ?? 'material';
$price = (float) ($item['total_price'] ?? 0);
match ($category) {
'material' => $materialCost += $price,
'labor' => $laborCost += $price,
'install' => $installCost += $price,
default => $materialCost += $price,
};
}
$subtotal = $materialCost + $laborCost + $installCost;
return [
'material_cost' => round($materialCost, 2),
'labor_cost' => round($laborCost, 2),
'install_cost' => round($installCost, 2),
'subtotal' => round($subtotal, 2),
];
}
/**
* 입력 스키마 반환 (프론트엔드용)
* DB에서 input 타입 수식을 조회하여 동적으로 생성
*/
public function getInputSchema(?string $productCategory = null, ?int $productId = null): array
{
$tenantId = $this->tenantId();
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에 수식이 없을 때 사용)
*/
private function getDefaultInputSchema(?string $productCategory = null): array
{
$category = $productCategory ?? Quote::CATEGORY_SCREEN;
$commonSchema = [
'W0' => [
'label' => '개구부 폭',
'type' => 'number',
'unit' => 'mm',
'required' => true,
'min' => 100,
'max' => 10000,
],
'H0' => [
'label' => '개구부 높이',
'type' => 'number',
'unit' => 'mm',
'required' => true,
'min' => 100,
'max' => 10000,
],
'QTY' => [
'label' => '수량',
'type' => 'integer',
'required' => true,
'min' => 1,
'default' => 1,
],
];
if ($category === Quote::CATEGORY_SCREEN) {
return array_merge($commonSchema, [
'INSTALL_TYPE' => [
'label' => '설치 유형',
'type' => 'select',
'options' => [
['value' => 'wall', 'label' => '벽면'],
['value' => 'ceiling', 'label' => '천장'],
['value' => 'floor', 'label' => '바닥'],
],
'default' => 'wall',
],
'MOTOR_TYPE' => [
'label' => '모터 유형',
'type' => 'select',
'options' => [
['value' => 'standard', 'label' => '일반형'],
['value' => 'heavy', 'label' => '고하중형'],
],
'default' => 'standard',
],
'CONTROL_TYPE' => [
'label' => '제어 방식',
'type' => 'select',
'options' => [
['value' => 'switch', 'label' => '스위치'],
['value' => 'remote', 'label' => '리모컨'],
['value' => 'smart', 'label' => '스마트'],
],
'default' => 'switch',
],
'CHAIN_SIDE' => [
'label' => '체인 위치',
'type' => 'select',
'options' => [
['value' => 'left', 'label' => '좌측'],
['value' => 'right', 'label' => '우측'],
],
'default' => 'left',
],
]);
}
if ($category === Quote::CATEGORY_STEEL) {
return array_merge($commonSchema, [
'MATERIAL' => [
'label' => '재질',
'type' => 'select',
'options' => [
['value' => 'ss304', 'label' => 'SUS304'],
['value' => 'ss316', 'label' => 'SUS316'],
['value' => 'galvanized', 'label' => '아연도금'],
],
'default' => 'ss304',
],
'THICKNESS' => [
'label' => '두께',
'type' => 'number',
'unit' => 'mm',
'min' => 0.5,
'max' => 10,
'step' => 0.1,
'default' => 1.5,
],
'FINISH' => [
'label' => '표면처리',
'type' => 'select',
'options' => [
['value' => 'hairline', 'label' => '헤어라인'],
['value' => 'mirror', 'label' => '미러'],
['value' => 'matte', 'label' => '무광'],
],
'default' => 'hairline',
],
'WELDING' => [
'label' => '용접 방식',
'type' => 'select',
'options' => [
['value' => 'tig', 'label' => 'TIG'],
['value' => 'mig', 'label' => 'MIG'],
['value' => 'spot', 'label' => '스팟'],
],
'default' => 'tig',
],
]);
}
return $commonSchema;
}
}