Files
sam-api/app/Services/Quote/FormulaEvaluatorService.php
kent 7566814876 feat: Phase 1.1 - MNG 견적 계산 로직 API 동기화
- CategoryGroup 모델 추가 (카테고리별 단가 계산)
- FormulaEvaluatorService에 10단계 BOM 계산 로직 추가
- calculateBomWithDebug, calculateCategoryPrice 등 주요 메서드 구현
- MNG 시뮬레이터와 동일한 계산 결과 보장

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 23:45:22 +09:00

1177 lines
38 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\Products\Price;
use App\Models\Quote\QuoteFormula;
use App\Services\Service;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 수식 평가 서비스
*
* 견적 자동산출을 위한 수식 검증 및 평가 엔진
* MNG FormulaEvaluatorService와 동기화된 BOM 계산 로직 포함
*
* 지원 함수: 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 = [];
private array $debugSteps = [];
private bool $debugMode = false;
// =========================================================================
// 기본 수식 평가 메서드
// =========================================================================
/**
* 수식 검증
*/
public function validateFormula(string $formula): array
{
$errors = [];
// 기본 문법 검증
if (empty(trim($formula))) {
return [
'success' => false,
'errors' => [__('error.formula_empty')],
];
}
// 괄호 매칭 검증
if (! $this->validateParentheses($formula)) {
$errors[] = __('error.formula_parentheses_mismatch');
}
// 변수 추출
$variables = $this->extractVariables($formula);
// 지원 함수 검증
$functions = $this->extractFunctions($formula);
foreach ($functions as $func) {
if (! in_array(strtoupper($func), self::SUPPORTED_FUNCTIONS)) {
$errors[] = __('error.formula_unsupported_function', ['function' => $func]);
}
}
return [
'success' => empty($errors),
'errors' => $errors,
'variables' => $variables,
'functions' => $functions,
];
}
/**
* 수식 평가
*
* @param string $formula 수식 문자열
* @param array $variables 변수 배열 [변수명 => 값]
*/
public function evaluate(string $formula, array $variables = []): mixed
{
$this->variables = array_merge($this->variables, $variables);
$this->errors = [];
try {
// 변수 치환
$expression = $this->substituteVariables($formula);
// 함수 처리
$expression = $this->processFunctions($expression);
// 최종 계산
return $this->calculateExpression($expression);
} catch (\Exception $e) {
$this->errors[] = $e->getMessage();
return null;
}
}
/**
* 다중 수식 일괄 평가
*
* @param array $formulas [변수명 => 수식] 배열
* @param array $inputVariables 입력 변수
* @return array [변수명 => 결과값]
*/
public function evaluateMultiple(array $formulas, array $inputVariables = []): array
{
$this->variables = $inputVariables;
$results = [];
foreach ($formulas as $variable => $formula) {
$result = $this->evaluate($formula);
$this->variables[$variable] = $result;
$results[$variable] = $result;
}
return [
'results' => $results,
'errors' => $this->errors,
];
}
/**
* 범위 기반 값 결정
*
* @param float $value 검사할 값
* @param array $ranges 범위 배열 [['min' => 0, 'max' => 100, 'result' => 값], ...]
* @param mixed $default 기본값
*/
public function evaluateRange(float $value, array $ranges, mixed $default = null): mixed
{
foreach ($ranges as $range) {
$min = $range['min'] ?? null;
$max = $range['max'] ?? null;
$inRange = true;
if ($min !== null && $value < $min) {
$inRange = false;
}
if ($max !== null && $value > $max) {
$inRange = false;
}
if ($inRange) {
$result = $range['result'] ?? $default;
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
/**
* 매핑 기반 값 결정
*
* @param mixed $sourceValue 소스 값
* @param array $mappings 매핑 배열 [['source' => 값, 'result' => 결과], ...]
* @param mixed $default 기본값
*/
public function evaluateMapping(mixed $sourceValue, array $mappings, mixed $default = null): mixed
{
foreach ($mappings as $mapping) {
$source = $mapping['source'] ?? null;
$result = $mapping['result'] ?? $default;
if ($sourceValue == $source) {
// result가 수식인 경우 평가
if (is_string($result) && $this->isFormula($result)) {
return $this->evaluate($result);
}
return $result;
}
}
return $default;
}
// =========================================================================
// Private 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] ?? []);
// 함수명 제외
return array_values(array_diff($variables, self::SUPPORTED_FUNCTIONS));
}
private function extractFunctions(string $formula): array
{
preg_match_all('/\b([A-Za-z_]+)\s*\(/', $formula, $matches);
return array_unique($matches[1] ?? []);
}
private function substituteVariables(string $formula): string
{
foreach ($this->variables as $var => $value) {
$formula = preg_replace('/\b'.preg_quote($var, '/').'\b/', (string) $value, $formula);
}
return $formula;
}
private function processFunctions(string $expression): string
{
// ROUND(value, decimals)
$expression = preg_replace_callback(
'/ROUND\s*\(\s*([^,]+)\s*,\s*(\d+)\s*\)/i',
fn ($m) => round((float) $this->calculateExpression($m[1]), (int) $m[2]),
$expression
);
// SUM(a, b, c, ...)
$expression = preg_replace_callback(
'/SUM\s*\(([^)]+)\)/i',
fn ($m) => array_sum(array_map('floatval', explode(',', $m[1]))),
$expression
);
// MIN, MAX
$expression = preg_replace_callback(
'/MIN\s*\(([^)]+)\)/i',
fn ($m) => min(array_map('floatval', explode(',', $m[1]))),
$expression
);
$expression = preg_replace_callback(
'/MAX\s*\(([^)]+)\)/i',
fn ($m) => max(array_map('floatval', explode(',', $m[1]))),
$expression
);
// IF(condition, true_val, false_val)
$expression = preg_replace_callback(
'/IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i',
function ($m) {
$condition = $this->evaluateCondition($m[1]);
return $condition ? $this->calculateExpression($m[2]) : $this->calculateExpression($m[3]);
},
$expression
);
// ABS, CEIL, FLOOR
$expression = preg_replace_callback(
'/ABS\s*\(([^)]+)\)/i',
fn ($m) => abs((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/CEIL\s*\(([^)]+)\)/i',
fn ($m) => ceil((float) $this->calculateExpression($m[1])),
$expression
);
$expression = preg_replace_callback(
'/FLOOR\s*\(([^)]+)\)/i',
fn ($m) => floor((float) $this->calculateExpression($m[1])),
$expression
);
return $expression;
}
private function calculateExpression(string $expression): float
{
// 안전한 수식 평가 (숫자, 연산자, 괄호만 허용)
$expression = preg_replace('/[^0-9+\-*\/().%\s]/', '', $expression);
if (empty(trim($expression))) {
return 0;
}
try {
// TODO: 프로덕션에서는 symfony/expression-language 등 안전한 라이브러리 사용 권장
return (float) eval("return {$expression};");
} catch (\Throwable $e) {
$this->errors[] = __('error.formula_calculation_error', ['expression' => $expression]);
return 0;
}
}
private function evaluateCondition(string $condition): bool
{
// 비교 연산자 처리
if (preg_match('/(.+)(>=|<=|>|<|==|!=)(.+)/', $condition, $m)) {
$left = (float) $this->calculateExpression(trim($m[1]));
$right = (float) $this->calculateExpression(trim($m[3]));
$op = $m[2];
return match ($op) {
'>=' => $left >= $right,
'<=' => $left <= $right,
'>' => $left > $right,
'<' => $left < $right,
'==' => $left == $right,
'!=' => $left != $right,
default => false,
};
}
return (bool) $this->calculateExpression($condition);
}
private function isFormula(string $value): bool
{
// 연산자나 함수가 포함되어 있으면 수식으로 판단
return preg_match('/[+\-*\/()]|[A-Z]+\s*\(/', $value) === 1;
}
// =========================================================================
// 상태 관리 메서드
// =========================================================================
/**
* 에러 목록 반환
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* 현재 변수 상태 반환
*/
public function getVariables(): array
{
return $this->variables;
}
/**
* 변수 설정
*/
public function setVariables(array $variables): self
{
$this->variables = $variables;
return $this;
}
/**
* 변수 추가
*/
public function addVariable(string $name, mixed $value): self
{
$this->variables[$name] = $value;
return $this;
}
/**
* 변수 및 에러 초기화
*/
public function reset(): void
{
$this->variables = [];
$this->errors = [];
$this->debugSteps = [];
}
// =========================================================================
// DB 기반 수식 실행 (QuoteFormula 모델 사용)
// =========================================================================
/**
* 범위별 수식 평가 (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,
};
}
// =========================================================================
// 디버그 모드 (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;
}
// =========================================================================
// 품목 조회 메서드
// =========================================================================
/**
* 품목 단가 조회
*/
private function getItemPrice(string $itemCode): float
{
$tenantId = $this->tenantId();
if (! $tenantId) {
$this->errors[] = __('error.tenant_id_required');
return 0;
}
// 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;
}
}
}