Files
sam-manage/app/Services/Quote/FormulaEvaluatorService.php
kent 33367406a0 feat(MNG): 견적 공식 시뮬레이터 UI 개선
- FormulaEvaluatorService: 공식 평가 로직 개선
- simulator.blade.php: 시뮬레이터 UI/UX 개선
  - 입력 필드 레이아웃 최적화
  - 계산 결과 표시 개선
  - 에러 처리 강화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 17:27:01 +09:00

1103 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Quote;
use App\Models\CategoryGroup;
use App\Models\Quote\QuoteFormula;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class FormulaEvaluatorService
{
private array $variables = [];
private array $errors = [];
private array $debugSteps = [];
private bool $debugMode = false;
private ?int $currentTenantId = null;
/**
* 수식 검증
*/
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 = $this->currentTenantId ?? session('selected_tenant_id');
if (! $tenantId) {
$this->errors[] = '테넌트 ID가 설정되지 않았습니다.';
return 0;
}
// 1. Price 모델에서 조회
$price = \App\Models\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;
}
/**
* 에러 목록 반환
*/
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 = $this->currentTenantId ?? 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),
'item_category' => $item->item_category, // 제품 카테고리 (SCREEN, STEEL 등)
'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,
};
}
/**
* 품목 목록에 상세 정보 추가
*/
public function enrichItemsWithDetails(array $items): array
{
$tenantId = $this->currentTenantId ?? 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;
}
// =========================================================================
// Phase 3: Design 시뮬레이터 동기화 기능
// =========================================================================
/**
* 디버그 모드 활성화
*/
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;
}
/**
* 카테고리 기반 단가 계산
*
* CategoryGroup을 사용하여 면적/중량/수량 기반 단가를 계산합니다.
* - 면적기반: 기본단가 × M (면적)
* - 중량기반: 기본단가 × K (중량)
* - 수량기반: 기본단가 × 1
*/
public function calculateCategoryPrice(
string $itemCategory,
float $basePrice,
array $variables,
?int $tenantId = null
): array {
$tenantId = $tenantId ?? session('selected_tenant_id');
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 ?? session('selected_tenant_id');
if (! $tenantId) {
return ['ungrouped' => $items];
}
// 품목 코드로 process_type 일괄 조회
$itemCodes = array_unique(array_column($items, 'item_code'));
if (empty($itemCodes)) {
return ['ungrouped' => $items];
}
$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;
}
/**
* BOM 계산 (10단계 디버깅 포함)
*
* Design 시뮬레이터와 동일한 10단계 계산 과정:
* 1. 입력값 수집 (W0, H0)
* 2. 변수 계산 (W1, H1, M, K)
* 3. 완제품 선택
* 4. BOM 전개
* 5. 단가 출처 결정
* 6. 수량 수식 평가
* 7. 단가 계산 (카테고리 기반)
* 8. 공정별 그룹화
* 9. 소계 계산
* 10. 최종 합계
*/
public function calculateBomWithDebug(
string $finishedGoodsCode,
array $inputVariables,
?int $tenantId = null
): array {
$this->enableDebugMode(true);
$tenantId = $tenantId ?? session('selected_tenant_id');
$this->currentTenantId = $tenantId;
// 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);
if (! $finishedGoods) {
$this->addDebugStep(2, '완제품선택', [
'code' => $finishedGoodsCode,
'error' => '완제품을 찾을 수 없습니다.',
]);
return [
'success' => false,
'error' => '완제품을 찾을 수 없습니다: '.$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: 변수 계산 (제품 카테고리에 따라 마진값 결정)
// Design 기준 마진값: 스크린(W+140, H+350), 철재(W+110, H+350)
$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) 계산
// 스크린: M×2 + W0/1000×14.17, 철재: M×25
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: 각 품목별 단가 계산 (addDebugStep은 calculateCategoryPrice 내부에서 호출)
$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, // 기본 단가 (원/㎡, 원/kg, 원/EA)
'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,
];
}
/**
* 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);
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[] = "수량 수식 오류: {$formula} - {$e->getMessage()}";
return 1;
}
}
/**
* 품목의 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 ?? '기타';
}
}