Files
sam-api/app/Services/Quote/QuoteCalculationService.php
kent 4e59bbf574 feat: Phase 1.2 - 다건 BOM 기반 자동산출 API 구현
- QuoteBomBulkCalculateRequest 생성 (React camelCase → API 약어 변환)
- QuoteCalculationService.calculateBomBulk() 메서드 추가
- POST /api/v1/quotes/calculate/bom/bulk 엔드포인트 추가
- Swagger 스키마 및 문서 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-02 13:13:50 +09:00

459 lines
16 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);
}
/**
* BOM 기반 견적 산출 (10단계 디버깅 포함)
*
* MNG FormulaEvaluatorService의 calculateBomWithDebug와 동일한 로직을 사용합니다.
* React 견적등록 화면에서 자동 견적 산출 시 호출됩니다.
*
* @param string $finishedGoodsCode 완제품 코드
* @param array $inputs 입력 변수 (W0, H0, QTY, PC, GT, MP, CT, WS, INSP)
* @param bool $debug 디버그 모드 (기본 false)
* @return array 산출 결과 (finished_goods, variables, items, grouped_items, subtotals, grand_total)
*/
public function calculateBom(string $finishedGoodsCode, array $inputs, bool $debug = false): array
{
$tenantId = $this->tenantId();
if (! $tenantId) {
return [
'success' => false,
'error' => __('error.tenant_not_set'),
];
}
// FormulaEvaluatorService의 calculateBomWithDebug 호출
$result = $this->formulaEvaluator->calculateBomWithDebug(
$finishedGoodsCode,
$inputs,
$tenantId
);
// 디버그 모드가 아니면 debug_steps 제거
if (! $debug && isset($result['debug_steps'])) {
unset($result['debug_steps']);
}
return $result;
}
/**
* 다건 BOM 기반 견적 산출
*
* 여러 품목의 견적을 일괄 계산합니다.
* React 견적등록 화면에서 품목 목록의 자동 견적 산출 시 호출됩니다.
*
* @param array $inputItems 입력 품목 배열 (QuoteBomBulkCalculateRequest::getInputItems() 결과)
* @param bool $debug 디버그 모드 (기본 false)
* @return array 산출 결과 배열
*/
public function calculateBomBulk(array $inputItems, bool $debug = false): array
{
$results = [];
$successCount = 0;
$failCount = 0;
$grandTotal = 0;
foreach ($inputItems as $item) {
$index = $item['index'];
$finishedGoodsCode = $item['finished_goods_code'];
$inputs = $item['inputs'];
try {
$result = $this->calculateBom($finishedGoodsCode, $inputs, $debug);
if ($result['success'] ?? false) {
$successCount++;
$grandTotal += $result['grand_total'] ?? 0;
} else {
$failCount++;
}
$results[] = [
'index' => $index,
'finished_goods_code' => $finishedGoodsCode,
'inputs' => $inputs,
'result' => $result,
];
} catch (\Throwable $e) {
$failCount++;
$results[] = [
'index' => $index,
'finished_goods_code' => $finishedGoodsCode,
'inputs' => $inputs,
'result' => [
'success' => false,
'error' => $e->getMessage(),
],
];
}
}
return [
'success' => $failCount === 0,
'summary' => [
'total_count' => count($inputItems),
'success_count' => $successCount,
'fail_count' => $failCount,
'grand_total' => round($grandTotal, 2),
],
'items' => $results,
];
}
/**
* 견적 품목 재계산 (기존 견적 기준)
*/
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;
}
}