Files
sam-api/app/Services/Quote/QuoteCalculationService.php
hskwon 4d3085e705 feat: 견적 산출 서비스 prices 테이블 연동
- Price 모델에 getCurrentPrice(), getSalesPriceByItemCode() 메서드 추가
- Price 모델에 STATUS_*, ITEM_TYPE_* 상수 추가
- QuoteCalculationService에 setTenantId(), getUnitPrice() 메서드 추가
- 스크린 품목 단가: 원단, 케이스, 브라켓, 인건비 prices 조회로 변경
- 철재 품목 단가: 철판, 용접, 표면처리, 가공비 prices 조회로 변경
- 모터 용량별 단가: 50W~300W prices 조회로 변경
- 모든 단가는 prices 조회 실패 시 기존 하드코딩 값을 fallback으로 사용
2025-12-19 16:20:38 +09:00

583 lines
20 KiB
PHP

<?php
namespace App\Services\Quote;
use App\Models\Products\Price;
use App\Models\Quote\Quote;
use App\Services\Service;
/**
* 견적 자동산출 서비스
*
* 입력 파라미터(W0, H0 등)를 기반으로 견적 품목과 금액을 자동 계산합니다.
* 제품 카테고리(스크린/철재)별 계산 로직을 지원합니다.
*/
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 제품 카테고리
* @return array 산출 결과
*/
public function calculate(array $inputs, ?string $productCategory = 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),
};
return [
'inputs' => $validatedInputs,
'outputs' => $result['outputs'],
'items' => $result['items'],
'costs' => $result['costs'],
'errors' => $this->formulaEvaluator->getErrors(),
];
}
/**
* 견적 미리보기 (저장 없이 계산만)
*/
public function preview(array $inputs, ?string $productCategory = null): array
{
return $this->calculate($inputs, $productCategory);
}
/**
* 견적 품목 재계산 (기존 견적 기준)
*/
public function recalculate(Quote $quote): array
{
$inputs = $quote->calculation_inputs ?? [];
$category = $quote->product_category;
return $this->calculate($inputs, $category);
}
/**
* 입력값 검증 및 기본값 설정
*/
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
]);
}
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;
}
/**
* 비용 계산
*/
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),
];
}
/**
* 모터 단가 조회 (prices 테이블 연동)
*/
private function getMotorPrice(string $capacity): float
{
// 용량별 품목코드 및 기본 단가
$motorCode = 'SCR-MOTOR-'.$capacity;
$fallbackPrice = match ($capacity) {
'50W' => 120000,
'100W' => 150000,
'200W' => 200000,
'300W' => 280000,
default => 150000,
};
return $this->getUnitPrice($motorCode, $fallbackPrice);
}
/**
* 입력 스키마 반환 (프론트엔드용)
*/
public function getInputSchema(?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;
}
}