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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Quote;
|
|
|
|
|
|
|
2025-12-30 23:45:22 +09:00
|
|
|
|
use App\Models\CategoryGroup;
|
2025-12-19 16:49:26 +09:00
|
|
|
|
use App\Models\Products\Price;
|
|
|
|
|
|
use App\Models\Quote\QuoteFormula;
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
use App\Services\Service;
|
2025-12-19 16:49:26 +09:00
|
|
|
|
use Illuminate\Support\Collection;
|
2025-12-30 23:45:22 +09:00
|
|
|
|
use Illuminate\Support\Facades\DB;
|
2025-12-19 16:49:26 +09:00
|
|
|
|
use RuntimeException;
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 수식 평가 서비스
|
|
|
|
|
|
*
|
|
|
|
|
|
* 견적 자동산출을 위한 수식 검증 및 평가 엔진
|
2025-12-30 23:45:22 +09:00
|
|
|
|
* MNG FormulaEvaluatorService와 동기화된 BOM 계산 로직 포함
|
|
|
|
|
|
*
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
* 지원 함수: 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 = [];
|
|
|
|
|
|
|
2025-12-30 23:45:22 +09:00
|
|
|
|
private array $debugSteps = [];
|
|
|
|
|
|
|
|
|
|
|
|
private bool $debugMode = false;
|
|
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// 기본 수식 평가 메서드
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 수식 검증
|
|
|
|
|
|
*/
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 23:45:22 +09:00
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// Private Helper Methods (기본)
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 23:45:22 +09:00
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// 상태 관리 메서드
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 에러 목록 반환
|
|
|
|
|
|
*/
|
|
|
|
|
|
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 = [];
|
2025-12-30 23:45:22 +09:00
|
|
|
|
$this->debugSteps = [];
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
}
|
2025-12-19 16:49:26 +09:00
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
2025-12-30 23:45:22 +09:00
|
|
|
|
// DB 기반 수식 실행 (QuoteFormula 모델 사용)
|
2025-12-19 16:49:26 +09:00
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 범위별 수식 평가 (QuoteFormula 기반)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function evaluateRangeFormula(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 매핑 수식 평가 (QuoteFormula 기반)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function evaluateMappingFormula(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 전체 수식 실행 (카테고리 순서대로)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param Collection $formulasByCategory 카테고리별 수식 컬렉션
|
|
|
|
|
|
* @param array $inputVariables 입력 변수
|
|
|
|
|
|
* @return array ['variables' => 결과, 'items' => 품목, 'errors' => 에러]
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function executeAll(Collection $formulasByCategory, array $inputVariables = []): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (! $this->tenantId()) {
|
|
|
|
|
|
throw new RuntimeException(__('error.tenant_id_required'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$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->evaluateRangeFormula($formula, $this->variables),
|
|
|
|
|
|
QuoteFormula::TYPE_MAPPING => $this->evaluateMappingFormula($formula, $this->variables),
|
|
|
|
|
|
default => null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 23:45:22 +09:00
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// 디버그 모드 (MNG 시뮬레이터 동기화)
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 디버그 모드 활성화
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function enableDebugMode(bool $enabled = true): self
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->debugMode = $enabled;
|
|
|
|
|
|
if ($enabled) {
|
|
|
|
|
|
$this->debugSteps = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 디버그 단계 기록
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function addDebugStep(int $step, string $name, array $data): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if (! $this->debugMode) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->debugSteps[] = [
|
|
|
|
|
|
'step' => $step,
|
|
|
|
|
|
'name' => $name,
|
|
|
|
|
|
'timestamp' => microtime(true),
|
|
|
|
|
|
'data' => $data,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 디버그 정보 반환
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function getDebugSteps(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return $this->debugSteps;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// BOM 기반 계산 (10단계 디버깅 포함) - MNG 동기화
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* BOM 계산 (10단계 디버깅 포함)
|
|
|
|
|
|
*
|
|
|
|
|
|
* MNG 시뮬레이터와 동일한 10단계 계산 과정:
|
|
|
|
|
|
* 1. 입력값 수집 (W0, H0)
|
|
|
|
|
|
* 2. 완제품 선택
|
|
|
|
|
|
* 3. 변수 계산 (W1, H1, M, K)
|
|
|
|
|
|
* 4. BOM 전개
|
|
|
|
|
|
* 5. 단가 출처 결정
|
|
|
|
|
|
* 6. 수량 수식 평가
|
|
|
|
|
|
* 7. 단가 계산 (카테고리 기반)
|
|
|
|
|
|
* 8. 공정별 그룹화
|
|
|
|
|
|
* 9. 소계 계산
|
|
|
|
|
|
* 10. 최종 합계
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function calculateBomWithDebug(
|
|
|
|
|
|
string $finishedGoodsCode,
|
|
|
|
|
|
array $inputVariables,
|
|
|
|
|
|
?int $tenantId = null
|
|
|
|
|
|
): array {
|
|
|
|
|
|
$this->enableDebugMode(true);
|
|
|
|
|
|
$tenantId = $tenantId ?? $this->tenantId();
|
|
|
|
|
|
|
|
|
|
|
|
if (! $tenantId) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'success' => false,
|
|
|
|
|
|
'error' => __('error.tenant_id_required'),
|
|
|
|
|
|
'debug_steps' => $this->debugSteps,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Step 1: 입력값 수집 (React 동기화 변수 포함)
|
|
|
|
|
|
$this->addDebugStep(1, '입력값수집', [
|
|
|
|
|
|
'W0' => $inputVariables['W0'] ?? null,
|
|
|
|
|
|
'H0' => $inputVariables['H0'] ?? null,
|
|
|
|
|
|
'QTY' => $inputVariables['QTY'] ?? 1,
|
|
|
|
|
|
'PC' => $inputVariables['PC'] ?? '',
|
|
|
|
|
|
'GT' => $inputVariables['GT'] ?? 'wall',
|
|
|
|
|
|
'MP' => $inputVariables['MP'] ?? 'single',
|
|
|
|
|
|
'CT' => $inputVariables['CT'] ?? 'basic',
|
|
|
|
|
|
'WS' => $inputVariables['WS'] ?? 50,
|
|
|
|
|
|
'INSP' => $inputVariables['INSP'] ?? 50000,
|
|
|
|
|
|
'finished_goods' => $finishedGoodsCode,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 2: 완제품 조회 (마진값 결정을 위해 먼저 조회)
|
|
|
|
|
|
$finishedGoods = $this->getItemDetails($finishedGoodsCode, $tenantId);
|
|
|
|
|
|
|
|
|
|
|
|
if (! $finishedGoods) {
|
|
|
|
|
|
$this->addDebugStep(2, '완제품선택', [
|
|
|
|
|
|
'code' => $finishedGoodsCode,
|
|
|
|
|
|
'error' => '완제품을 찾을 수 없습니다.',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'success' => false,
|
|
|
|
|
|
'error' => __('error.finished_goods_not_found', ['code' => $finishedGoodsCode]),
|
|
|
|
|
|
'debug_steps' => $this->debugSteps,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(2, '완제품선택', [
|
|
|
|
|
|
'code' => $finishedGoods['code'],
|
|
|
|
|
|
'name' => $finishedGoods['name'],
|
|
|
|
|
|
'item_category' => $finishedGoods['item_category'] ?? 'N/A',
|
|
|
|
|
|
'has_bom' => $finishedGoods['has_bom'],
|
|
|
|
|
|
'bom_count' => count($finishedGoods['bom'] ?? []),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 3: 변수 계산 (제품 카테고리에 따라 마진값 결정)
|
|
|
|
|
|
$W0 = $inputVariables['W0'] ?? 0;
|
|
|
|
|
|
$H0 = $inputVariables['H0'] ?? 0;
|
|
|
|
|
|
$productCategory = $finishedGoods['item_category'] ?? 'SCREEN';
|
|
|
|
|
|
|
|
|
|
|
|
// 제품 카테고리에 따른 마진값 결정
|
|
|
|
|
|
if (strtoupper($productCategory) === 'STEEL') {
|
|
|
|
|
|
$marginW = 110; // 철재 마진
|
|
|
|
|
|
$marginH = 350;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$marginW = 140; // 스크린 기본 마진
|
|
|
|
|
|
$marginH = 350;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$W1 = $W0 + $marginW; // 마진 포함 폭
|
|
|
|
|
|
$H1 = $H0 + $marginH; // 마진 포함 높이
|
|
|
|
|
|
$M = ($W1 * $H1) / 1000000; // 면적 (㎡)
|
|
|
|
|
|
|
|
|
|
|
|
// 제품 카테고리에 따른 중량(K) 계산
|
|
|
|
|
|
if (strtoupper($productCategory) === 'STEEL') {
|
|
|
|
|
|
$K = $M * 25; // 철재 중량
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$K = $M * 2 + ($W0 / 1000) * 14.17; // 스크린 중량
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$calculatedVariables = array_merge($inputVariables, [
|
|
|
|
|
|
'W0' => $W0,
|
|
|
|
|
|
'H0' => $H0,
|
|
|
|
|
|
'W1' => $W1,
|
|
|
|
|
|
'H1' => $H1,
|
|
|
|
|
|
'W' => $W1, // 수식용 별칭
|
|
|
|
|
|
'H' => $H1, // 수식용 별칭
|
|
|
|
|
|
'M' => $M,
|
|
|
|
|
|
'K' => $K,
|
|
|
|
|
|
'PC' => $productCategory,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(3, '변수계산', array_merge($calculatedVariables, [
|
|
|
|
|
|
'margin_type' => strtoupper($productCategory) === 'STEEL' ? '철재(W+110)' : '스크린(W+140)',
|
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
|
|
// Step 4: BOM 전개
|
|
|
|
|
|
$bomItems = $this->expandBomWithFormulas($finishedGoods, $calculatedVariables, $tenantId);
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(4, 'BOM전개', [
|
|
|
|
|
|
'total_items' => count($bomItems),
|
|
|
|
|
|
'item_codes' => array_column($bomItems, 'item_code'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 5-7: 각 품목별 단가 계산
|
|
|
|
|
|
$calculatedItems = [];
|
|
|
|
|
|
foreach ($bomItems as $bomItem) {
|
|
|
|
|
|
// Step 6: 수량 수식 평가
|
|
|
|
|
|
$quantityFormula = $bomItem['quantity_formula'] ?? '1';
|
|
|
|
|
|
$quantity = $this->evaluateQuantityFormula($quantityFormula, $calculatedVariables);
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(6, '수량계산', [
|
|
|
|
|
|
'item_code' => $bomItem['item_code'],
|
|
|
|
|
|
'formula' => $quantityFormula,
|
|
|
|
|
|
'result' => $quantity,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 5 & 7: 단가 출처 및 계산
|
|
|
|
|
|
$basePrice = $this->getItemPrice($bomItem['item_code']);
|
|
|
|
|
|
$itemCategory = $bomItem['item_category'] ?? $this->getItemCategory($bomItem['item_code'], $tenantId);
|
|
|
|
|
|
|
|
|
|
|
|
$priceResult = $this->calculateCategoryPrice(
|
|
|
|
|
|
$itemCategory,
|
|
|
|
|
|
$basePrice,
|
|
|
|
|
|
$calculatedVariables,
|
|
|
|
|
|
$tenantId
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 면적/중량 기반: final_price에 이미 면적/중량이 곱해져 있음
|
|
|
|
|
|
// 수량 기반: quantity × unit_price
|
|
|
|
|
|
$categoryGroup = $priceResult['category_group'];
|
|
|
|
|
|
if ($categoryGroup === 'area_based' || $categoryGroup === 'weight_based') {
|
|
|
|
|
|
// 면적/중량 기반: final_price = base_price × M or K (이미 계산됨)
|
|
|
|
|
|
$totalPrice = $priceResult['final_price'];
|
|
|
|
|
|
$displayQuantity = $priceResult['multiplier']; // 표시용 수량 = 면적 또는 중량
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 수량 기반: total = quantity × unit_price
|
|
|
|
|
|
$totalPrice = $quantity * $priceResult['final_price'];
|
|
|
|
|
|
$displayQuantity = $quantity;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(7, '금액계산', [
|
|
|
|
|
|
'item_code' => $bomItem['item_code'],
|
|
|
|
|
|
'quantity' => $displayQuantity,
|
|
|
|
|
|
'unit_price' => $basePrice,
|
|
|
|
|
|
'total_price' => $totalPrice,
|
|
|
|
|
|
'calculation_note' => $priceResult['calculation_note'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$calculatedItems[] = [
|
|
|
|
|
|
'item_code' => $bomItem['item_code'],
|
|
|
|
|
|
'item_name' => $bomItem['item_name'],
|
|
|
|
|
|
'item_category' => $itemCategory,
|
|
|
|
|
|
'quantity' => $displayQuantity,
|
|
|
|
|
|
'quantity_formula' => $quantityFormula,
|
|
|
|
|
|
'base_price' => $basePrice,
|
|
|
|
|
|
'multiplier' => $priceResult['multiplier'],
|
|
|
|
|
|
'unit_price' => $basePrice,
|
|
|
|
|
|
'total_price' => $totalPrice,
|
|
|
|
|
|
'calculation_note' => $priceResult['calculation_note'],
|
|
|
|
|
|
'category_group' => $priceResult['category_group'],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Step 8: 공정별 그룹화
|
|
|
|
|
|
$groupedItems = $this->groupItemsByProcess($calculatedItems, $tenantId);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 9: 소계 계산
|
|
|
|
|
|
$subtotals = [];
|
|
|
|
|
|
foreach ($groupedItems as $processType => $group) {
|
|
|
|
|
|
if (! is_array($group) || ! isset($group['items'])) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$subtotals[$processType] = [
|
|
|
|
|
|
'name' => $group['name'] ?? $processType,
|
|
|
|
|
|
'count' => count($group['items']),
|
|
|
|
|
|
'subtotal' => $group['subtotal'] ?? 0,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(9, '소계계산', $subtotals);
|
|
|
|
|
|
|
|
|
|
|
|
// Step 10: 최종 합계
|
|
|
|
|
|
$grandTotal = array_sum(array_column($calculatedItems, 'total_price'));
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(10, '최종합계', [
|
|
|
|
|
|
'item_count' => count($calculatedItems),
|
|
|
|
|
|
'grand_total' => $grandTotal,
|
|
|
|
|
|
'formatted' => number_format($grandTotal).'원',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'success' => true,
|
|
|
|
|
|
'finished_goods' => $finishedGoods,
|
|
|
|
|
|
'variables' => $calculatedVariables,
|
|
|
|
|
|
'items' => $calculatedItems,
|
|
|
|
|
|
'grouped_items' => $groupedItems,
|
|
|
|
|
|
'subtotals' => $subtotals,
|
|
|
|
|
|
'grand_total' => $grandTotal,
|
|
|
|
|
|
'debug_steps' => $this->debugSteps,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 카테고리 기반 단가 계산
|
|
|
|
|
|
*
|
|
|
|
|
|
* CategoryGroup을 사용하여 면적/중량/수량 기반 단가를 계산합니다.
|
|
|
|
|
|
* - 면적기반: 기본단가 × M (면적)
|
|
|
|
|
|
* - 중량기반: 기본단가 × K (중량)
|
|
|
|
|
|
* - 수량기반: 기본단가 × 1
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function calculateCategoryPrice(
|
|
|
|
|
|
string $itemCategory,
|
|
|
|
|
|
float $basePrice,
|
|
|
|
|
|
array $variables,
|
|
|
|
|
|
?int $tenantId = null
|
|
|
|
|
|
): array {
|
|
|
|
|
|
$tenantId = $tenantId ?? $this->tenantId();
|
|
|
|
|
|
|
|
|
|
|
|
if (! $tenantId) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
'final_price' => $basePrice,
|
|
|
|
|
|
'calculation_note' => '테넌트 미설정',
|
|
|
|
|
|
'multiplier' => 1.0,
|
|
|
|
|
|
'category_group' => null,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 카테고리 그룹 조회
|
|
|
|
|
|
$categoryGroup = CategoryGroup::findByItemCategory($tenantId, $itemCategory);
|
|
|
|
|
|
|
|
|
|
|
|
if (! $categoryGroup) {
|
|
|
|
|
|
$this->addDebugStep(5, '단가출처', [
|
|
|
|
|
|
'item_category' => $itemCategory,
|
|
|
|
|
|
'base_price' => $basePrice,
|
|
|
|
|
|
'category_group' => null,
|
|
|
|
|
|
'note' => '카테고리 그룹 미등록 - 수량단가 적용',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'final_price' => $basePrice,
|
|
|
|
|
|
'calculation_note' => '수량단가 (그룹 미등록)',
|
|
|
|
|
|
'multiplier' => 1.0,
|
|
|
|
|
|
'category_group' => null,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CategoryGroup 모델의 calculatePrice 메서드 사용
|
|
|
|
|
|
$result = $categoryGroup->calculatePrice($basePrice, $variables);
|
|
|
|
|
|
$result['category_group'] = $categoryGroup->code;
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(5, '단가출처', [
|
|
|
|
|
|
'item_category' => $itemCategory,
|
|
|
|
|
|
'base_price' => $basePrice,
|
|
|
|
|
|
'category_group' => $categoryGroup->code,
|
|
|
|
|
|
'multiplier_variable' => $categoryGroup->multiplier_variable,
|
|
|
|
|
|
'multiplier_value' => $result['multiplier'],
|
|
|
|
|
|
'final_price' => $result['final_price'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 공정별 품목 그룹화
|
|
|
|
|
|
*
|
|
|
|
|
|
* 품목을 process_type에 따라 그룹화합니다:
|
|
|
|
|
|
* - screen: 스크린 공정 (원단, 패널, 도장 등)
|
|
|
|
|
|
* - bending: 절곡 공정 (알루미늄, 스테인리스 등)
|
|
|
|
|
|
* - steel: 철재 공정 (철재, 강판 등)
|
|
|
|
|
|
* - electric: 전기 공정 (모터, 제어반, 전선 등)
|
|
|
|
|
|
* - assembly: 조립 공정 (볼트, 너트, 브라켓 등)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function groupItemsByProcess(array $items, ?int $tenantId = null): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$tenantId = $tenantId ?? $this->tenantId();
|
|
|
|
|
|
|
|
|
|
|
|
if (! $tenantId) {
|
|
|
|
|
|
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 품목 코드로 process_type 일괄 조회
|
|
|
|
|
|
$itemCodes = array_unique(array_column($items, 'item_code'));
|
|
|
|
|
|
|
|
|
|
|
|
if (empty($itemCodes)) {
|
|
|
|
|
|
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$processTypes = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->whereIn('code', $itemCodes)
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->pluck('process_type', 'code')
|
|
|
|
|
|
->toArray();
|
|
|
|
|
|
|
|
|
|
|
|
// 그룹별 분류
|
|
|
|
|
|
$grouped = [
|
|
|
|
|
|
'screen' => ['name' => '스크린 공정', 'items' => [], 'subtotal' => 0],
|
|
|
|
|
|
'bending' => ['name' => '절곡 공정', 'items' => [], 'subtotal' => 0],
|
|
|
|
|
|
'steel' => ['name' => '철재 공정', 'items' => [], 'subtotal' => 0],
|
|
|
|
|
|
'electric' => ['name' => '전기 공정', 'items' => [], 'subtotal' => 0],
|
|
|
|
|
|
'assembly' => ['name' => '조립 공정', 'items' => [], 'subtotal' => 0],
|
|
|
|
|
|
'other' => ['name' => '기타', 'items' => [], 'subtotal' => 0],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($items as $item) {
|
|
|
|
|
|
$processType = $processTypes[$item['item_code']] ?? 'other';
|
|
|
|
|
|
|
|
|
|
|
|
if (! isset($grouped[$processType])) {
|
|
|
|
|
|
$processType = 'other';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$grouped[$processType]['items'][] = $item;
|
|
|
|
|
|
$grouped[$processType]['subtotal'] += $item['total_price'] ?? 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 빈 그룹 제거
|
|
|
|
|
|
$grouped = array_filter($grouped, fn ($g) => ! empty($g['items']));
|
|
|
|
|
|
|
|
|
|
|
|
$this->addDebugStep(8, '공정그룹화', [
|
|
|
|
|
|
'total_items' => count($items),
|
|
|
|
|
|
'groups' => array_map(fn ($g) => [
|
|
|
|
|
|
'name' => $g['name'],
|
|
|
|
|
|
'count' => count($g['items']),
|
|
|
|
|
|
'subtotal' => $g['subtotal'],
|
|
|
|
|
|
], $grouped),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return $grouped;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// 품목 조회 메서드
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
2025-12-19 16:49:26 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 품목 단가 조회
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function getItemPrice(string $itemCode): float
|
|
|
|
|
|
{
|
|
|
|
|
|
$tenantId = $this->tenantId();
|
|
|
|
|
|
|
|
|
|
|
|
if (! $tenantId) {
|
|
|
|
|
|
$this->errors[] = __('error.tenant_id_required');
|
|
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 23:45:22 +09:00
|
|
|
|
// 1. Price 모델에서 조회
|
|
|
|
|
|
$price = Price::getSalesPriceByItemCode($tenantId, $itemCode);
|
|
|
|
|
|
|
|
|
|
|
|
if ($price > 0) {
|
|
|
|
|
|
return $price;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Fallback: items.attributes.salesPrice에서 조회
|
|
|
|
|
|
$item = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->where('code', $itemCode)
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
|
|
if ($item && ! empty($item->attributes)) {
|
|
|
|
|
|
$attributes = json_decode($item->attributes, true);
|
|
|
|
|
|
|
|
|
|
|
|
return (float) ($attributes['salesPrice'] ?? 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 품목 상세 정보 조회 (BOM 트리 포함)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function getItemDetails(string $itemCode, ?int $tenantId = null): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
$tenantId = $tenantId ?? $this->tenantId();
|
|
|
|
|
|
|
|
|
|
|
|
if (! $tenantId) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$item = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->where('code', $itemCode)
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
|
|
if (! $item) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'id' => $item->id,
|
|
|
|
|
|
'code' => $item->code,
|
|
|
|
|
|
'name' => $item->name,
|
|
|
|
|
|
'item_type' => $item->item_type,
|
|
|
|
|
|
'item_type_label' => $this->getItemTypeLabel($item->item_type),
|
|
|
|
|
|
'item_category' => $item->item_category,
|
|
|
|
|
|
'process_type' => $item->process_type,
|
|
|
|
|
|
'unit' => $item->unit,
|
|
|
|
|
|
'description' => $item->description,
|
|
|
|
|
|
'attributes' => json_decode($item->attributes ?? '{}', true),
|
|
|
|
|
|
'bom' => $this->getBomTree($tenantId, $item->id, json_decode($item->bom ?? '[]', true)),
|
|
|
|
|
|
'has_bom' => ! empty($item->bom) && $item->bom !== '[]',
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* BOM 트리 재귀적으로 조회
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function getBomTree(int $tenantId, int $parentItemId, array $bomData, int $depth = 0): array
|
|
|
|
|
|
{
|
|
|
|
|
|
// 무한 루프 방지
|
|
|
|
|
|
if ($depth > 10 || empty($bomData)) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$children = [];
|
|
|
|
|
|
$childIds = array_column($bomData, 'child_item_id');
|
|
|
|
|
|
|
|
|
|
|
|
if (empty($childIds)) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 자식 품목들 일괄 조회
|
|
|
|
|
|
$childItems = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->whereIn('id', $childIds)
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->get()
|
|
|
|
|
|
->keyBy('id');
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($bomData as $bomItem) {
|
|
|
|
|
|
$childItemId = $bomItem['child_item_id'] ?? null;
|
|
|
|
|
|
$quantity = $bomItem['quantity'] ?? 1;
|
|
|
|
|
|
|
|
|
|
|
|
if (! $childItemId) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$childItem = $childItems->get($childItemId);
|
|
|
|
|
|
|
|
|
|
|
|
if (! $childItem) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$childBomData = json_decode($childItem->bom ?? '[]', true);
|
|
|
|
|
|
|
|
|
|
|
|
$children[] = [
|
|
|
|
|
|
'id' => $childItem->id,
|
|
|
|
|
|
'code' => $childItem->code,
|
|
|
|
|
|
'name' => $childItem->name,
|
|
|
|
|
|
'item_type' => $childItem->item_type,
|
|
|
|
|
|
'item_type_label' => $this->getItemTypeLabel($childItem->item_type),
|
|
|
|
|
|
'unit' => $childItem->unit,
|
|
|
|
|
|
'quantity' => (float) $quantity,
|
|
|
|
|
|
'description' => $childItem->description,
|
|
|
|
|
|
'has_bom' => ! empty($childBomData),
|
|
|
|
|
|
'children' => $this->getBomTree($tenantId, $childItem->id, $childBomData, $depth + 1),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $children;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 품목 유형 라벨
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function getItemTypeLabel(string $itemType): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return match ($itemType) {
|
|
|
|
|
|
'FG' => '완제품',
|
|
|
|
|
|
'PT' => '부품',
|
|
|
|
|
|
'SM' => '부자재',
|
|
|
|
|
|
'RM' => '원자재',
|
|
|
|
|
|
'CS' => '소모품',
|
|
|
|
|
|
default => $itemType,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 품목의 item_category 조회
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function getItemCategory(string $itemCode, int $tenantId): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$category = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->where('code', $itemCode)
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->value('item_category');
|
|
|
|
|
|
|
|
|
|
|
|
return $category ?? '기타';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* BOM 전개 (수량 수식 포함)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function expandBomWithFormulas(array $finishedGoods, array $variables, int $tenantId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$bomItems = [];
|
|
|
|
|
|
|
|
|
|
|
|
// items 테이블에서 완제품의 bom 필드 조회
|
|
|
|
|
|
$item = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->where('code', $finishedGoods['code'])
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
|
|
if (! $item || empty($item->bom)) {
|
|
|
|
|
|
return $bomItems;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$bomData = json_decode($item->bom, true);
|
|
|
|
|
|
|
|
|
|
|
|
if (! is_array($bomData)) {
|
|
|
|
|
|
return $bomItems;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// BOM 데이터 형식: child_item_id 기반 또는 코드 기반 (Design 형식: childItemCode)
|
|
|
|
|
|
foreach ($bomData as $bomEntry) {
|
|
|
|
|
|
$childItemId = $bomEntry['child_item_id'] ?? null;
|
|
|
|
|
|
$childItemCode = $bomEntry['item_code'] ?? $bomEntry['childItemCode'] ?? null;
|
|
|
|
|
|
$quantityFormula = $bomEntry['quantityFormula'] ?? $bomEntry['quantity'] ?? '1';
|
|
|
|
|
|
|
|
|
|
|
|
if ($childItemId) {
|
|
|
|
|
|
// ID 기반 조회
|
|
|
|
|
|
$childItem = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->where('id', $childItemId)
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->first();
|
|
|
|
|
|
} elseif ($childItemCode) {
|
|
|
|
|
|
// 코드 기반 조회
|
|
|
|
|
|
$childItem = DB::table('items')
|
|
|
|
|
|
->where('tenant_id', $tenantId)
|
|
|
|
|
|
->where('code', $childItemCode)
|
|
|
|
|
|
->whereNull('deleted_at')
|
|
|
|
|
|
->first();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($childItem) {
|
|
|
|
|
|
$bomItems[] = [
|
|
|
|
|
|
'item_code' => $childItem->code,
|
|
|
|
|
|
'item_name' => $childItem->name,
|
|
|
|
|
|
'item_category' => $childItem->item_category,
|
|
|
|
|
|
'process_type' => $childItem->process_type,
|
|
|
|
|
|
'quantity_formula' => (string) $quantityFormula,
|
|
|
|
|
|
'unit' => $childItem->unit,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 재귀적 BOM 전개 (반제품인 경우)
|
|
|
|
|
|
if (in_array($childItem->item_type, ['SF', 'PT'])) {
|
|
|
|
|
|
$childDetails = $this->getItemDetails($childItem->code, $tenantId);
|
|
|
|
|
|
if ($childDetails && $childDetails['has_bom']) {
|
|
|
|
|
|
$childBomItems = $this->expandBomWithFormulas($childDetails, $variables, $tenantId);
|
|
|
|
|
|
$bomItems = array_merge($bomItems, $childBomItems);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $bomItems;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 수량 수식 평가
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function evaluateQuantityFormula(string $formula, array $variables): float
|
|
|
|
|
|
{
|
|
|
|
|
|
// 빈 수식은 기본값 1 반환
|
|
|
|
|
|
if (empty(trim($formula))) {
|
|
|
|
|
|
return 1.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 숫자만 있으면 바로 반환
|
|
|
|
|
|
if (is_numeric($formula)) {
|
|
|
|
|
|
return (float) $formula;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 변수 치환
|
|
|
|
|
|
$expression = $formula;
|
|
|
|
|
|
foreach ($variables as $var => $value) {
|
|
|
|
|
|
$expression = preg_replace('/\b'.preg_quote($var, '/').'\b/', (string) $value, $expression);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 계산
|
|
|
|
|
|
try {
|
|
|
|
|
|
return $this->calculateExpression($expression);
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
$this->errors[] = __('error.quantity_formula_error', ['formula' => $formula, 'error' => $e->getMessage()]);
|
|
|
|
|
|
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
2025-12-19 16:49:26 +09:00
|
|
|
|
}
|
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.* 성공 메시지
2025-12-04 22:03:40 +09:00
|
|
|
|
}
|