feat: [quote] 견적 API Phase 2-3 완료 (Service + Controller Layer)
Phase 2 - Service Layer:
- QuoteService: 견적 CRUD + 상태관리 (확정/전환)
- QuoteNumberService: 견적번호 채번 (KD-{PREFIX}-YYMMDD-SEQ)
- FormulaEvaluatorService: 수식 평가 엔진 (SUM, IF, ROUND 등)
- QuoteCalculationService: 자동산출 (스크린/철재 제품)
- QuoteDocumentService: PDF 생성 및 이메일/카카오 발송
Phase 3 - Controller Layer:
- QuoteController: 16개 엔드포인트
- FormRequest 7개: Index, Store, Update, BulkDelete, Calculate, SendEmail, SendKakao
- QuoteApi.php: Swagger 문서 (12개 스키마, 16개 엔드포인트)
- routes/api.php: 16개 라우트 등록
i18n 키 추가:
- error.php: quote_not_found, formula_* 등
- message.php: quote.* 성공 메시지
This commit is contained in:
398
app/Services/Quote/FormulaEvaluatorService.php
Normal file
398
app/Services/Quote/FormulaEvaluatorService.php
Normal file
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Services\Service;
|
||||
|
||||
/**
|
||||
* 수식 평가 서비스
|
||||
*
|
||||
* 견적 자동산출을 위한 수식 검증 및 평가 엔진
|
||||
* 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT
|
||||
*/
|
||||
class FormulaEvaluatorService extends Service
|
||||
{
|
||||
/**
|
||||
* 지원 함수 목록
|
||||
*/
|
||||
public const SUPPORTED_FUNCTIONS = [
|
||||
'SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT',
|
||||
];
|
||||
|
||||
private array $variables = [];
|
||||
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* 수식 검증
|
||||
*/
|
||||
public function validateFormula(string $formula): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// 기본 문법 검증
|
||||
if (empty(trim($formula))) {
|
||||
return [
|
||||
'success' => 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 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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user