Files
sam-manage/app/Services/Quote/FormulaEvaluatorService.php
hskwon 60618ddd04 feat: 견적 시뮬레이터 개선 및 FlowTester 조건 평가기 추가
- 견적 시뮬레이터 UI 레이아웃 개선 (가로 배치, 반응형)
- FlowTester ConditionEvaluator 클래스 추가 (조건부 실행 지원)
- FormulaEvaluatorService 기능 확장
- DependencyResolver 의존성 해결 로직 개선
- PushDeviceToken 모델 확장 (FCM 토큰 관리)
- QuoteFormula API 엔드포인트 추가
- FlowTester 가이드 모달 업데이트
2025-12-23 23:41:37 +09:00

575 lines
18 KiB
PHP

<?php
namespace App\Services\Quote;
use App\Models\Quote\QuoteFormula;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
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,
];
// range/mapping 결과에서 품목 자동 추출
if (in_array($formula->type, [QuoteFormula::TYPE_RANGE, QuoteFormula::TYPE_MAPPING])) {
$extractedItem = $this->extractItemFromResult($result, $formula);
if ($extractedItem) {
$items[] = $extractedItem;
}
}
} 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,
];
}
/**
* range/mapping 결과에서 품목 정보 추출
*/
private function extractItemFromResult(mixed $result, QuoteFormula $formula): ?array
{
// JSON 문자열이면 파싱
if (is_string($result)) {
$decoded = json_decode($result, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$result = $decoded;
}
}
// 배열이고 item_code가 있으면 품목으로 변환
if (is_array($result) && isset($result['item_code'])) {
$quantity = $result['quantity'] ?? 1;
$itemCode = $result['item_code'];
$unitPrice = $this->getItemPrice($itemCode);
// 수량이 수식이면 평가
if (! is_numeric($quantity)) {
$quantity = $this->evaluate((string) $quantity);
}
return [
'item_code' => $itemCode,
'item_name' => $result['value'] ?? $itemCode,
'specification' => $result['note'] ?? null,
'unit' => 'EA',
'quantity' => (float) $quantity,
'unit_price' => $unitPrice,
'total_price' => (float) $quantity * $unitPrice,
'formula_variable' => $formula->variable,
'auto_selected' => true,
];
}
return null;
}
/**
* 단일 수식 실행
*/
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
{
$tenantId = session('selected_tenant_id');
if (! $tenantId) {
$this->errors[] = '테넌트 ID가 설정되지 않았습니다.';
return 0;
}
return \App\Models\Price::getSalesPriceByItemCode($tenantId, $itemCode);
}
/**
* 에러 목록 반환
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* 현재 변수 상태 반환
*/
public function getVariables(): array
{
return $this->variables;
}
/**
* 변수 초기화
*/
public function resetVariables(): void
{
$this->variables = [];
$this->errors = [];
}
/**
* 품목 상세 정보 조회 (BOM 트리 포함)
*/
public function getItemDetails(string $itemCode): ?array
{
$tenantId = session('selected_tenant_id');
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),
'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,
};
}
/**
* 품목 목록에 상세 정보 추가
*/
public function enrichItemsWithDetails(array $items): array
{
$tenantId = session('selected_tenant_id');
if (! $tenantId) {
return $items;
}
// 품목 코드 수집
$itemCodes = array_unique(array_column($items, 'item_code'));
if (empty($itemCodes)) {
return $items;
}
// 품목 정보 일괄 조회
$itemsData = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $itemCodes)
->whereNull('deleted_at')
->get()
->keyBy('code');
// 품목별 상세 정보 추가
foreach ($items as &$item) {
$itemData = $itemsData->get($item['item_code']);
if ($itemData) {
$bomData = json_decode($itemData->bom ?? '[]', true);
$item['item_id'] = $itemData->id;
$item['item_type'] = $itemData->item_type;
$item['item_type_label'] = $this->getItemTypeLabel($itemData->item_type);
$item['item_name'] = $itemData->name; // DB에서 정확한 이름으로 갱신
$item['unit'] = $itemData->unit ?? $item['unit'];
$item['description'] = $itemData->description;
$item['attributes'] = json_decode($itemData->attributes ?? '{}', true);
$item['has_bom'] = ! empty($bomData);
$item['bom_children'] = $this->getBomTree($tenantId, $itemData->id, $bomData);
} else {
$item['item_id'] = null;
$item['item_type'] = null;
$item['item_type_label'] = '미등록';
$item['description'] = null;
$item['attributes'] = [];
$item['has_bom'] = false;
$item['bom_children'] = [];
}
}
return $items;
}
}