- Price 모델에 getCurrentPrice(), getSalesPriceByItemCode() 메서드 추가 - Price 모델에 STATUS_*, ITEM_TYPE_* 상수 추가 - QuoteCalculationService에 setTenantId(), getUnitPrice() 메서드 추가 - 스크린 품목 단가: 원단, 케이스, 브라켓, 인건비 prices 조회로 변경 - 철재 품목 단가: 철판, 용접, 표면처리, 가공비 prices 조회로 변경 - 모터 용량별 단가: 50W~300W prices 조회로 변경 - 모든 단가는 prices 조회 실패 시 기존 하드코딩 값을 fallback으로 사용
583 lines
20 KiB
PHP
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;
|
|
}
|
|
}
|