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.* 성공 메시지
399 lines
11 KiB
PHP
399 lines
11 KiB
PHP
<?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 = [];
|
|
}
|
|
}
|