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:
2025-12-04 22:03:40 +09:00
parent d164bb4c4a
commit 40ca8b8697
18 changed files with 3264 additions and 3 deletions

View 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 = [];
}
}