- 견적 시뮬레이터 UI 레이아웃 개선 (가로 배치, 반응형) - FlowTester ConditionEvaluator 클래스 추가 (조건부 실행 지원) - FormulaEvaluatorService 기능 확장 - DependencyResolver 의존성 해결 로직 개선 - PushDeviceToken 모델 확장 (FCM 토큰 관리) - QuoteFormula API 엔드포인트 추가 - FlowTester 가이드 모달 업데이트
575 lines
18 KiB
PHP
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;
|
|
}
|
|
}
|