diff --git a/app/Models/Products/Price.php b/app/Models/Products/Price.php index 9cfdbaf..1128146 100644 --- a/app/Models/Products/Price.php +++ b/app/Models/Products/Price.php @@ -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); } } diff --git a/app/Models/Quote/QuoteFormula.php b/app/Models/Quote/QuoteFormula.php new file mode 100644 index 0000000..2a59611 --- /dev/null +++ b/app/Models/Quote/QuoteFormula.php @@ -0,0 +1,219 @@ + '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, + }; + } +} diff --git a/app/Models/Quote/QuoteFormulaCategory.php b/app/Models/Quote/QuoteFormulaCategory.php new file mode 100644 index 0000000..33eed53 --- /dev/null +++ b/app/Models/Quote/QuoteFormulaCategory.php @@ -0,0 +1,110 @@ + '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'); + } +} diff --git a/app/Models/Quote/QuoteFormulaItem.php b/app/Models/Quote/QuoteFormulaItem.php new file mode 100644 index 0000000..b5965d2 --- /dev/null +++ b/app/Models/Quote/QuoteFormulaItem.php @@ -0,0 +1,70 @@ + '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; + } +} diff --git a/app/Models/Quote/QuoteFormulaMapping.php b/app/Models/Quote/QuoteFormulaMapping.php new file mode 100644 index 0000000..7031786 --- /dev/null +++ b/app/Models/Quote/QuoteFormulaMapping.php @@ -0,0 +1,65 @@ + '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}'"; + } +} diff --git a/app/Models/Quote/QuoteFormulaRange.php b/app/Models/Quote/QuoteFormulaRange.php new file mode 100644 index 0000000..24f6850 --- /dev/null +++ b/app/Models/Quote/QuoteFormulaRange.php @@ -0,0 +1,107 @@ + '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}"; + } +} diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 904d987..dd6f6a8 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -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); + } } diff --git a/app/Services/Quote/QuoteCalculationService.php b/app/Services/Quote/QuoteCalculationService.php index e8e7e17..0981c5d 100644 --- a/app/Services/Quote/QuoteCalculationService.php +++ b/app/Services/Quote/QuoteCalculationService.php @@ -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;