false, 'errors' => ['수식이 비어있습니다.']]; } // 괄호 매칭 검증 if (! $this->validateParentheses($formula)) { $errors[] = '괄호가 올바르게 닫히지 않았습니다.'; } // 변수 추출 및 검증 $variables = $this->extractVariables($formula); // 지원 함수 검증 $functions = $this->extractFunctions($formula); $supportedFunctions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT']; foreach ($functions as $func) { if (! in_array(strtoupper($func), $supportedFunctions)) { $errors[] = "지원하지 않는 함수입니다: {$func}"; } } return [ 'success' => empty($errors), 'errors' => $errors, 'variables' => $variables, 'functions' => $functions, ]; } /** * 수식 평가 */ 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); // 최종 계산 $result = $this->calculateExpression($expression); return $result; } catch (\Exception $e) { $this->errors[] = $e->getMessage(); return null; } } /** * 범위별 수식 평가 */ public function evaluateRange(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; } /** * 매핑 수식 평가 */ public function evaluateMapping(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; } /** * 전체 수식 실행 (카테고리 순서대로) */ public function executeAll(Collection $formulasByCategory, array $inputVariables = []): array { $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, ]; // range/mapping 결과에서 품목 자동 추출 if (in_array($formula->type, [QuoteFormula::TYPE_RANGE, QuoteFormula::TYPE_MAPPING])) { $extractedItem = $this->extractItemFromResult($result, $formula); if ($extractedItem) { $items[] = $extractedItem; } } } 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, ]; } /** * range/mapping 결과에서 품목 정보 추출 */ private function extractItemFromResult(mixed $result, QuoteFormula $formula): ?array { // JSON 문자열이면 파싱 if (is_string($result)) { $decoded = json_decode($result, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $result = $decoded; } } // 배열이고 item_code가 있으면 품목으로 변환 if (is_array($result) && isset($result['item_code'])) { $quantity = $result['quantity'] ?? 1; $itemCode = $result['item_code']; $unitPrice = $this->getItemPrice($itemCode); // 수량이 수식이면 평가 if (! is_numeric($quantity)) { $quantity = $this->evaluate((string) $quantity); } return [ 'item_code' => $itemCode, 'item_name' => $result['value'] ?? $itemCode, 'specification' => $result['note'] ?? null, 'unit' => 'EA', 'quantity' => (float) $quantity, 'unit_price' => $unitPrice, 'total_price' => (float) $quantity * $unitPrice, 'formula_variable' => $formula->variable, 'auto_selected' => true, ]; } return null; } /** * 단일 수식 실행 */ 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->evaluateRange($formula, $this->variables), QuoteFormula::TYPE_MAPPING => $this->evaluateMapping($formula, $this->variables), default => null, }; } // ========================================================================= // 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] ?? []); // 함수명 제외 $functions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT']; return array_values(array_diff($variables, $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 { // eval 대신 안전한 계산 라이브러리 사용 권장 // 여기서는 간단히 eval 사용 (프로덕션에서는 symfony/expression-language 등 사용) return (float) eval("return {$expression};"); } catch (\Throwable $e) { $this->errors[] = "계산 오류: {$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 getItemPrice(string $itemCode): float { $tenantId = $this->currentTenantId ?? session('selected_tenant_id'); if (! $tenantId) { $this->errors[] = '테넌트 ID가 설정되지 않았습니다.'; return 0; } // 1. Price 모델에서 조회 $price = \App\Models\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; } /** * 에러 목록 반환 */ public function getErrors(): array { return $this->errors; } /** * 현재 변수 상태 반환 */ public function getVariables(): array { return $this->variables; } /** * 변수 초기화 */ public function resetVariables(): void { $this->variables = []; $this->errors = []; } /** * 품목 상세 정보 조회 (BOM 트리 포함) */ public function getItemDetails(string $itemCode): ?array { $tenantId = $this->currentTenantId ?? session('selected_tenant_id'); 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, // 제품 카테고리 (SCREEN, STEEL 등) '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, }; } /** * 품목 목록에 상세 정보 추가 */ public function enrichItemsWithDetails(array $items): array { $tenantId = $this->currentTenantId ?? session('selected_tenant_id'); if (! $tenantId) { return $items; } // 품목 코드 수집 $itemCodes = array_unique(array_column($items, 'item_code')); if (empty($itemCodes)) { return $items; } // 품목 정보 일괄 조회 $itemsData = DB::table('items') ->where('tenant_id', $tenantId) ->whereIn('code', $itemCodes) ->whereNull('deleted_at') ->get() ->keyBy('code'); // 품목별 상세 정보 추가 foreach ($items as &$item) { $itemData = $itemsData->get($item['item_code']); if ($itemData) { $bomData = json_decode($itemData->bom ?? '[]', true); $item['item_id'] = $itemData->id; $item['item_type'] = $itemData->item_type; $item['item_type_label'] = $this->getItemTypeLabel($itemData->item_type); $item['item_name'] = $itemData->name; // DB에서 정확한 이름으로 갱신 $item['unit'] = $itemData->unit ?? $item['unit']; $item['description'] = $itemData->description; $item['attributes'] = json_decode($itemData->attributes ?? '{}', true); $item['has_bom'] = ! empty($bomData); $item['bom_children'] = $this->getBomTree($tenantId, $itemData->id, $bomData); } else { $item['item_id'] = null; $item['item_type'] = null; $item['item_type_label'] = '미등록'; $item['description'] = null; $item['attributes'] = []; $item['has_bom'] = false; $item['bom_children'] = []; } } return $items; } // ========================================================================= // Phase 3: Design 시뮬레이터 동기화 기능 // ========================================================================= /** * 디버그 모드 활성화 */ 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; } /** * 카테고리 기반 단가 계산 * * CategoryGroup을 사용하여 면적/중량/수량 기반 단가를 계산합니다. * - 면적기반: 기본단가 × M (면적) * - 중량기반: 기본단가 × K (중량) * - 수량기반: 기본단가 × 1 */ public function calculateCategoryPrice( string $itemCategory, float $basePrice, array $variables, ?int $tenantId = null ): array { $tenantId = $tenantId ?? session('selected_tenant_id'); 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 ?? session('selected_tenant_id'); if (! $tenantId) { return ['ungrouped' => $items]; } // 품목 코드로 process_type 일괄 조회 $itemCodes = array_unique(array_column($items, 'item_code')); if (empty($itemCodes)) { return ['ungrouped' => $items]; } $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; } /** * BOM 계산 (10단계 디버깅 포함) * * Design 시뮬레이터와 동일한 10단계 계산 과정: * 1. 입력값 수집 (W0, H0) * 2. 변수 계산 (W1, H1, M, K) * 3. 완제품 선택 * 4. BOM 전개 * 5. 단가 출처 결정 * 6. 수량 수식 평가 * 7. 단가 계산 (카테고리 기반) * 8. 공정별 그룹화 * 9. 소계 계산 * 10. 최종 합계 */ public function calculateBomWithDebug( string $finishedGoodsCode, array $inputVariables, ?int $tenantId = null ): array { $this->enableDebugMode(true); $tenantId = $tenantId ?? session('selected_tenant_id'); $this->currentTenantId = $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); if (! $finishedGoods) { $this->addDebugStep(2, '완제품선택', [ 'code' => $finishedGoodsCode, 'error' => '완제품을 찾을 수 없습니다.', ]); return [ 'success' => false, 'error' => '완제품을 찾을 수 없습니다: '.$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: 변수 계산 (제품 카테고리에 따라 마진값 결정) // Design 기준 마진값: 스크린(W+140, H+350), 철재(W+110, H+350) $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) 계산 // 스크린: M×2 + W0/1000×14.17, 철재: M×25 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: 각 품목별 단가 계산 (addDebugStep은 calculateCategoryPrice 내부에서 호출) $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, // 기본 단가 (원/㎡, 원/kg, 원/EA) '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, ]; } /** * 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); 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[] = "수량 수식 오류: {$formula} - {$e->getMessage()}"; return 1; } } /** * 품목의 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 ?? '기타'; } }