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; } /** * 디버그 단계 기록 */ /** * FG 코드에서 모델/설치타입/마감타입 파싱 * 형식: FG-{MODEL}-{INSTALLATION}-{FINISHING} (예: FG-KQTS01-벽면형-SUS) * 파싱 실패 시 null 반환 (추후 FG 코드 형식 변경 대비) */ private function parseFgCode(string $code): array { $parts = explode('-', $code); if (count($parts) >= 4 && $parts[0] === 'FG') { return [ 'model' => $parts[1], 'installation' => $parts[2], 'finishing' => $parts[3], ]; } return []; } 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, ]; } // 테넌트 전용 핸들러 분기 (Zero Config: class_exists 자동 발견) $tenantHandler = FormulaHandlerFactory::make($tenantId); if ($tenantHandler !== null) { return $this->calculateTenantBom($tenantHandler, $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 계산 * * FormulaHandlerFactory가 발견한 TenantFormulaHandler 구현체를 사용. * 핸들러가 제공하는 calculateMotorCapacity, calculateBracketSize, * calculateDynamicItems 메서드로 BOM을 계산한다. * * @param TenantFormulaHandler $handler 테넌트 전용 핸들러 * @param string $finishedGoodsCode 완제품 코드 * @param array $inputVariables 입력 변수 (W0, H0, QTY 등) * @param int $tenantId 테넌트 ID * @return array 계산 결과 */ private function calculateTenantBom( TenantFormulaHandler $handler, string $finishedGoodsCode, array $inputVariables, int $tenantId ): array { $handlerClass = get_class($handler); $this->addDebugStep(0, '테넌트전용계산', [ 'tenant_id' => $tenantId, 'handler' => class_basename($handlerClass), 'handler_class' => $handlerClass, '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' => '테넌트 전용 계산 - 완제품 미등록 상태로 진행', ]); } // 핸들러는 파라미터로 이미 전달됨 (FormulaHandlerFactory가 생성) // Step 3: 경동 전용 변수 계산 (제품타입별 면적/중량 공식) // 스크린: +160, 철재: +110 (5130 레거시 기준) $W1 = $W0 + ($productType === 'steel' ? 110 : 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); // 핸들러가 필요한 키 보장 // 우선순위: 입력값 > FG 코드 파싱 > 기본값 $fgParsed = $this->parseFgCode($finishedGoodsCode); $productModel = $inputVariables['product_model'] ?? $fgParsed['model'] ?? 'KSS01'; $finishingType = $inputVariables['finishing_type'] ?? $fgParsed['finishing'] ?? 'SUS'; // 가이드레일 설치타입: 입력값 > FG파싱 > GT매핑 > 기본값 $installationType = $inputVariables['installation_type'] ?? $fgParsed['installation'] ?? match ($inputVariables['GT'] ?? 'wall') { 'floor' => '측면형', 'mixed' => '혼합형', default => '벽면형', }; // 모터 전압: 프론트 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' => $productType === 'steel' ? 'W0 + 110' : 'W0 + 160', 'calculation' => $productType === 'steel' ? "{$W0} + 110" : "{$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: 동적 항목 계산 (TenantFormulaHandler 사용) $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->getTenantCategoryName($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' => 'tenant_handler', ]; } /** * 테넌트 핸들러용 카테고리명 반환 */ private function getTenantCategoryName(string $category): string { return match ($category) { 'material' => '주자재', 'motor' => '모터', 'controller' => '제어기', 'steel' => '절곡품', 'parts' => '부자재', default => $category, }; } }