## 구현 내용 ### 모델 (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개 라우트
355 lines
11 KiB
PHP
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 = [];
|
|
}
|
|
}
|