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)
This commit is contained in:
2025-12-19 16:49:26 +09:00
parent 21d4d0d1b1
commit 0d49e4cc75
8 changed files with 838 additions and 361 deletions

View File

@@ -29,10 +29,16 @@ class Price extends Model
public const STATUS_FINALIZED = 'finalized';
// 품목 유형
public const ITEM_TYPE_PRODUCT = 'PRODUCT';
// 품목 유형 (items.item_type)
public const ITEM_TYPE_FG = 'FG'; // Finished Goods (완제품)
public const ITEM_TYPE_MATERIAL = 'MATERIAL';
public const ITEM_TYPE_PT = 'PT'; // Part (부품)
public const ITEM_TYPE_RM = 'RM'; // Raw Material (원자재)
public const ITEM_TYPE_SM = 'SM'; // Semi-finished (반제품)
public const ITEM_TYPE_CS = 'CS'; // Consumables/Supplies (소모품)
protected $fillable = [
'tenant_id',
@@ -289,7 +295,6 @@ public static function getCurrentPrice(
/**
* 품목 코드로 현재 유효 판매단가 조회
* (quote_formula_items.item_code와 연동용)
*
* @param int $tenantId 테넌트 ID
* @param string $itemCode 품목 코드
@@ -297,32 +302,19 @@ public static function getCurrentPrice(
*/
public static function getSalesPriceByItemCode(int $tenantId, string $itemCode): float
{
// products 테이블에서 품목 코드로 검색
$product = \Illuminate\Support\Facades\DB::table('products')
// items 테이블에서 품목 코드로 검색
$item = \Illuminate\Support\Facades\DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first();
if ($product) {
$price = static::getCurrentPrice($tenantId, self::ITEM_TYPE_PRODUCT, $product->id);
return (float) ($price?->sales_price ?? 0);
if (! $item) {
return 0;
}
// materials 테이블에서도 검색
$material = \Illuminate\Support\Facades\DB::table('materials')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first();
$price = static::getCurrentPrice($tenantId, $item->item_type, $item->id);
if ($material) {
$price = static::getCurrentPrice($tenantId, self::ITEM_TYPE_MATERIAL, $material->id);
return (float) ($price?->sales_price ?? 0);
}
return 0;
return (float) ($price?->sales_price ?? 0);
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace App\Models\Quote;
use App\Models\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 견적 수식 모델
*
* @property int $id
* @property int $tenant_id
* @property int $category_id
* @property int|null $product_id
* @property string $name
* @property string $variable
* @property string $type
* @property string|null $formula
* @property string $output_type
* @property string|null $description
* @property int $sort_order
* @property bool $is_active
*/
class QuoteFormula extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'quote_formulas';
protected $fillable = [
'tenant_id',
'category_id',
'product_id',
'name',
'variable',
'type',
'formula',
'output_type',
'description',
'sort_order',
'is_active',
'created_by',
'updated_by',
];
protected $casts = [
'is_active' => 'boolean',
'sort_order' => 'integer',
];
protected $attributes = [
'type' => 'calculation',
'output_type' => 'variable',
'sort_order' => 0,
'is_active' => true,
];
// 수식 유형 상수
public const TYPE_INPUT = 'input';
public const TYPE_CALCULATION = 'calculation';
public const TYPE_RANGE = 'range';
public const TYPE_MAPPING = 'mapping';
// 출력 유형 상수
public const OUTPUT_VARIABLE = 'variable';
public const OUTPUT_ITEM = 'item';
// =========================================================================
// Relationships
// =========================================================================
/**
* 카테고리 관계
*/
public function category(): BelongsTo
{
return $this->belongsTo(QuoteFormulaCategory::class, 'category_id');
}
/**
* 범위 규칙
*/
public function ranges(): HasMany
{
return $this->hasMany(QuoteFormulaRange::class, 'formula_id')
->orderBy('sort_order');
}
/**
* 매핑 규칙
*/
public function mappings(): HasMany
{
return $this->hasMany(QuoteFormulaMapping::class, 'formula_id')
->orderBy('sort_order');
}
/**
* 품목 출력
*/
public function items(): HasMany
{
return $this->hasMany(QuoteFormulaItem::class, 'formula_id')
->orderBy('sort_order');
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// Scopes
// =========================================================================
/**
* 수식이 공통 수식인지 확인
*/
public function isCommon(): bool
{
return is_null($this->product_id);
}
/**
* Scope: 공통 수식
*/
public function scopeCommon(Builder $query): Builder
{
return $query->whereNull('product_id');
}
/**
* Scope: 특정 제품 수식 (공통 + 제품 전용)
*/
public function scopeForProduct(Builder $query, ?int $productId): Builder
{
return $query->where(function ($q) use ($productId) {
$q->whereNull('product_id');
if ($productId) {
$q->orWhere('product_id', $productId);
}
});
}
/**
* Scope: 활성화된 수식
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* Scope: 정렬 순서
*/
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order');
}
/**
* Scope: 유형별 필터
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 유형 레이블 조회
*/
public function getTypeLabelAttribute(): string
{
return match ($this->type) {
self::TYPE_INPUT => '입력값',
self::TYPE_CALCULATION => '계산식',
self::TYPE_RANGE => '범위별',
self::TYPE_MAPPING => '매핑',
default => $this->type,
};
}
/**
* 출력 유형 레이블 조회
*/
public function getOutputTypeLabelAttribute(): string
{
return match ($this->output_type) {
self::OUTPUT_VARIABLE => '변수',
self::OUTPUT_ITEM => '품목',
default => $this->output_type,
};
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Models\Quote;
use App\Models\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 견적 수식 카테고리 모델
*
* @property int $id
* @property int $tenant_id
* @property string $code
* @property string $name
* @property string|null $description
* @property int $sort_order
* @property bool $is_active
* @property int|null $created_by
* @property int|null $updated_by
*/
class QuoteFormulaCategory extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'quote_formula_categories';
protected $fillable = [
'tenant_id',
'code',
'name',
'description',
'sort_order',
'is_active',
'created_by',
'updated_by',
];
protected $casts = [
'is_active' => 'boolean',
'sort_order' => 'integer',
];
protected $attributes = [
'sort_order' => 0,
'is_active' => true,
];
// =========================================================================
// Relationships
// =========================================================================
/**
* 카테고리에 속한 수식들
*/
public function formulas(): HasMany
{
return $this->hasMany(QuoteFormula::class, 'category_id')
->orderBy('sort_order');
}
/**
* 활성화된 수식만
*/
public function activeFormulas(): HasMany
{
return $this->formulas()->where('is_active', true);
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// Scopes
// =========================================================================
/**
* Scope: 활성화된 카테고리
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* Scope: 정렬 순서
*/
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models\Quote;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 수식 품목 출력 모델
*
* @property int $id
* @property int $formula_id
* @property string $item_code
* @property string $item_name
* @property string|null $specification
* @property string $unit
* @property string $quantity_formula
* @property string|null $unit_price_formula
* @property int $sort_order
*/
class QuoteFormulaItem extends Model
{
protected $table = 'quote_formula_items';
protected $fillable = [
'formula_id',
'item_code',
'item_name',
'specification',
'unit',
'quantity_formula',
'unit_price_formula',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
];
protected $attributes = [
'sort_order' => 0,
];
// =========================================================================
// Relationships
// =========================================================================
public function formula(): BelongsTo
{
return $this->belongsTo(QuoteFormula::class, 'formula_id');
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 품목 표시 문자열
*/
public function getDisplayNameAttribute(): string
{
$name = "[{$this->item_code}] {$this->item_name}";
if ($this->specification) {
$name .= " ({$this->specification})";
}
return $name;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models\Quote;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 수식 매핑 값 모델
*
* @property int $id
* @property int $formula_id
* @property string $source_variable
* @property string $source_value
* @property string $result_value
* @property string $result_type
* @property int $sort_order
*/
class QuoteFormulaMapping extends Model
{
protected $table = 'quote_formula_mappings';
protected $fillable = [
'formula_id',
'source_variable',
'source_value',
'result_value',
'result_type',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
];
protected $attributes = [
'result_type' => 'fixed',
'sort_order' => 0,
];
public const RESULT_FIXED = 'fixed';
public const RESULT_FORMULA = 'formula';
// =========================================================================
// Relationships
// =========================================================================
public function formula(): BelongsTo
{
return $this->belongsTo(QuoteFormula::class, 'formula_id');
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 매핑 조건 표시 문자열
*/
public function getConditionLabelAttribute(): string
{
return "{$this->source_variable} = '{$this->source_value}'";
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Models\Quote;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 수식 범위별 값 모델
*
* @property int $id
* @property int $formula_id
* @property float|null $min_value
* @property float|null $max_value
* @property string $condition_variable
* @property string $result_value
* @property string $result_type
* @property int $sort_order
*/
class QuoteFormulaRange extends Model
{
protected $table = 'quote_formula_ranges';
protected $fillable = [
'formula_id',
'min_value',
'max_value',
'condition_variable',
'result_value',
'result_type',
'sort_order',
];
protected $casts = [
'min_value' => 'decimal:4',
'max_value' => 'decimal:4',
'sort_order' => 'integer',
];
protected $attributes = [
'result_type' => 'fixed',
'sort_order' => 0,
];
public const RESULT_FIXED = 'fixed';
public const RESULT_FORMULA = 'formula';
// =========================================================================
// Relationships
// =========================================================================
public function formula(): BelongsTo
{
return $this->belongsTo(QuoteFormula::class, 'formula_id');
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 값이 범위 내에 있는지 확인
*/
public function isInRange($value): bool
{
$min = $this->min_value;
$max = $this->max_value;
if (is_null($min) && is_null($max)) {
return true;
}
if (is_null($min)) {
return $value <= $max;
}
if (is_null($max)) {
return $value >= $min;
}
return $value >= $min && $value <= $max;
}
/**
* 범위 표시 문자열
*/
public function getRangeLabelAttribute(): string
{
$min = $this->min_value;
$max = $this->max_value;
if (is_null($min) && is_null($max)) {
return '전체';
}
if (is_null($min)) {
return "~ {$max}";
}
if (is_null($max)) {
return "{$min} ~";
}
return "{$min} ~ {$max}";
}
}

View File

@@ -2,7 +2,11 @@
namespace App\Services\Quote;
use App\Models\Products\Price;
use App\Models\Quote\QuoteFormula;
use App\Services\Service;
use Illuminate\Support\Collection;
use RuntimeException;
/**
* 수식 평가 서비스
@@ -395,4 +399,139 @@ public function reset(): void
$this->variables = [];
$this->errors = [];
}
// =========================================================================
// DB 기반 수식 실행 (mng 패턴)
// =========================================================================
/**
* 범위별 수식 평가 (QuoteFormula 기반)
*/
public function evaluateRangeFormula(QuoteFormula $formula, array $variables = []): mixed
{
$conditionVar = $formula->ranges->first()?->condition_variable;
$value = $variables[$conditionVar] ?? 0;
foreach ($formula->ranges as $range) {
if ($range->isInRange($value)) {
if ($range->result_type === 'formula') {
return $this->evaluate($range->result_value, $variables);
}
return $range->result_value;
}
}
return null;
}
/**
* 매핑 수식 평가 (QuoteFormula 기반)
*/
public function evaluateMappingFormula(QuoteFormula $formula, array $variables = []): mixed
{
foreach ($formula->mappings as $mapping) {
$sourceValue = $variables[$mapping->source_variable] ?? null;
if ($sourceValue == $mapping->source_value) {
if ($mapping->result_type === 'formula') {
return $this->evaluate($mapping->result_value, $variables);
}
return $mapping->result_value;
}
}
return null;
}
/**
* 전체 수식 실행 (카테고리 순서대로)
*
* @param Collection $formulasByCategory 카테고리별 수식 컬렉션
* @param array $inputVariables 입력 변수
* @return array ['variables' => 결과, 'items' => 품목, 'errors' => 에러]
*/
public function executeAll(Collection $formulasByCategory, array $inputVariables = []): array
{
if (! $this->tenantId()) {
throw new RuntimeException(__('error.tenant_id_required'));
}
$this->variables = $inputVariables;
$results = [];
$items = [];
foreach ($formulasByCategory as $categoryCode => $formulas) {
foreach ($formulas as $formula) {
$result = $this->executeFormula($formula);
if ($formula->output_type === QuoteFormula::OUTPUT_VARIABLE) {
$this->variables[$formula->variable] = $result;
$results[$formula->variable] = [
'name' => $formula->name,
'value' => $result,
'category' => $formula->category->name,
'type' => $formula->type,
];
} else {
// 품목 출력
foreach ($formula->items as $item) {
$quantity = $this->evaluate($item->quantity_formula);
$unitPrice = $item->unit_price_formula
? $this->evaluate($item->unit_price_formula)
: $this->getItemPrice($item->item_code);
$items[] = [
'item_code' => $item->item_code,
'item_name' => $item->item_name,
'specification' => $item->specification,
'unit' => $item->unit,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'total_price' => $quantity * $unitPrice,
'formula_variable' => $formula->variable,
];
}
}
}
}
return [
'variables' => $results,
'items' => $items,
'errors' => $this->errors,
];
}
/**
* 단일 수식 실행
*/
private function executeFormula(QuoteFormula $formula): mixed
{
return match ($formula->type) {
QuoteFormula::TYPE_INPUT => $this->variables[$formula->variable] ??
($formula->formula ? $this->evaluate($formula->formula) : null),
QuoteFormula::TYPE_CALCULATION => $this->evaluate($formula->formula, $this->variables),
QuoteFormula::TYPE_RANGE => $this->evaluateRangeFormula($formula, $this->variables),
QuoteFormula::TYPE_MAPPING => $this->evaluateMappingFormula($formula, $this->variables),
default => null,
};
}
/**
* 품목 단가 조회
*/
private function getItemPrice(string $itemCode): float
{
$tenantId = $this->tenantId();
if (! $tenantId) {
$this->errors[] = __('error.tenant_id_required');
return 0;
}
return Price::getSalesPriceByItemCode($tenantId, $itemCode);
}
}

View File

@@ -2,91 +2,78 @@
namespace App\Services\Quote;
use App\Models\Products\Price;
use App\Models\Quote\Quote;
use App\Models\Quote\QuoteFormula;
use App\Models\Quote\QuoteFormulaCategory;
use App\Services\Service;
use Illuminate\Support\Collection;
/**
* 견적 자동산출 서비스
*
* 입력 파라미터(W0, H0 등)를 기반으로 견적 품목과 금액을 자동 계산합니다.
* DB에 저장된 수식(quote_formulas)을 기반으로 견적 품목과 금액을 자동 계산합니다.
* 제품 카테고리(스크린/철재)별 계산 로직을 지원합니다.
*/
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 제품 카테고리
* @param int|null $productId 제품 ID (제품별 전용 수식 조회용)
* @return array 산출 결과
*/
public function calculate(array $inputs, ?string $productCategory = null): array
public function calculate(array $inputs, ?string $productCategory = null, ?int $productId = 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),
};
// 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['outputs'],
'outputs' => $result['variables'],
'items' => $result['items'],
'costs' => $result['costs'],
'errors' => $this->formulaEvaluator->getErrors(),
'costs' => $costs,
'errors' => $result['errors'],
];
}
/**
* 견적 미리보기 (저장 없이 계산만)
*/
public function preview(array $inputs, ?string $productCategory = null): array
public function preview(array $inputs, ?string $productCategory = null, ?int $productId = null): array
{
return $this->calculate($inputs, $productCategory);
return $this->calculate($inputs, $productCategory, $productId);
}
/**
@@ -96,8 +83,44 @@ public function recalculate(Quote $quote): array
{
$inputs = $quote->calculation_inputs ?? [];
$category = $quote->product_category;
$productId = $quote->product_id;
return $this->calculate($inputs, $category);
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());
}
/**
@@ -129,285 +152,16 @@ private function validateInputs(array $inputs, string $category): array
]);
}
// 추가 사용자 입력값 병합 (DB 수식에서 사용할 수 있도록)
foreach ($inputs as $key => $value) {
if (! isset($validated[$key])) {
$validated[$key] = $value;
}
}
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;
}
/**
* 비용 계산
*/
@@ -440,27 +194,48 @@ private function calculateCosts(array $items): array
}
/**
* 모터 단가 조회 (prices 테이블 연동)
* 입력 스키마 반환 (프론트엔드용)
* DB에서 input 타입 수식을 조회하여 동적으로 생성
*/
private function getMotorPrice(string $capacity): float
public function getInputSchema(?string $productCategory = null, ?int $productId = null): array
{
// 용량별 품목코드 및 기본 단가
$motorCode = 'SCR-MOTOR-'.$capacity;
$fallbackPrice = match ($capacity) {
'50W' => 120000,
'100W' => 150000,
'200W' => 200000,
'300W' => 280000,
default => 150000,
};
$tenantId = $this->tenantId();
return $this->getUnitPrice($motorCode, $fallbackPrice);
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에 수식이 없을 때 사용)
*/
public function getInputSchema(?string $productCategory = null): array
private function getDefaultInputSchema(?string $productCategory = null): array
{
$category = $productCategory ?? Quote::CATEGORY_SCREEN;