- 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>
459 lines
16 KiB
PHP
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;
|
|
}
|
|
}
|