Files
sam-api/app/Services/Quote/FormulaEvaluatorService.php

1850 lines
63 KiB
PHP
Raw Normal View History

<?php
namespace App\Services\Quote;
use App\Models\CategoryGroup;
use App\Models\Items\Item;
use App\Models\Products\Price;
use App\Models\Quote\QuoteFormula;
use App\Services\Quote\Handlers\KyungdongFormulaHandler;
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',
];
/**
* 경동기업 테넌트 ID
*/
private const KYUNGDONG_TENANT_ID = 287;
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);
// 품목마스터에서 규격/단위 조회 (우선순위: 품목마스터 > 수식품목)
$itemMasterData = $this->getItemSpecAndUnit($item->item_code);
$specification = $itemMasterData['specification'] ?? $item->specification;
$unit = $itemMasterData['unit'] ?? $item->unit ?? 'EA';
$items[] = [
'item_code' => $item->item_code,
'item_name' => $item->item_name,
'specification' => $specification,
'unit' => $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,
];
}
// 경동기업(tenant_id=287) 전용 계산 로직 분기
if ($tenantId === self::KYUNGDONG_TENANT_ID) {
return $this->calculateKyungdongBom($finishedGoodsCode, $inputVariables, $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, $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,
'specification' => $bomItem['specification'] ?? null,
'unit' => $bomItem['unit'] ?? 'EA',
'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: 공정별 그룹화 및 items에 process_group 추가
$groupedItems = $this->groupItemsByProcess($calculatedItems, $tenantId);
// items에 process_group 필드 추가 (프론트엔드에서 분류에 사용)
$calculatedItems = $this->addProcessGroupToItems($calculatedItems, $groupedItems);
// 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;
}
/**
* 품목 카테고리 그룹화 (동적 카테고리 시스템)
*
* 품목을 item_category 필드 기준으로 그룹화합니다.
* 카테고리 정보는 categories 테이블에서 code_group='item_category' 조회합니다.
*
* 카테고리 구조:
* - BODY: 본체
* - BENDING 하위 카테고리:
* - BENDING_GUIDE: 절곡품 - 가이드레일
* - BENDING_CASE: 절곡품 - 케이스
* - BENDING_BOTTOM: 절곡품 - 하단마감재
* - MOTOR_CTRL: 모터 & 제어기
* - ACCESSORY: 부자재
*/
public function groupItemsByProcess(array $items, ?int $tenantId = null): array
{
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
}
// 품목 코드로 item_category 일괄 조회
$itemCodes = array_unique(array_column($items, 'item_code'));
if (empty($itemCodes)) {
return ['ungrouped' => ['name' => '그룹없음', 'items' => $items, 'subtotal' => 0]];
}
// 품목별 item_category 조회
$itemCategories = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $itemCodes)
->whereNull('deleted_at')
->pluck('item_category', 'code')
->toArray();
// 카테고리 트리 조회 (item_category 코드 그룹)
$categoryTree = $this->getItemCategoryTree($tenantId);
// 카테고리 코드 → 정보 매핑 생성 (탭 구조에 맞게)
$categoryMapping = $this->buildCategoryMapping($categoryTree);
// 그룹별 분류를 위한 빈 구조 생성
$grouped = [];
foreach ($categoryMapping as $code => $info) {
$grouped[$code] = ['name' => $info['name'], 'items' => [], 'subtotal' => 0];
}
// 기타 그룹 추가
$grouped['OTHER'] = ['name' => '기타', 'items' => [], 'subtotal' => 0];
foreach ($items as $item) {
$categoryCode = $itemCategories[$item['item_code']] ?? 'OTHER';
// 매핑에 없는 카테고리는 기타로 분류
if (! isset($grouped[$categoryCode])) {
$categoryCode = 'OTHER';
}
$grouped[$categoryCode]['items'][] = $item;
$grouped[$categoryCode]['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;
}
/**
* item_category 카테고리 트리 조회
*
* categories 테이블에서 code_group='item_category' 카테고리를 트리 구조로 조회
*/
private function getItemCategoryTree(int $tenantId): array
{
$categories = DB::table('categories')
->where('tenant_id', $tenantId)
->where('code_group', 'item_category')
->where('is_active', 1)
->whereNull('deleted_at')
->orderBy('parent_id')
->orderBy('sort_order')
->orderBy('id')
->get(['id', 'parent_id', 'code', 'name'])
->toArray();
// 트리 구조로 변환
$categoryIds = array_column($categories, 'id');
$rootIds = array_filter($categories, fn ($c) => $c->parent_id === null || ! in_array($c->parent_id, $categoryIds));
$byParent = [];
foreach ($categories as $c) {
$parentKey = in_array($c->id, array_column($rootIds, 'id')) ? 0 : ($c->parent_id ?? 0);
$byParent[$parentKey][] = $c;
}
$buildTree = function ($parentId) use (&$buildTree, &$byParent) {
$nodes = $byParent[$parentId] ?? [];
return array_map(function ($n) use ($buildTree) {
return [
'id' => $n->id,
'code' => $n->code,
'name' => $n->name,
'children' => $buildTree($n->id),
];
}, $nodes);
};
return $buildTree(0);
}
/**
* 카테고리 트리를 구조에 맞게 매핑 생성
*
* BENDING 카테고리의 경우 하위 카테고리를 개별 탭으로,
* 나머지는 그대로 1depth 탭으로 매핑
*/
private function buildCategoryMapping(array $categoryTree): array
{
$mapping = [];
foreach ($categoryTree as $category) {
if ($category['code'] === 'BENDING' && ! empty($category['children'])) {
// BENDING: 하위 카테고리를 개별 탭으로
foreach ($category['children'] as $subCategory) {
$mapping[$subCategory['code']] = [
'name' => '절곡품 - '.$subCategory['name'],
'parentCode' => 'BENDING',
];
}
} else {
// 나머지: 1depth 탭
$mapping[$category['code']] = [
'name' => $category['name'],
'parentCode' => null,
];
}
}
return $mapping;
}
/**
* items 배열에 카테고리 정보 필드 추가
*
* groupedItems에서 아이템의 소속 그룹을 찾아 카테고리 관련 필드를 추가합니다.
* 프론트엔드에서 탭별 분류에 사용됩니다.
*
* 추가되는 필드:
* - process_group: 그룹명 (레거시 호환)
* - process_group_key: 그룹키 (레거시 호환)
* - category_code: 동적 카테고리 코드 (신규 시스템)
*/
private function addProcessGroupToItems(array $items, array $groupedItems): array
{
// 각 그룹의 아이템 코드 → 그룹정보 매핑 생성
$itemCodeToGroup = [];
foreach ($groupedItems as $groupKey => $group) {
if (! isset($group['items']) || ! is_array($group['items'])) {
continue;
}
foreach ($group['items'] as $groupItem) {
$itemCodeToGroup[$groupItem['item_code']] = [
'key' => $groupKey,
'name' => $group['name'] ?? $groupKey,
];
}
}
// items 배열에 카테고리 정보 추가
return array_map(function ($item) use ($itemCodeToGroup) {
$groupInfo = $itemCodeToGroup[$item['item_code']] ?? ['key' => 'OTHER', 'name' => '기타'];
$item['process_group'] = $groupInfo['name'];
$item['process_group_key'] = $groupInfo['key']; // 레거시 호환
$item['category_code'] = $groupInfo['key']; // 신규 동적 카테고리 시스템
return $item;
}, $items);
}
// =========================================================================
// 품목 조회 메서드
// =========================================================================
/**
* 품목 단가 조회
*
* @param string $itemCode 품목 코드
* @param int|null $tenantIdOverride 테넌트 ID (외부 호출 사용)
*/
public function getItemPrice(string $itemCode, ?int $tenantIdOverride = null): float
{
$tenantId = $tenantIdOverride ?? $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 ?? '기타';
}
/**
* 품목마스터에서 규격(specification) 단위(unit) 조회
*
* @param string $itemCode 품목 코드
* @return array ['specification' => string|null, 'unit' => string]
*/
private function getItemSpecAndUnit(string $itemCode): array
{
$tenantId = $this->tenantId();
if (! $tenantId) {
return ['specification' => null, 'unit' => 'EA'];
}
// Items 테이블에서 기본 정보 조회
$item = Item::where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->with('details')
->first();
if (! $item) {
return ['specification' => null, 'unit' => 'EA'];
}
// specification은 ItemDetail에서, unit은 Item에서 가져옴
$specification = $item->details?->specification ?? null;
$unit = $item->unit ?? 'EA';
return [
'specification' => $specification,
'unit' => $unit,
];
}
/**
* 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 기반 조회 (item_details 조인하여 specification 포함)
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.id', $childItemId)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
} elseif ($childItemCode) {
// 코드 기반 조회 (item_details 조인하여 specification 포함)
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.code', $childItemCode)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->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,
'specification' => $childItem->specification,
];
// 재귀적 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;
}
}
// =========================================================================
// BOM 원자재(Leaf Node) 조회 - 소요자재내역용
// =========================================================================
/**
* BOM 트리에서 원자재(leaf nodes) 추출
*
* 완제품의 BOM을 재귀적으로 탐색하여 실제 구매해야 원자재 목록을 반환합니다.
* - Leaf node: BOM이 없는 품목 또는 item_type이 RM(원자재), SM(부자재), CS(소모품) 품목
* - 수량은 상위 구조의 수량을 누적하여 계산
*
* @param string $finishedGoodsCode 완제품 코드
* @param float $orderQuantity 주문 수량 (QTY)
* @param array $variables 변수 배열 (W0, H0 )
* @param int|null $tenantId 테넌트 ID
* @return array 원자재 목록 (leaf nodes)
*/
public function getBomLeafMaterials(
string $finishedGoodsCode,
float $orderQuantity,
array $variables,
?int $tenantId = null
): array {
$tenantId = $tenantId ?? $this->tenantId();
if (! $tenantId) {
return [];
}
// 완제품 조회
$finishedGoods = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $finishedGoodsCode)
->whereNull('deleted_at')
->first();
if (! $finishedGoods || empty($finishedGoods->bom)) {
return [];
}
$bomData = json_decode($finishedGoods->bom, true);
if (! is_array($bomData) || empty($bomData)) {
return [];
}
// 재귀적으로 leaf nodes 수집
$leafMaterials = [];
$this->collectLeafMaterials(
$bomData,
$tenantId,
$orderQuantity,
$variables,
$leafMaterials
);
// 동일 품목 코드 병합 (수량 합산)
return $this->mergeLeafMaterials($leafMaterials, $tenantId);
}
/**
* 재귀적으로 BOM 트리를 탐색하여 leaf materials 수집
*
* @param array $bomData BOM 데이터 배열
* @param int $tenantId 테넌트 ID
* @param float $parentQuantity 상위 품목 수량 (누적)
* @param array $variables 변수 배열
* @param array &$leafMaterials 결과 배열 (참조)
* @param int $depth 재귀 깊이 (무한루프 방지)
*/
private function collectLeafMaterials(
array $bomData,
int $tenantId,
float $parentQuantity,
array $variables,
array &$leafMaterials,
int $depth = 0
): void {
// 무한 루프 방지
if ($depth > 10) {
return;
}
foreach ($bomData as $bomEntry) {
$childItemId = $bomEntry['child_item_id'] ?? null;
$childItemCode = $bomEntry['item_code'] ?? $bomEntry['childItemCode'] ?? null;
$quantityFormula = $bomEntry['quantityFormula'] ?? $bomEntry['quantity'] ?? '1';
// 수량 계산
$itemQuantity = $this->evaluateQuantityFormula((string) $quantityFormula, $variables);
$totalQuantity = $parentQuantity * $itemQuantity;
// 자식 품목 조회
$childItem = null;
if ($childItemId) {
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.id', $childItemId)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
} elseif ($childItemCode) {
$childItem = DB::table('items')
->leftJoin('item_details', 'items.id', '=', 'item_details.item_id')
->where('items.tenant_id', $tenantId)
->where('items.code', $childItemCode)
->whereNull('items.deleted_at')
->select('items.*', 'item_details.specification')
->first();
}
if (! $childItem) {
continue;
}
// 자식의 BOM 확인
$childBomData = json_decode($childItem->bom ?? '[]', true);
$hasChildBom = ! empty($childBomData) && is_array($childBomData);
// Leaf node 판단 조건:
// 1. BOM이 없는 품목
// 2. 또는 item_type이 원자재(RM), 부자재(SM), 소모품(CS)인 품목
$isLeafType = in_array($childItem->item_type, ['RM', 'SM', 'CS']);
if (! $hasChildBom || $isLeafType) {
// Leaf node - 결과에 추가
$leafMaterials[] = [
'item_id' => $childItem->id,
'item_code' => $childItem->code,
'item_name' => $childItem->name,
'item_type' => $childItem->item_type,
'item_category' => $childItem->item_category,
'specification' => $childItem->specification,
'unit' => $childItem->unit ?? 'EA',
'quantity' => $totalQuantity,
'process_type' => $childItem->process_type,
];
} else {
// 중간 노드 (부품/반제품) - 재귀 탐색
$this->collectLeafMaterials(
$childBomData,
$tenantId,
$totalQuantity,
$variables,
$leafMaterials,
$depth + 1
);
}
}
}
/**
* 동일 품목 코드의 leaf materials 병합 (수량 합산)
*
* @param array $leafMaterials 원자재 목록
* @param int $tenantId 테넌트 ID
* @return array 병합된 원자재 목록
*/
private function mergeLeafMaterials(array $leafMaterials, int $tenantId): array
{
$merged = [];
foreach ($leafMaterials as $material) {
$code = $material['item_code'];
if (isset($merged[$code])) {
// 동일 품목 - 수량 합산
$merged[$code]['quantity'] += $material['quantity'];
} else {
// 새 품목 추가
$merged[$code] = $material;
}
}
// 단가 조회 및 금액 계산
$result = [];
foreach ($merged as $material) {
$unitPrice = $this->getItemPrice($material['item_code']);
$totalPrice = $material['quantity'] * $unitPrice;
$result[] = array_merge($material, [
'unit_price' => $unitPrice,
'total_price' => round($totalPrice, 2),
]);
}
return array_values($result);
}
// =========================================================================
// 경동기업 전용 계산 (tenant_id = 287)
// =========================================================================
/**
* 경동기업 전용 BOM 계산
*
* 5130 레거시 시스템의 견적 로직을 구현한 KyungdongFormulaHandler 사용
* - 3차원 조건 모터 용량 계산 (제품타입 × 인치 × 중량)
* - 브라켓 크기 결정
* - 10 절곡품 계산
* - 3 부자재 계산
*
* @param string $finishedGoodsCode 완제품 코드
* @param array $inputVariables 입력 변수 (W0, H0, QTY )
* @param int $tenantId 테넌트 ID
* @return array 계산 결과
*/
private function calculateKyungdongBom(
string $finishedGoodsCode,
array $inputVariables,
int $tenantId
): array {
$this->addDebugStep(0, '경동전용계산', [
'tenant_id' => $tenantId,
'handler' => 'KyungdongFormulaHandler',
'finished_goods' => $finishedGoodsCode,
]);
// Step 1: 입력값 수집
$W0 = (float) ($inputVariables['W0'] ?? 0);
$H0 = (float) ($inputVariables['H0'] ?? 0);
$QTY = (int) ($inputVariables['QTY'] ?? 1);
$bracketInch = $inputVariables['bracket_inch'] ?? '5';
$productType = $inputVariables['product_type'] ?? 'screen';
$this->addDebugStep(1, '입력값수집', [
'formulas' => [
['var' => 'W0', 'desc' => '개구부 폭', 'value' => $W0, 'unit' => 'mm'],
['var' => 'H0', 'desc' => '개구부 높이', 'value' => $H0, 'unit' => 'mm'],
['var' => 'QTY', 'desc' => '수량', 'value' => $QTY, 'unit' => 'EA'],
['var' => 'bracket_inch', 'desc' => '브라켓 인치', 'value' => $bracketInch, 'unit' => '인치'],
['var' => 'product_type', 'desc' => '제품 타입', 'value' => $productType, 'unit' => ''],
],
]);
// 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',
]);
// KyungdongFormulaHandler 인스턴스 생성
$handler = new KyungdongFormulaHandler;
// Step 3: 경동 전용 변수 계산
$W1 = $W0 + 140;
$H1 = $H0 + 350;
$area = ($W0 * ($H0 + 550)) / 1000000;
// 중량 계산 (제품타입별)
if ($productType === 'steel') {
$weight = $area * 25;
$weightFormula = "AREA × 25";
$weightCalc = "{$area} × 25";
} else {
$weight = $area * 2 + ($W0 / 1000) * 14.17;
$weightFormula = "AREA × 2 + (W0 / 1000) × 14.17";
$weightCalc = "{$area} × 2 + ({$W0} / 1000) × 14.17";
}
// 모터 용량 결정
$motorCapacity = $handler->calculateMotorCapacity($productType, $weight, $bracketInch);
// 브라켓 크기 결정
$bracketSize = $handler->calculateBracketSize($weight, $bracketInch);
$calculatedVariables = array_merge($inputVariables, [
'W0' => $W0,
'H0' => $H0,
'QTY' => $QTY,
'W1' => $W1,
'H1' => $H1,
'AREA' => round($area, 4),
'WEIGHT' => round($weight, 2),
'MOTOR_CAPACITY' => $motorCapacity,
'BRACKET_SIZE' => $bracketSize,
'bracket_inch' => $bracketInch,
'product_type' => $productType,
]);
$this->addDebugStep(3, '변수계산', [
'formulas' => [
[
'var' => 'W1',
'desc' => '제작 폭',
'formula' => 'W0 + 140',
'calculation' => "{$W0} + 140",
'result' => $W1,
'unit' => 'mm',
],
[
'var' => 'H1',
'desc' => '제작 높이',
'formula' => 'H0 + 350',
'calculation' => "{$H0} + 350",
'result' => $H1,
'unit' => 'mm',
],
[
'var' => 'AREA',
'desc' => '면적',
'formula' => '(W0 × (H0 + 550)) / 1,000,000',
'calculation' => "({$W0} × ({$H0} + 550)) / 1,000,000",
'result' => round($area, 4),
'unit' => '㎡',
],
[
'var' => 'WEIGHT',
'desc' => '중량',
'formula' => $weightFormula,
'calculation' => $weightCalc,
'result' => round($weight, 2),
'unit' => 'kg',
],
[
'var' => 'MOTOR_CAPACITY',
'desc' => '모터 용량',
'formula' => '중량/브라켓 기준표 조회',
'calculation' => "WEIGHT({$weight}) + INCH({$bracketInch}) → 조회",
'result' => $motorCapacity,
'unit' => '',
],
[
'var' => 'BRACKET_SIZE',
'desc' => '브라켓 크기',
'formula' => '중량 기준표 조회',
'calculation' => "WEIGHT({$weight}) → 조회",
'result' => $bracketSize,
'unit' => '인치',
],
],
]);
// Step 4-7: 동적 항목 계산 (KyungdongFormulaHandler 사용)
$dynamicItems = $handler->calculateDynamicItems($calculatedVariables);
$this->addDebugStep(4, 'BOM전개', [
'total_items' => count($dynamicItems),
'item_categories' => array_unique(array_column($dynamicItems, 'category')),
'items' => array_map(fn ($item) => [
'name' => $item['item_name'],
'category' => $item['category'],
], $dynamicItems),
]);
// Step 5-7: 단가 계산 (각 항목별)
$calculatedItems = [];
$itemFormulas = [];
foreach ($dynamicItems as $item) {
$itemFormulas[] = [
'item' => $item['item_name'],
'qty_formula' => $item['quantity_formula'] ?? '고정값',
'qty_result' => $item['quantity'],
'unit_price' => $item['unit_price'],
'price_formula' => '수량 × 단가',
'price_calc' => "{$item['quantity']} × {$item['unit_price']}",
'total' => $item['total_price'],
];
$calculatedItems[] = [
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'],
'item_category' => $item['category'],
'specification' => $item['specification'] ?? '',
'unit' => $item['unit'],
'quantity' => $item['quantity'],
'quantity_formula' => $item['quantity_formula'] ?? '',
'unit_price' => $item['unit_price'],
'total_price' => $item['total_price'],
'category_group' => $item['category'],
'process_group' => $item['category'],
'calculation_note' => '경동기업 전용 계산',
];
}
$this->addDebugStep(6, '수량계산', [
'formulas' => $itemFormulas,
]);
$this->addDebugStep(7, '금액계산', [
'formulas' => $itemFormulas,
]);
// Step 8: 카테고리별 그룹화
$groupedItems = [];
foreach ($calculatedItems as $item) {
$category = $item['category_group'];
if (! isset($groupedItems[$category])) {
$groupedItems[$category] = [
'name' => $this->getKyungdongCategoryName($category),
'items' => [],
'subtotal' => 0,
];
}
$groupedItems[$category]['items'][] = $item;
$groupedItems[$category]['subtotal'] += $item['total_price'];
}
$this->addDebugStep(8, '카테고리그룹화', [
'groups' => array_map(fn ($g) => [
'name' => $g['name'],
'count' => count($g['items']),
'subtotal' => $g['subtotal'],
], $groupedItems),
]);
// Step 9: 소계 계산
$subtotals = [];
$subtotalFormulas = [];
foreach ($groupedItems as $category => $group) {
$subtotals[$category] = [
'name' => $group['name'],
'count' => count($group['items']),
'subtotal' => $group['subtotal'],
];
$subtotalFormulas[] = [
'category' => $group['name'],
'formula' => implode(' + ', array_map(fn ($i) => $i['item_name'], $group['items'])),
'result' => $group['subtotal'],
];
}
$this->addDebugStep(9, '소계계산', [
'formulas' => $subtotalFormulas,
'subtotals' => $subtotals,
]);
// Step 10: 최종 합계
$grandTotal = array_sum(array_column($calculatedItems, 'total_price'));
$subtotalValues = array_column($subtotals, 'subtotal');
$this->addDebugStep(10, '최종합계', [
'formula' => implode(' + ', array_column($subtotals, 'name')),
'calculation' => implode(' + ', array_map(fn ($v) => number_format($v), $subtotalValues)),
'result' => $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,
'calculation_type' => 'kyungdong',
];
}
/**
* 경동기업 카테고리명 반환
*/
private function getKyungdongCategoryName(string $category): string
{
return match ($category) {
'material' => '주자재',
'motor' => '모터',
'controller' => '제어기',
'steel' => '절곡품',
'parts' => '부자재',
default => $category,
};
}
}