Files
sam-manage/app/Services/Quote/FormulaEvaluatorService.php
hskwon dac02f120b feat(quote-formulas): 견적수식 관리 기능 구현
## 구현 내용

### 모델 (5개)
- QuoteFormulaCategory: 수식 카테고리
- QuoteFormula: 수식 정의 (input/calculation/range/mapping)
- QuoteFormulaRange: 범위별 값 정의
- QuoteFormulaMapping: 매핑 테이블
- QuoteFormulaItem: 수식-품목 연결

### 서비스 (3개)
- QuoteFormulaCategoryService: 카테고리 CRUD
- QuoteFormulaService: 수식 CRUD, 복제, 재정렬
- FormulaEvaluatorService: 수식 계산 엔진
  - 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT

### API Controller (2개)
- QuoteFormulaCategoryController: 카테고리 API (11개 엔드포인트)
- QuoteFormulaController: 수식 API (16개 엔드포인트)

### FormRequest (4개)
- Store/Update QuoteFormulaCategoryRequest
- Store/Update QuoteFormulaRequest

### Blade Views (8개)
- 수식 목록/추가/수정/시뮬레이터
- 카테고리 목록/추가/수정
- HTMX 테이블 partial

### 라우트
- API: 27개 엔드포인트
- Web: 7개 라우트
2025-12-04 14:00:24 +09:00

355 lines
11 KiB
PHP

<?php
namespace App\Services\Quote;
use App\Models\Quote\QuoteFormula;
use Illuminate\Support\Collection;
class FormulaEvaluatorService
{
private array $variables = [];
private array $errors = [];
/**
* 수식 검증
*/
public function validateFormula(string $formula): array
{
$errors = [];
// 기본 문법 검증
if (empty(trim($formula))) {
return ['success' => 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
{
// TODO: 품목 마스터에서 단가 조회
return 0;
}
/**
* 에러 목록 반환
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* 현재 변수 상태 반환
*/
public function getVariables(): array
{
return $this->variables;
}
/**
* 변수 초기화
*/
public function resetVariables(): void
{
$this->variables = [];
$this->errors = [];
}
}