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); $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, }; } // ========================================================================= // 디버그 모드 (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, ]; } // 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 = 140; // 스크린 기본 마진 $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, '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: 공정별 그룹화 $groupedItems = $this->groupItemsByProcess($calculatedItems, $tenantId); // 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; } /** * 공정별 품목 그룹화 * * 품목을 process_type에 따라 그룹화합니다: * - screen: 스크린 공정 (원단, 패널, 도장 등) * - bending: 절곡 공정 (알루미늄, 스테인리스 등) * - steel: 철재 공정 (철재, 강판 등) * - electric: 전기 공정 (모터, 제어반, 전선 등) * - assembly: 조립 공정 (볼트, 너트, 브라켓 등) */ public function groupItemsByProcess(array $items, ?int $tenantId = null): array { $tenantId = $tenantId ?? $this->tenantId(); if (! $tenantId) { return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]]; } // 품목 코드로 process_type 일괄 조회 $itemCodes = array_unique(array_column($items, 'item_code')); if (empty($itemCodes)) { return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]]; } $processTypes = DB::table('items') ->where('tenant_id', $tenantId) ->whereIn('code', $itemCodes) ->whereNull('deleted_at') ->pluck('process_type', 'code') ->toArray(); // 그룹별 분류 $grouped = [ 'screen' => ['name' => '스크린 공정', 'items' => [], 'subtotal' => 0], 'bending' => ['name' => '절곡 공정', 'items' => [], 'subtotal' => 0], 'steel' => ['name' => '철재 공정', 'items' => [], 'subtotal' => 0], 'electric' => ['name' => '전기 공정', 'items' => [], 'subtotal' => 0], 'assembly' => ['name' => '조립 공정', 'items' => [], 'subtotal' => 0], 'other' => ['name' => '기타', 'items' => [], 'subtotal' => 0], ]; foreach ($items as $item) { $processType = $processTypes[$item['item_code']] ?? 'other'; if (! isset($grouped[$processType])) { $processType = 'other'; } $grouped[$processType]['items'][] = $item; $grouped[$processType]['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; } // ========================================================================= // 품목 조회 메서드 // ========================================================================= /** * 품목 단가 조회 */ private function getItemPrice(string $itemCode): float { $tenantId = $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 ?? '기타'; } /** * 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 기반 조회 $childItem = DB::table('items') ->where('tenant_id', $tenantId) ->where('id', $childItemId) ->whereNull('deleted_at') ->first(); } elseif ($childItemCode) { // 코드 기반 조회 $childItem = DB::table('items') ->where('tenant_id', $tenantId) ->where('code', $childItemCode) ->whereNull('deleted_at') ->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, ]; // 재귀적 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; } } }