Files
sam-api/app/Services/Quote/FormulaEvaluatorService.php
권혁성 10b1b26c1b fix: 경동 BOM 계산 수정 및 품목-공정 매핑
- KyungdongFormulaHandler: product_type 자동 추론(item_category 기반), 철재 주자재 EGI코일로 변경, 조인트바 steel 공통 지원
- FormulaEvaluatorService: FG item_category에서 product_type 자동 판별
- MapItemsToProcesses: 경동 품목-공정 매핑 커맨드 정비
- KyungdongItemMasterSeeder: BOM child_item_id code 기반 재매핑
- ItemsBomController: ghost ID 유효성 검증 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:50:21 +09:00

1892 lines
66 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Quote;
use App\Models\CategoryGroup;
use App\Models\Items\Item;
use App\Models\Products\Price;
use App\Models\Quote\QuoteFormula;
use App\Services\Quote\Handlers\KyungdongFormulaHandler;
use App\Services\Service;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 수식 평가 서비스
*
* 견적 자동산출을 위한 수식 검증 및 평가 엔진
* MNG FormulaEvaluatorService와 동기화된 BOM 계산 로직 포함
*
* 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT
*/
class FormulaEvaluatorService extends Service
{
/**
* 지원 함수 목록
*/
public const SUPPORTED_FUNCTIONS = [
'SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT',
];
/**
* 경동기업 테넌트 ID
*/
private const KYUNGDONG_TENANT_ID = 287;
private array $variables = [];
private array $errors = [];
private array $debugSteps = [];
private bool $debugMode = false;
// =========================================================================
// 기본 수식 평가 메서드
// =========================================================================
/**
* 수식 검증
*/
public function validateFormula(string $formula): array
{
$errors = [];
// 기본 문법 검증
if (empty(trim($formula))) {
return [
'success' => false,
'errors' => [__('error.formula_empty')],
];
}
// 괄호 매칭 검증
if (! $this->validateParentheses($formula)) {
$errors[] = __('error.formula_parentheses_mismatch');
}
// 변수 추출
$variables = $this->extractVariables($formula);
// 지원 함수 검증
$functions = $this->extractFunctions($formula);
foreach ($functions as $func) {
if (! in_array(strtoupper($func), self::SUPPORTED_FUNCTIONS)) {
$errors[] = __('error.formula_unsupported_function', ['function' => $func]);
}
}
return [
'success' => empty($errors),
'errors' => $errors,
'variables' => $variables,
'functions' => $functions,
];
}
/**
* 수식 평가
*
* @param string $formula 수식 문자열
* @param array $variables 변수 배열 [변수명 => 값]
*/
public function evaluate(string $formula, array $variables = []): mixed
{
$this->variables = array_merge($this->variables, $variables);
$this->errors = [];
try {
// 변수 치환
$expression = $this->substituteVariables($formula);
// 함수 처리
$expression = $this->processFunctions($expression);
// 최종 계산
return $this->calculateExpression($expression);
} catch (\Exception $e) {
$this->errors[] = $e->getMessage();
return null;
}
}
/**
* 다중 수식 일괄 평가
*
* @param array $formulas [변수명 => 수식] 배열
* @param array $inputVariables 입력 변수
* @return array [변수명 => 결과값]
*/
public function evaluateMultiple(array $formulas, array $inputVariables = []): array
{
$this->variables = $inputVariables;
$results = [];
foreach ($formulas as $variable => $formula) {
$result = $this->evaluate($formula);
$this->variables[$variable] = $result;
$results[$variable] = $result;
}
return [
'results' => $results,
'errors' => $this->errors,
];
}
/**
* 범위 기반 값 결정
*
* @param float $value 검사할 값
* @param array $ranges 범위 배열 [['min' => 0, 'max' => 100, 'result' => 값], ...]
* @param mixed $default 기본값
*/
public function evaluateRange(float $value, array $ranges, mixed $default = null): mixed
{
foreach ($ranges as $range) {
$min = $range['min'] ?? null;
$max = $range['max'] ?? null;
$inRange = true;
if ($min !== null && $value < $min) {
$inRange = false;
}
if ($max !== null && $value > $max) {
$inRange = false;
}
if ($inRange) {
$result = $range['result'] ?? $default;
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
/**
* 매핑 기반 값 결정
*
* @param mixed $sourceValue 소스 값
* @param array $mappings 매핑 배열 [['source' => 값, 'result' => 결과], ...]
* @param mixed $default 기본값
*/
public function evaluateMapping(mixed $sourceValue, array $mappings, mixed $default = null): mixed
{
foreach ($mappings as $mapping) {
$source = $mapping['source'] ?? null;
$result = $mapping['result'] ?? $default;
if ($sourceValue == $source) {
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
// =========================================================================
// Private Helper Methods (기본)
// =========================================================================
private function validateParentheses(string $formula): bool
{
$count = 0;
foreach (str_split($formula) as $char) {
if ($char === '(') {
$count++;
}
if ($char === ')') {
$count--;
}
if ($count < 0) {
return false;
}
}
return $count === 0;
}
private function extractVariables(string $formula): array
{
preg_match_all('/\b([A-Z][A-Z0-9_]*)\b/', $formula, $matches);
$variables = array_unique($matches[1] ?? []);
// 함수명 제외
return array_values(array_diff($variables, self::SUPPORTED_FUNCTIONS));
}
private function extractFunctions(string $formula): array
{
preg_match_all('/\b([A-Za-z_]+)\s*\(/', $formula, $matches);
return array_unique($matches[1] ?? []);
}
private function substituteVariables(string $formula): string
{
foreach ($this->variables as $var => $value) {
$formula = preg_replace('/\b'.preg_quote($var, '/').'\b/', (string) $value, $formula);
}
return $formula;
}
private function processFunctions(string $expression): string
{
// ROUND(value, decimals)
$expression = preg_replace_callback(
'/ROUND\s*\(\s*([^,]+)\s*,\s*(\d+)\s*\)/i',
fn ($m) => round((float) $this->calculateExpression($m[1]), (int) $m[2]),
$expression
);
// SUM(a, b, c, ...)
$expression = preg_replace_callback(
'/SUM\s*\(([^)]+)\)/i',
fn ($m) => array_sum(array_map('floatval', explode(',', $m[1]))),
$expression
);
// MIN, MAX
$expression = preg_replace_callback(
'/MIN\s*\(([^)]+)\)/i',
fn ($m) => min(array_map('floatval', explode(',', $m[1]))),
$expression
);
$expression = preg_replace_callback(
'/MAX\s*\(([^)]+)\)/i',
fn ($m) => max(array_map('floatval', explode(',', $m[1]))),
$expression
);
// IF(condition, true_val, false_val)
$expression = preg_replace_callback(
'/IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i',
function ($m) {
$condition = $this->evaluateCondition($m[1]);
return $condition ? $this->calculateExpression($m[2]) : $this->calculateExpression($m[3]);
},
$expression
);
// ABS, CEIL, FLOOR
$expression = preg_replace_callback(
'/ABS\s*\(([^)]+)\)/i',
fn ($m) => abs((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/CEIL\s*\(([^)]+)\)/i',
fn ($m) => ceil((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/FLOOR\s*\(([^)]+)\)/i',
fn ($m) => floor((float) $this->calculateExpression($m[1])),
$expression
);
return $expression;
}
private function calculateExpression(string $expression): float
{
// 안전한 수식 평가 (숫자, 연산자, 괄호만 허용)
$expression = preg_replace('/[^0-9+\-*\/().%\s]/', '', $expression);
if (empty(trim($expression))) {
return 0;
}
try {
// TODO: 프로덕션에서는 symfony/expression-language 등 안전한 라이브러리 사용 권장
return (float) eval("return {$expression};");
} catch (\Throwable $e) {
$this->errors[] = __('error.formula_calculation_error', ['expression' => $expression]);
return 0;
}
}
private function evaluateCondition(string $condition): bool
{
// 비교 연산자 처리
if (preg_match('/(.+)(>=|<=|>|<|==|!=)(.+)/', $condition, $m)) {
$left = (float) $this->calculateExpression(trim($m[1]));
$right = (float) $this->calculateExpression(trim($m[3]));
$op = $m[2];
return match ($op) {
'>=' => $left >= $right,
'<=' => $left <= $right,
'>' => $left > $right,
'<' => $left < $right,
'==' => $left == $right,
'!=' => $left != $right,
default => false,
};
}
return (bool) $this->calculateExpression($condition);
}
private function isFormula(string $value): bool
{
// 연산자나 함수가 포함되어 있으면 수식으로 판단
return preg_match('/[+\-*\/()]|[A-Z]+\s*\(/', $value) === 1;
}
// =========================================================================
// 상태 관리 메서드
// =========================================================================
/**
* 에러 목록 반환
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* 현재 변수 상태 반환
*/
public function getVariables(): array
{
return $this->variables;
}
/**
* 변수 설정
*/
public function setVariables(array $variables): self
{
$this->variables = $variables;
return $this;
}
/**
* 변수 추가
*/
public function addVariable(string $name, mixed $value): self
{
$this->variables[$name] = $value;
return $this;
}
/**
* 변수 및 에러 초기화
*/
public function reset(): void
{
$this->variables = [];
$this->errors = [];
$this->debugSteps = [];
}
// =========================================================================
// DB 기반 수식 실행 (QuoteFormula 모델 사용)
// =========================================================================
/**
* 범위별 수식 평가 (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);
// 품목마스터에서 규격/단위 조회 (우선순위: 품목마스터 > 수식품목)
$itemMasterData = $this->getItemSpecAndUnit($item->item_code);
$specification = $itemMasterData['specification'] ?? $item->specification;
$unit = $itemMasterData['unit'] ?? $item->unit ?? 'EA';
$items[] = [
'item_code' => $item->item_code,
'item_name' => $item->item_name,
'specification' => $specification,
'unit' => $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,
};
}
// =========================================================================
// 디버그 모드 (MNG 시뮬레이터 동기화)
// =========================================================================
/**
* 디버그 모드 활성화
*/
public function enableDebugMode(bool $enabled = true): self
{
$this->debugMode = $enabled;
if ($enabled) {
$this->debugSteps = [];
}
return $this;
}
/**
* 디버그 단계 기록
*/
private function addDebugStep(int $step, string $name, array $data): void
{
if (! $this->debugMode) {
return;
}
$this->debugSteps[] = [
'step' => $step,
'name' => $name,
'timestamp' => microtime(true),
'data' => $data,
];
}
/**
* 디버그 정보 반환
*/
public function getDebugSteps(): array
{
return $this->debugSteps;
}
// =========================================================================
// BOM 기반 계산 (10단계 디버깅 포함) - MNG 동기화
// =========================================================================
/**
* BOM 계산 (10단계 디버깅 포함)
*
* MNG 시뮬레이터와 동일한 10단계 계산 과정:
* 1. 입력값 수집 (W0, H0)
* 2. 완제품 선택
* 3. 변수 계산 (W1, H1, M, K)
* 4. BOM 전개
* 5. 단가 출처 결정
* 6. 수량 수식 평가
* 7. 단가 계산 (카테고리 기반)
* 8. 공정별 그룹화
* 9. 소계 계산
* 10. 최종 합계
*/
public function calculateBomWithDebug(
string $finishedGoodsCode,
array $inputVariables,
?int $tenantId = null
): array {
$this->enableDebugMode(true);
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return [
'success' => false,
'error' => __('error.tenant_id_required'),
'debug_steps' => $this->debugSteps,
];
}
// 경동기업(tenant_id=287) 전용 계산 로직 분기
if ($tenantId === self::KYUNGDONG_TENANT_ID) {
return $this->calculateKyungdongBom($finishedGoodsCode, $inputVariables, $tenantId);
}
// Step 1: 입력값 수집 (React 동기화 변수 포함)
$this->addDebugStep(1, '입력값수집', [
'W0' => $inputVariables['W0'] ?? null,
'H0' => $inputVariables['H0'] ?? null,
'QTY' => $inputVariables['QTY'] ?? 1,
'PC' => $inputVariables['PC'] ?? '',
'GT' => $inputVariables['GT'] ?? 'wall',
'MP' => $inputVariables['MP'] ?? 'single',
'CT' => $inputVariables['CT'] ?? 'basic',
'WS' => $inputVariables['WS'] ?? 50,
'INSP' => $inputVariables['INSP'] ?? 50000,
'finished_goods' => $finishedGoodsCode,
]);
// Step 2: 완제품 조회 (마진값 결정을 위해 먼저 조회)
$finishedGoods = $this->getItemDetails($finishedGoodsCode, $tenantId);
if (! $finishedGoods) {
$this->addDebugStep(2, '완제품선택', [
'code' => $finishedGoodsCode,
'error' => '완제품을 찾을 수 없습니다.',
]);
return [
'success' => false,
'error' => __('error.finished_goods_not_found', ['code' => $finishedGoodsCode]),
'debug_steps' => $this->debugSteps,
];
}
$this->addDebugStep(2, '완제품선택', [
'code' => $finishedGoods['code'],
'name' => $finishedGoods['name'],
'item_category' => $finishedGoods['item_category'] ?? 'N/A',
'has_bom' => $finishedGoods['has_bom'],
'bom_count' => count($finishedGoods['bom'] ?? []),
]);
// Step 3: 변수 계산 (제품 카테고리에 따라 마진값 결정)
$W0 = $inputVariables['W0'] ?? 0;
$H0 = $inputVariables['H0'] ?? 0;
$productCategory = $finishedGoods['item_category'] ?? 'SCREEN';
// 제품 카테고리에 따른 마진값 결정
if (strtoupper($productCategory) === 'STEEL') {
$marginW = 110; // 철재 마진
$marginH = 350;
} else {
$marginW = 160; // 스크린 기본 마진
$marginH = 350;
}
$W1 = $W0 + $marginW; // 마진 포함 폭
$H1 = $H0 + $marginH; // 마진 포함 높이
$M = ($W1 * $H1) / 1000000; // 면적 (㎡)
// 제품 카테고리에 따른 중량(K) 계산
if (strtoupper($productCategory) === 'STEEL') {
$K = $M * 25; // 철재 중량
} else {
$K = $M * 2 + ($W0 / 1000) * 14.17; // 스크린 중량
}
$calculatedVariables = array_merge($inputVariables, [
'W0' => $W0,
'H0' => $H0,
'W1' => $W1,
'H1' => $H1,
'W' => $W1, // 수식용 별칭
'H' => $H1, // 수식용 별칭
'M' => $M,
'K' => $K,
'PC' => $productCategory,
]);
$this->addDebugStep(3, '변수계산', array_merge($calculatedVariables, [
'margin_type' => strtoupper($productCategory) === 'STEEL' ? '철재(W+110)' : '스크린(W+140)',
]));
// Step 4: BOM 전개
$bomItems = $this->expandBomWithFormulas($finishedGoods, $calculatedVariables, $tenantId);
$this->addDebugStep(4, 'BOM전개', [
'total_items' => count($bomItems),
'item_codes' => array_column($bomItems, 'item_code'),
]);
// Step 5-7: 각 품목별 단가 계산
$calculatedItems = [];
foreach ($bomItems as $bomItem) {
// Step 6: 수량 수식 평가
$quantityFormula = $bomItem['quantity_formula'] ?? '1';
$quantity = $this->evaluateQuantityFormula($quantityFormula, $calculatedVariables);
$this->addDebugStep(6, '수량계산', [
'item_code' => $bomItem['item_code'],
'formula' => $quantityFormula,
'result' => $quantity,
]);
// Step 5 & 7: 단가 출처 및 계산
$basePrice = $this->getItemPrice($bomItem['item_code']);
$itemCategory = $bomItem['item_category'] ?? $this->getItemCategory($bomItem['item_code'], $tenantId);
$priceResult = $this->calculateCategoryPrice(
$itemCategory,
$basePrice,
$calculatedVariables,
$tenantId
);
// 면적/중량 기반: final_price에 이미 면적/중량이 곱해져 있음
// 수량 기반: quantity × unit_price
$categoryGroup = $priceResult['category_group'];
if ($categoryGroup === 'area_based' || $categoryGroup === 'weight_based') {
// 면적/중량 기반: final_price = base_price × M or K (이미 계산됨)
$totalPrice = $priceResult['final_price'];
$displayQuantity = $priceResult['multiplier']; // 표시용 수량 = 면적 또는 중량
} else {
// 수량 기반: total = quantity × unit_price
$totalPrice = $quantity * $priceResult['final_price'];
$displayQuantity = $quantity;
}
$this->addDebugStep(7, '금액계산', [
'item_code' => $bomItem['item_code'],
'quantity' => $displayQuantity,
'unit_price' => $basePrice,
'total_price' => $totalPrice,
'calculation_note' => $priceResult['calculation_note'],
]);
$calculatedItems[] = [
'item_code' => $bomItem['item_code'],
'item_name' => $bomItem['item_name'],
'item_category' => $itemCategory,
'specification' => $bomItem['specification'] ?? null,
'unit' => $bomItem['unit'] ?? 'EA',
'quantity' => $displayQuantity,
'quantity_formula' => $quantityFormula,
'base_price' => $basePrice,
'multiplier' => $priceResult['multiplier'],
'unit_price' => $basePrice,
'total_price' => $totalPrice,
'calculation_note' => $priceResult['calculation_note'],
'category_group' => $priceResult['category_group'],
];
}
// Step 8: 공정별 그룹화 및 items에 process_group 추가
$groupedItems = $this->groupItemsByProcess($calculatedItems, $tenantId);
// items에 process_group 필드 추가 (프론트엔드에서 분류에 사용)
$calculatedItems = $this->addProcessGroupToItems($calculatedItems, $groupedItems);
// Step 9: 소계 계산
$subtotals = [];
foreach ($groupedItems as $processType => $group) {
if (! is_array($group) || ! isset($group['items'])) {
continue;
}
$subtotals[$processType] = [
'name' => $group['name'] ?? $processType,
'count' => count($group['items']),
'subtotal' => $group['subtotal'] ?? 0,
];
}
$this->addDebugStep(9, '소계계산', $subtotals);
// Step 10: 최종 합계
$grandTotal = array_sum(array_column($calculatedItems, 'total_price'));
$this->addDebugStep(10, '최종합계', [
'item_count' => count($calculatedItems),
'grand_total' => $grandTotal,
'formatted' => number_format($grandTotal).'원',
]);
return [
'success' => true,
'finished_goods' => $finishedGoods,
'variables' => $calculatedVariables,
'items' => $calculatedItems,
'grouped_items' => $groupedItems,
'subtotals' => $subtotals,
'grand_total' => $grandTotal,
'debug_steps' => $this->debugSteps,
];
}
/**
* 카테고리 기반 단가 계산
*
* CategoryGroup을 사용하여 면적/중량/수량 기반 단가를 계산합니다.
* - 면적기반: 기본단가 × M (면적)
* - 중량기반: 기본단가 × K (중량)
* - 수량기반: 기본단가 × 1
*/
public function calculateCategoryPrice(
string $itemCategory,
float $basePrice,
array $variables,
?int $tenantId = null
): array {
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return [
'final_price' => $basePrice,
'calculation_note' => '테넌트 미설정',
'multiplier' => 1.0,
'category_group' => null,
];
}
// 카테고리 그룹 조회
$categoryGroup = CategoryGroup::findByItemCategory($tenantId, $itemCategory);
if (! $categoryGroup) {
$this->addDebugStep(5, '단가출처', [
'item_category' => $itemCategory,
'base_price' => $basePrice,
'category_group' => null,
'note' => '카테고리 그룹 미등록 - 수량단가 적용',
]);
return [
'final_price' => $basePrice,
'calculation_note' => '수량단가 (그룹 미등록)',
'multiplier' => 1.0,
'category_group' => null,
];
}
// CategoryGroup 모델의 calculatePrice 메서드 사용
$result = $categoryGroup->calculatePrice($basePrice, $variables);
$result['category_group'] = $categoryGroup->code;
$this->addDebugStep(5, '단가출처', [
'item_category' => $itemCategory,
'base_price' => $basePrice,
'category_group' => $categoryGroup->code,
'multiplier_variable' => $categoryGroup->multiplier_variable,
'multiplier_value' => $result['multiplier'],
'final_price' => $result['final_price'],
]);
return $result;
}
/**
* 품목 카테고리 그룹화 (동적 카테고리 시스템)
*
* 품목을 item_category 필드 기준으로 그룹화합니다.
* 카테고리 정보는 categories 테이블에서 code_group='item_category'로 조회합니다.
*
* 카테고리 구조:
* - BODY: 본체
* - BENDING 하위 카테고리:
* - BENDING_GUIDE: 절곡품 - 가이드레일
* - BENDING_CASE: 절곡품 - 케이스
* - BENDING_BOTTOM: 절곡품 - 하단마감재
* - MOTOR_CTRL: 모터 & 제어기
* - ACCESSORY: 부자재
*/
public function groupItemsByProcess(array $items, ?int $tenantId = null): array
{
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
}
// 품목 코드로 item_category 일괄 조회
$itemCodes = array_unique(array_column($items, 'item_code'));
if (empty($itemCodes)) {
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
}
// 품목별 item_category 조회
$itemCategories = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $itemCodes)
->whereNull('deleted_at')
->pluck('item_category', 'code')
->toArray();
// 카테고리 트리 조회 (item_category 코드 그룹)
$categoryTree = $this->getItemCategoryTree($tenantId);
// 카테고리 코드 → 정보 매핑 생성 (탭 구조에 맞게)
$categoryMapping = $this->buildCategoryMapping($categoryTree);
// 그룹별 분류를 위한 빈 구조 생성
$grouped = [];
foreach ($categoryMapping as $code => $info) {
$grouped[$code] = ['name' => $info['name'], 'items' => [], 'subtotal' => 0];
}
// 기타 그룹 추가
$grouped['OTHER'] = ['name' => '기타', 'items' => [], 'subtotal' => 0];
foreach ($items as $item) {
$categoryCode = $itemCategories[$item['item_code']] ?? 'OTHER';
// 매핑에 없는 카테고리는 기타로 분류
if (! isset($grouped[$categoryCode])) {
$categoryCode = 'OTHER';
}
$grouped[$categoryCode]['items'][] = $item;
$grouped[$categoryCode]['subtotal'] += $item['total_price'] ?? 0;
}
// 빈 그룹 제거
$grouped = array_filter($grouped, fn ($g) => ! empty($g['items']));
$this->addDebugStep(8, '카테고리그룹화', [
'total_items' => count($items),
'groups' => array_map(fn ($g) => [
'name' => $g['name'],
'count' => count($g['items']),
'subtotal' => $g['subtotal'],
], $grouped),
]);
return $grouped;
}
/**
* item_category 카테고리 트리 조회
*
* categories 테이블에서 code_group='item_category'인 카테고리를 트리 구조로 조회
*/
private function getItemCategoryTree(int $tenantId): array
{
$categories = DB::table('categories')
->where('tenant_id', $tenantId)
->where('code_group', 'item_category')
->where('is_active', 1)
->whereNull('deleted_at')
->orderBy('parent_id')
->orderBy('sort_order')
->orderBy('id')
->get(['id', 'parent_id', 'code', 'name'])
->toArray();
// 트리 구조로 변환
$categoryIds = array_column($categories, 'id');
$rootIds = array_filter($categories, fn ($c) => $c->parent_id === null || ! in_array($c->parent_id, $categoryIds));
$byParent = [];
foreach ($categories as $c) {
$parentKey = in_array($c->id, array_column($rootIds, 'id')) ? 0 : ($c->parent_id ?? 0);
$byParent[$parentKey][] = $c;
}
$buildTree = function ($parentId) use (&$buildTree, &$byParent) {
$nodes = $byParent[$parentId] ?? [];
return array_map(function ($n) use ($buildTree) {
return [
'id' => $n->id,
'code' => $n->code,
'name' => $n->name,
'children' => $buildTree($n->id),
];
}, $nodes);
};
return $buildTree(0);
}
/**
* 카테고리 트리를 탭 구조에 맞게 매핑 생성
*
* BENDING 카테고리의 경우 하위 카테고리를 개별 탭으로,
* 나머지는 그대로 1depth 탭으로 매핑
*/
private function buildCategoryMapping(array $categoryTree): array
{
$mapping = [];
foreach ($categoryTree as $category) {
if ($category['code'] === 'BENDING' && ! empty($category['children'])) {
// BENDING: 하위 카테고리를 개별 탭으로
foreach ($category['children'] as $subCategory) {
$mapping[$subCategory['code']] = [
'name' => '절곡품 - '.$subCategory['name'],
'parentCode' => 'BENDING',
];
}
} else {
// 나머지: 1depth 탭
$mapping[$category['code']] = [
'name' => $category['name'],
'parentCode' => null,
];
}
}
return $mapping;
}
/**
* items 배열에 카테고리 정보 필드 추가
*
* groupedItems에서 각 아이템의 소속 그룹을 찾아 카테고리 관련 필드를 추가합니다.
* 프론트엔드에서 탭별 분류에 사용됩니다.
*
* 추가되는 필드:
* - process_group: 그룹명 (레거시 호환)
* - process_group_key: 그룹키 (레거시 호환)
* - category_code: 동적 카테고리 코드 (신규 시스템)
*/
private function addProcessGroupToItems(array $items, array $groupedItems): array
{
// 각 그룹의 아이템 코드 → 그룹정보 매핑 생성
$itemCodeToGroup = [];
foreach ($groupedItems as $groupKey => $group) {
if (! isset($group['items']) || ! is_array($group['items'])) {
continue;
}
foreach ($group['items'] as $groupItem) {
$itemCodeToGroup[$groupItem['item_code']] = [
'key' => $groupKey,
'name' => $group['name'] ?? $groupKey,
];
}
}
// items 배열에 카테고리 정보 추가
return array_map(function ($item) use ($itemCodeToGroup) {
$groupInfo = $itemCodeToGroup[$item['item_code']] ?? ['key' => 'OTHER', 'name' => '기타'];
$item['process_group'] = $groupInfo['name'];
$item['process_group_key'] = $groupInfo['key']; // 레거시 호환
$item['category_code'] = $groupInfo['key']; // 신규 동적 카테고리 시스템
return $item;
}, $items);
}
// =========================================================================
// 품목 조회 메서드
// =========================================================================
/**
* 품목 단가 조회
*
* @param string $itemCode 품목 코드
* @param int|null $tenantIdOverride 테넌트 ID (외부 호출 시 사용)
*/
public function getItemPrice(string $itemCode, ?int $tenantIdOverride = null): float
{
$tenantId = $tenantIdOverride ?? $this->tenantId();
if (! $tenantId) {
$this->errors[] = __('error.tenant_id_required');
return 0;
}
// 1. Price 모델에서 조회
$price = Price::getSalesPriceByItemCode($tenantId, $itemCode);
if ($price > 0) {
return $price;
}
// 2. Fallback: items.attributes.salesPrice에서 조회
$item = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first();
if ($item && ! empty($item->attributes)) {
$attributes = json_decode($item->attributes, true);
return (float) ($attributes['salesPrice'] ?? 0);
}
return 0;
}
/**
* 품목 상세 정보 조회 (BOM 트리 포함)
*/
public function getItemDetails(string $itemCode, ?int $tenantId = null): ?array
{
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return null;
}
$item = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first();
if (! $item) {
return null;
}
return [
'id' => $item->id,
'code' => $item->code,
'name' => $item->name,
'item_type' => $item->item_type,
'item_type_label' => $this->getItemTypeLabel($item->item_type),
'item_category' => $item->item_category,
'process_type' => $item->process_type,
'unit' => $item->unit,
'description' => $item->description,
'attributes' => json_decode($item->attributes ?? '{}', true),
'bom' => $this->getBomTree($tenantId, $item->id, json_decode($item->bom ?? '[]', true)),
'has_bom' => ! empty($item->bom) && $item->bom !== '[]',
];
}
/**
* BOM 트리 재귀적으로 조회
*/
private function getBomTree(int $tenantId, int $parentItemId, array $bomData, int $depth = 0): array
{
// 무한 루프 방지
if ($depth > 10 || empty($bomData)) {
return [];
}
$children = [];
$childIds = array_column($bomData, 'child_item_id');
if (empty($childIds)) {
return [];
}
// 자식 품목들 일괄 조회
$childItems = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('id', $childIds)
->whereNull('deleted_at')
->get()
->keyBy('id');
foreach ($bomData as $bomItem) {
$childItemId = $bomItem['child_item_id'] ?? null;
$quantity = $bomItem['quantity'] ?? 1;
if (! $childItemId) {
continue;
}
$childItem = $childItems->get($childItemId);
if (! $childItem) {
continue;
}
$childBomData = json_decode($childItem->bom ?? '[]', true);
$children[] = [
'id' => $childItem->id,
'code' => $childItem->code,
'name' => $childItem->name,
'item_type' => $childItem->item_type,
'item_type_label' => $this->getItemTypeLabel($childItem->item_type),
'unit' => $childItem->unit,
'quantity' => (float) $quantity,
'description' => $childItem->description,
'has_bom' => ! empty($childBomData),
'children' => $this->getBomTree($tenantId, $childItem->id, $childBomData, $depth + 1),
];
}
return $children;
}
/**
* 품목 유형 라벨
*/
public function getItemTypeLabel(string $itemType): string
{
return match ($itemType) {
'FG' => '완제품',
'PT' => '부품',
'SM' => '부자재',
'RM' => '원자재',
'CS' => '소모품',
default => $itemType,
};
}
/**
* 품목의 item_category 조회
*/
private function getItemCategory(string $itemCode, int $tenantId): string
{
$category = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->value('item_category');
return $category ?? '기타';
}
/**
* 품목마스터에서 규격(specification)과 단위(unit) 조회
*
* @param string $itemCode 품목 코드
* @return array ['specification' => string|null, 'unit' => string]
*/
private function getItemSpecAndUnit(string $itemCode): array
{
$tenantId = $this->tenantId();
if (! $tenantId) {
return ['specification' => null, 'unit' => 'EA'];
}
// Items 테이블에서 기본 정보 조회
$item = Item::where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->with('details')
->first();
if (! $item) {
return ['specification' => null, 'unit' => 'EA'];
}
// specification은 ItemDetail에서, unit은 Item에서 가져옴
$specification = $item->details?->specification ?? null;
$unit = $item->unit ?? 'EA';
return [
'specification' => $specification,
'unit' => $unit,
];
}
/**
* BOM 전개 (수량 수식 포함)
*/
private function expandBomWithFormulas(array $finishedGoods, array $variables, int $tenantId): array
{
$bomItems = [];
// items 테이블에서 완제품의 bom 필드 조회
$item = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $finishedGoods['code'])
->whereNull('deleted_at')
->first();
if (! $item || empty($item->bom)) {
return $bomItems;
}
$bomData = json_decode($item->bom, true);
if (! is_array($bomData)) {
return $bomItems;
}
// BOM 데이터 형식: child_item_id 기반 또는 코드 기반 (Design 형식: childItemCode)
foreach ($bomData as $bomEntry) {
$childItemId = $bomEntry['child_item_id'] ?? null;
$childItemCode = $bomEntry['item_code'] ?? $bomEntry['childItemCode'] ?? null;
$quantityFormula = $bomEntry['quantityFormula'] ?? $bomEntry['quantity'] ?? '1';
if ($childItemId) {
// ID 기반 조회 (item_details 조인하여 specification 포함)
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.id', $childItemId)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
} elseif ($childItemCode) {
// 코드 기반 조회 (item_details 조인하여 specification 포함)
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.code', $childItemCode)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
} else {
continue;
}
if ($childItem) {
$bomItems[] = [
'item_code' => $childItem->code,
'item_name' => $childItem->name,
'item_category' => $childItem->item_category,
'process_type' => $childItem->process_type,
'quantity_formula' => (string) $quantityFormula,
'unit' => $childItem->unit,
'specification' => $childItem->specification,
];
// 재귀적 BOM 전개 (반제품인 경우)
if (in_array($childItem->item_type, ['SF', 'PT'])) {
$childDetails = $this->getItemDetails($childItem->code, $tenantId);
if ($childDetails && $childDetails['has_bom']) {
$childBomItems = $this->expandBomWithFormulas($childDetails, $variables, $tenantId);
$bomItems = array_merge($bomItems, $childBomItems);
}
}
}
}
return $bomItems;
}
/**
* 수량 수식 평가
*/
private function evaluateQuantityFormula(string $formula, array $variables): float
{
// 빈 수식은 기본값 1 반환
if (empty(trim($formula))) {
return 1.0;
}
// 숫자만 있으면 바로 반환
if (is_numeric($formula)) {
return (float) $formula;
}
// 변수 치환
$expression = $formula;
foreach ($variables as $var => $value) {
$expression = preg_replace('/\b'.preg_quote($var, '/').'\b/', (string) $value, $expression);
}
// 계산
try {
return $this->calculateExpression($expression);
} catch (\Throwable $e) {
$this->errors[] = __('error.quantity_formula_error', ['formula' => $formula, 'error' => $e->getMessage()]);
return 1;
}
}
// =========================================================================
// BOM 원자재(Leaf Node) 조회 - 소요자재내역용
// =========================================================================
/**
* BOM 트리에서 원자재(leaf nodes)만 추출
*
* 완제품의 BOM을 재귀적으로 탐색하여 실제 구매해야 할 원자재 목록을 반환합니다.
* - Leaf node: BOM이 없는 품목 또는 item_type이 RM(원자재), SM(부자재), CS(소모품)인 품목
* - 수량은 상위 구조의 수량을 누적하여 계산
*
* @param string $finishedGoodsCode 완제품 코드
* @param float $orderQuantity 주문 수량 (QTY)
* @param array $variables 변수 배열 (W0, H0 등)
* @param int|null $tenantId 테넌트 ID
* @return array 원자재 목록 (leaf nodes)
*/
public function getBomLeafMaterials(
string $finishedGoodsCode,
float $orderQuantity,
array $variables,
?int $tenantId = null
): array {
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return [];
}
// 완제품 조회
$finishedGoods = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $finishedGoodsCode)
->whereNull('deleted_at')
->first();
if (! $finishedGoods || empty($finishedGoods->bom)) {
return [];
}
$bomData = json_decode($finishedGoods->bom, true);
if (! is_array($bomData) || empty($bomData)) {
return [];
}
// 재귀적으로 leaf nodes 수집
$leafMaterials = [];
$this->collectLeafMaterials(
$bomData,
$tenantId,
$orderQuantity,
$variables,
$leafMaterials
);
// 동일 품목 코드 병합 (수량 합산)
return $this->mergeLeafMaterials($leafMaterials, $tenantId);
}
/**
* 재귀적으로 BOM 트리를 탐색하여 leaf materials 수집
*
* @param array $bomData BOM 데이터 배열
* @param int $tenantId 테넌트 ID
* @param float $parentQuantity 상위 품목 수량 (누적)
* @param array $variables 변수 배열
* @param array &$leafMaterials 결과 배열 (참조)
* @param int $depth 재귀 깊이 (무한루프 방지)
*/
private function collectLeafMaterials(
array $bomData,
int $tenantId,
float $parentQuantity,
array $variables,
array &$leafMaterials,
int $depth = 0
): void {
// 무한 루프 방지
if ($depth > 10) {
return;
}
foreach ($bomData as $bomEntry) {
$childItemId = $bomEntry['child_item_id'] ?? null;
$childItemCode = $bomEntry['item_code'] ?? $bomEntry['childItemCode'] ?? null;
$quantityFormula = $bomEntry['quantityFormula'] ?? $bomEntry['quantity'] ?? '1';
// 수량 계산
$itemQuantity = $this->evaluateQuantityFormula((string) $quantityFormula, $variables);
$totalQuantity = $parentQuantity * $itemQuantity;
// 자식 품목 조회
$childItem = null;
if ($childItemId) {
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.id', $childItemId)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
} elseif ($childItemCode) {
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.code', $childItemCode)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
}
if (! $childItem) {
continue;
}
// 자식의 BOM 확인
$childBomData = json_decode($childItem->bom ?? '[]', true);
$hasChildBom = ! empty($childBomData) && is_array($childBomData);
// Leaf node 판단 조건:
// 1. BOM이 없는 품목
// 2. 또는 item_type이 원자재(RM), 부자재(SM), 소모품(CS)인 품목
$isLeafType = in_array($childItem->item_type, ['RM', 'SM', 'CS']);
if (! $hasChildBom || $isLeafType) {
// Leaf node - 결과에 추가
$leafMaterials[] = [
'item_id' => $childItem->id,
'item_code' => $childItem->code,
'item_name' => $childItem->name,
'item_type' => $childItem->item_type,
'item_category' => $childItem->item_category,
'specification' => $childItem->specification,
'unit' => $childItem->unit ?? 'EA',
'quantity' => $totalQuantity,
'process_type' => $childItem->process_type,
];
} else {
// 중간 노드 (부품/반제품) - 재귀 탐색
$this->collectLeafMaterials(
$childBomData,
$tenantId,
$totalQuantity,
$variables,
$leafMaterials,
$depth + 1
);
}
}
}
/**
* 동일 품목 코드의 leaf materials 병합 (수량 합산)
*
* @param array $leafMaterials 원자재 목록
* @param int $tenantId 테넌트 ID
* @return array 병합된 원자재 목록
*/
private function mergeLeafMaterials(array $leafMaterials, int $tenantId): array
{
$merged = [];
foreach ($leafMaterials as $material) {
$code = $material['item_code'];
if (isset($merged[$code])) {
// 동일 품목 - 수량 합산
$merged[$code]['quantity'] += $material['quantity'];
} else {
// 새 품목 추가
$merged[$code] = $material;
}
}
// 단가 조회 및 금액 계산
$result = [];
foreach ($merged as $material) {
$unitPrice = $this->getItemPrice($material['item_code']);
$totalPrice = $material['quantity'] * $unitPrice;
$result[] = array_merge($material, [
'unit_price' => $unitPrice,
'total_price' => round($totalPrice, 2),
]);
}
return array_values($result);
}
// =========================================================================
// 경동기업 전용 계산 (tenant_id = 287)
// =========================================================================
/**
* 경동기업 전용 BOM 계산
*
* 5130 레거시 시스템의 견적 로직을 구현한 KyungdongFormulaHandler 사용
* - 3차원 조건 모터 용량 계산 (제품타입 × 인치 × 중량)
* - 브라켓 크기 결정
* - 10종 절곡품 계산
* - 3종 부자재 계산
*
* @param string $finishedGoodsCode 완제품 코드
* @param array $inputVariables 입력 변수 (W0, H0, QTY 등)
* @param int $tenantId 테넌트 ID
* @return array 계산 결과
*/
private function calculateKyungdongBom(
string $finishedGoodsCode,
array $inputVariables,
int $tenantId
): array {
$this->addDebugStep(0, '경동전용계산', [
'tenant_id' => $tenantId,
'handler' => 'KyungdongFormulaHandler',
'finished_goods' => $finishedGoodsCode,
]);
// Step 1: 입력값 수집
$W0 = (float) ($inputVariables['W0'] ?? 0);
$H0 = (float) ($inputVariables['H0'] ?? 0);
$QTY = (int) ($inputVariables['QTY'] ?? 1);
$bracketInch = $inputVariables['bracket_inch'] ?? '5';
// product_type: 프론트 입력값 우선, 없으면 FG item_category에서 자동 추론
$finishedGoods = $this->getItemDetails($finishedGoodsCode, $tenantId);
$itemCategory = $finishedGoods['item_category'] ?? null;
$productType = $inputVariables['product_type'] ?? match (true) {
$itemCategory === '철재' => 'steel',
str_contains($itemCategory ?? '', '슬랫') => 'slat',
default => 'screen',
};
$this->addDebugStep(1, '입력값수집', [
'formulas' => [
['var' => 'W0', 'desc' => '개구부 폭', 'value' => $W0, 'unit' => 'mm'],
['var' => 'H0', 'desc' => '개구부 높이', 'value' => $H0, 'unit' => 'mm'],
['var' => 'QTY', 'desc' => '수량', 'value' => $QTY, 'unit' => 'EA'],
['var' => 'bracket_inch', 'desc' => '브라켓 인치', 'value' => $bracketInch, 'unit' => '인치'],
['var' => 'product_type', 'desc' => '제품 타입', 'value' => $productType, 'unit' => ''],
['var' => 'product_model', 'desc' => '모델코드', 'value' => $inputVariables['product_model'] ?? 'KSS01', 'unit' => ''],
['var' => 'finishing_type', 'desc' => '마감타입', 'value' => $inputVariables['finishing_type'] ?? 'SUS', 'unit' => ''],
['var' => 'installation_type', 'desc' => '설치타입', 'value' => $inputVariables['installation_type'] ?? '벽면형', 'unit' => ''],
],
]);
// Step 2: 완제품 정보 활용 (Step 1에서 이미 조회됨)
if ($finishedGoods) {
$this->addDebugStep(2, '완제품선택', [
'code' => $finishedGoods['code'],
'name' => $finishedGoods['name'],
'item_category' => $finishedGoods['item_category'] ?? 'N/A',
]);
} else {
// 경동 전용: 완제품 미등록 상태에서도 견적 계산 진행
$finishedGoods = [
'code' => $finishedGoodsCode,
'name' => $finishedGoodsCode,
'item_category' => 'estimate',
];
$this->addDebugStep(2, '완제품선택', [
'code' => $finishedGoodsCode,
'note' => '경동 전용 계산 - 완제품 미등록 상태로 진행',
]);
}
// KyungdongFormulaHandler 인스턴스 생성
$handler = new KyungdongFormulaHandler;
// Step 3: 경동 전용 변수 계산 (제품타입별 면적/중량 공식)
$W1 = $W0 + 160;
$H1 = $H0 + 350;
if ($productType === 'slat') {
// 슬랫: W0 × (H0 + 50) / 1M
$area = ($W0 * ($H0 + 50)) / 1000000;
$weight = $area * 25;
$areaFormula = '(W0 × (H0 + 50)) / 1,000,000';
$areaCalc = "({$W0} × ({$H0} + 50)) / 1,000,000";
$weightFormula = 'AREA × 25';
$weightCalc = "{$area} × 25";
} elseif ($productType === 'steel') {
// 철재: W1 × (H1 + 550) / 1M, 중량 = 면적 × 25
$area = ($W1 * ($H1 + 550)) / 1000000;
$weight = $area * 25;
$areaFormula = '(W1 × (H1 + 550)) / 1,000,000';
$areaCalc = "({$W1} × ({$H1} + 550)) / 1,000,000";
$weightFormula = 'AREA × 25';
$weightCalc = "{$area} × 25";
} else {
// 스크린: W1 × (H1 + 550) / 1M
$area = ($W1 * ($H1 + 550)) / 1000000;
$weight = $area * 2 + ($W0 / 1000) * 14.17;
$areaFormula = '(W1 × (H1 + 550)) / 1,000,000';
$areaCalc = "({$W1} × ({$H1} + 550)) / 1,000,000";
$weightFormula = 'AREA × 2 + (W0 / 1000) × 14.17';
$weightCalc = "{$area} × 2 + ({$W0} / 1000) × 14.17";
}
// 모터 용량 결정 (입력값 우선, 없으면 자동계산)
$motorCapacity = $inputVariables['MOTOR_CAPACITY']
?? $inputVariables['motor_capacity']
?? $handler->calculateMotorCapacity($productType, $weight, $bracketInch);
// 브라켓 크기 결정 (입력값 우선, 없으면 자동계산)
$bracketSize = $inputVariables['BRACKET_SIZE']
?? $inputVariables['bracket_size']
?? $handler->calculateBracketSize($weight, $bracketInch);
// 핸들러가 필요한 키 보장 (inputVariables에서 전달되지 않으면 기본값)
$productModel = $inputVariables['product_model'] ?? 'KSS01';
$finishingType = $inputVariables['finishing_type'] ?? 'SUS';
$installationType = $inputVariables['installation_type'] ?? '벽면형';
// 모터 전압: 프론트 MP(single/three) → motor_voltage(220V/380V) 매핑
$motorVoltage = $inputVariables['motor_voltage'] ?? match ($inputVariables['MP'] ?? 'single') {
'three' => '380V',
default => '220V',
};
$calculatedVariables = array_merge($inputVariables, [
'W0' => $W0,
'H0' => $H0,
'QTY' => $QTY,
'W1' => $W1,
'H1' => $H1,
'AREA' => round($area, 4),
'WEIGHT' => round($weight, 2),
'MOTOR_CAPACITY' => $motorCapacity,
'BRACKET_SIZE' => $bracketSize,
'bracket_inch' => $bracketInch,
'product_type' => $productType,
'product_model' => $productModel,
'finishing_type' => $finishingType,
'installation_type' => $installationType,
'motor_voltage' => $motorVoltage,
]);
$this->addDebugStep(3, '변수계산', [
'formulas' => [
[
'var' => 'W1',
'desc' => '제작 폭',
'formula' => 'W0 + 160',
'calculation' => "{$W0} + 160",
'result' => $W1,
'unit' => 'mm',
],
[
'var' => 'H1',
'desc' => '제작 높이',
'formula' => 'H0 + 350',
'calculation' => "{$H0} + 350",
'result' => $H1,
'unit' => 'mm',
],
[
'var' => 'AREA',
'desc' => '면적',
'formula' => $areaFormula,
'calculation' => $areaCalc,
'result' => round($area, 4),
'unit' => '㎡',
],
[
'var' => 'WEIGHT',
'desc' => '중량',
'formula' => $weightFormula,
'calculation' => $weightCalc,
'result' => round($weight, 2),
'unit' => 'kg',
],
[
'var' => 'MOTOR_CAPACITY',
'desc' => '모터 용량',
'formula' => '중량/브라켓 기준표 조회',
'calculation' => "WEIGHT({$weight}) + INCH({$bracketInch}) → 조회",
'result' => $motorCapacity,
'unit' => '',
],
[
'var' => 'BRACKET_SIZE',
'desc' => '브라켓 크기',
'formula' => '중량 기준표 조회',
'calculation' => "WEIGHT({$weight}) → 조회",
'result' => $bracketSize,
'unit' => '인치',
],
],
]);
// Step 4-7: 동적 항목 계산 (KyungdongFormulaHandler 사용)
$dynamicItems = $handler->calculateDynamicItems($calculatedVariables);
$this->addDebugStep(4, 'BOM전개', [
'total_items' => count($dynamicItems),
'item_categories' => array_unique(array_column($dynamicItems, 'category')),
'items' => array_map(fn ($item) => [
'name' => $item['item_name'],
'category' => $item['category'],
], $dynamicItems),
]);
// Step 5-7: 단가 계산 (각 항목별)
$calculatedItems = [];
$itemFormulas = [];
foreach ($dynamicItems as $item) {
$itemFormulas[] = [
'item' => $item['item_name'],
'qty_formula' => $item['quantity_formula'] ?? '고정값',
'qty_result' => $item['quantity'],
'unit_price' => $item['unit_price'],
'price_formula' => '수량 × 단가',
'price_calc' => "{$item['quantity']} × {$item['unit_price']}",
'total' => $item['total_price'],
];
$calculatedItems[] = [
'item_id' => $item['item_id'] ?? null,
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'],
'item_category' => $item['category'],
'specification' => $item['specification'] ?? '',
'unit' => $item['unit'],
'quantity' => $item['quantity'],
'quantity_formula' => $item['quantity_formula'] ?? '',
'unit_price' => $item['unit_price'],
'total_price' => $item['total_price'],
'category_group' => $item['category'],
'process_group' => $item['category'],
'calculation_note' => '경동기업 전용 계산',
];
}
$this->addDebugStep(6, '수량계산', [
'formulas' => $itemFormulas,
]);
$this->addDebugStep(7, '금액계산', [
'formulas' => $itemFormulas,
]);
// Step 8: 카테고리별 그룹화
$groupedItems = [];
foreach ($calculatedItems as $item) {
$category = $item['category_group'];
if (! isset($groupedItems[$category])) {
$groupedItems[$category] = [
'name' => $this->getKyungdongCategoryName($category),
'items' => [],
'subtotal' => 0,
];
}
$groupedItems[$category]['items'][] = $item;
$groupedItems[$category]['subtotal'] += $item['total_price'];
}
$this->addDebugStep(8, '카테고리그룹화', [
'groups' => array_map(fn ($g) => [
'name' => $g['name'],
'count' => count($g['items']),
'subtotal' => $g['subtotal'],
], $groupedItems),
]);
// Step 9: 소계 계산
$subtotals = [];
$subtotalFormulas = [];
foreach ($groupedItems as $category => $group) {
$subtotals[$category] = [
'name' => $group['name'],
'count' => count($group['items']),
'subtotal' => $group['subtotal'],
];
$subtotalFormulas[] = [
'category' => $group['name'],
'formula' => implode(' + ', array_map(fn ($i) => $i['item_name'], $group['items'])),
'result' => $group['subtotal'],
];
}
$this->addDebugStep(9, '소계계산', [
'formulas' => $subtotalFormulas,
'subtotals' => $subtotals,
]);
// Step 10: 최종 합계
$grandTotal = array_sum(array_column($calculatedItems, 'total_price'));
$subtotalValues = array_column($subtotals, 'subtotal');
$this->addDebugStep(10, '최종합계', [
'formula' => implode(' + ', array_column($subtotals, 'name')),
'calculation' => implode(' + ', array_map(fn ($v) => number_format($v), $subtotalValues)),
'result' => $grandTotal,
'formatted' => number_format($grandTotal).'원',
]);
return [
'success' => true,
'finished_goods' => $finishedGoods,
'variables' => $calculatedVariables,
'items' => $calculatedItems,
'grouped_items' => $groupedItems,
'subtotals' => $subtotals,
'grand_total' => $grandTotal,
'debug_steps' => $this->debugSteps,
'calculation_type' => 'kyungdong',
];
}
/**
* 경동기업 카테고리명 반환
*/
private function getKyungdongCategoryName(string $category): string
{
return match ($category) {
'material' => '주자재',
'motor' => '모터',
'controller' => '제어기',
'steel' => '절곡품',
'parts' => '부자재',
default => $category,
};
}
}