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, ]; } 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->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 = session('selected_tenant_id'); if (! $tenantId) { $this->errors[] = "테넌트 ID가 설정되지 않았습니다."; return 0; } return \App\Models\Price::getSalesPriceByItemCode($tenantId, $itemCode); } /** * 에러 목록 반환 */ public function getErrors(): array { return $this->errors; } /** * 현재 변수 상태 반환 */ public function getVariables(): array { return $this->variables; } /** * 변수 초기화 */ public function resetVariables(): void { $this->variables = []; $this->errors = []; } }