From 9e042b5d6b9e10492a508a301e872ab60e47de10 Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 24 Dec 2025 15:45:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(simulator):=20Phase=204=20-=20BOM=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=20UI=20=EB=B0=8F=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - simulateBom API 엔드포인트 추가 (calculateBomWithDebug 연동) - simulator.blade.php: BOM 디버깅 모드 탭 추가 - 10단계 디버그 스텝 패널 - 공정별 품목 그룹화 표시 - 원가 요약 (공정별 소계, 총합계) - FormulaEvaluatorService: currentTenantId 속성 추가 - 콘솔/API에서 tenant_id 전달 가능하도록 수정 - routes/api.php: simulate-bom 라우트 추가 --- .../Admin/Quote/QuoteFormulaController.php | 41 ++ .../Quote/FormulaEvaluatorService.php | 9 +- .../views/quote-formulas/simulator.blade.php | 393 ++++++++++++++++++ routes/api.php | 1 + 4 files changed, 441 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php index 2ce6fc4f..3711f956 100644 --- a/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php +++ b/app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php @@ -317,6 +317,47 @@ public function duplicate(int $id): JsonResponse ]); } + /** + * BOM 기반 시뮬레이션 (디버깅 포함) + * + * Design 시뮬레이터와 동일한 계산 로직을 사용하여 + * 10단계 디버깅 정보, 공정별 그룹화, 카테고리 가격 계산을 포함합니다. + */ + public function simulateBom(Request $request): JsonResponse + { + $validated = $request->validate([ + 'finished_goods_code' => 'required|string|max:50', + 'input_variables' => 'required|array', + 'input_variables.W0' => 'required|numeric|min:1', + 'input_variables.H0' => 'required|numeric|min:1', + ]); + + $tenantId = session('selected_tenant_id'); + + if (! $tenantId) { + return response()->json([ + 'success' => false, + 'message' => '테넌트를 선택해주세요.', + ], 400); + } + + // 디버그 모드 활성화 + $this->evaluatorService->enableDebugMode(); + + // BOM 기반 계산 (10단계 디버깅 포함) + $result = $this->evaluatorService->calculateBomWithDebug( + $validated['finished_goods_code'], + $validated['input_variables'], + $tenantId + ); + + return response()->json([ + 'success' => $result['success'] ?? false, + 'data' => $result, + 'message' => $result['error'] ?? null, + ]); + } + /** * 전체 품목 목록 (시뮬레이터용) */ diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 36d94383..5a33d7be 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -17,6 +17,8 @@ class FormulaEvaluatorService private bool $debugMode = false; + private ?int $currentTenantId = null; + /** * 수식 검증 */ @@ -377,7 +379,7 @@ private function evaluateCondition(string $condition): bool private function getItemPrice(string $itemCode): float { - $tenantId = session('selected_tenant_id'); + $tenantId = $this->currentTenantId ?? session('selected_tenant_id'); if (! $tenantId) { $this->errors[] = '테넌트 ID가 설정되지 않았습니다.'; @@ -438,7 +440,7 @@ public function resetVariables(): void */ public function getItemDetails(string $itemCode): ?array { - $tenantId = session('selected_tenant_id'); + $tenantId = $this->currentTenantId ?? session('selected_tenant_id'); if (! $tenantId) { return null; @@ -546,7 +548,7 @@ public function getItemTypeLabel(string $itemType): string */ public function enrichItemsWithDetails(array $items): array { - $tenantId = session('selected_tenant_id'); + $tenantId = $this->currentTenantId ?? session('selected_tenant_id'); if (! $tenantId) { return $items; @@ -789,6 +791,7 @@ public function calculateBomWithDebug( ): array { $this->enableDebugMode(true); $tenantId = $tenantId ?? session('selected_tenant_id'); + $this->currentTenantId = $tenantId; // Step 1: 입력값 수집 $this->addDebugStep(1, '입력값수집', [ diff --git a/resources/views/quote-formulas/simulator.blade.php b/resources/views/quote-formulas/simulator.blade.php index cfc8de5d..cd93b096 100644 --- a/resources/views/quote-formulas/simulator.blade.php +++ b/resources/views/quote-formulas/simulator.blade.php @@ -66,6 +66,95 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium + +
+
+ + +
+
+ + + + + +
@@ -165,6 +254,7 @@ class="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-n
+ @endsection @@ -861,7 +951,310 @@ function renderBomTree(children, parentQty = 1, depth = 0) { `; } + // ========================================================================= + // BOM 시뮬레이션 기능 + // ========================================================================= + + // 모드 전환 설정 + function setupModeTabs() { + document.querySelectorAll('.mode-tab').forEach(tab => { + tab.addEventListener('click', function() { + const mode = this.dataset.mode; + switchMode(mode); + }); + }); + } + + // 모드 전환 + function switchMode(mode) { + const formulaSection = document.getElementById('formulaSection'); + const bomSection = document.getElementById('bomSection'); + const modeFormula = document.getElementById('modeFormula'); + const modeBom = document.getElementById('modeBom'); + + if (mode === 'formula') { + formulaSection.classList.remove('hidden'); + bomSection.classList.add('hidden'); + modeFormula.classList.add('text-blue-600', 'border-b-2', 'border-blue-600', 'bg-blue-50'); + modeFormula.classList.remove('text-gray-500'); + modeBom.classList.remove('text-green-600', 'border-b-2', 'border-green-600', 'bg-green-50'); + modeBom.classList.add('text-gray-500'); + } else { + formulaSection.classList.add('hidden'); + bomSection.classList.remove('hidden'); + modeBom.classList.add('text-green-600', 'border-b-2', 'border-green-600', 'bg-green-50'); + modeBom.classList.remove('text-gray-500'); + modeFormula.classList.remove('text-blue-600', 'border-b-2', 'border-blue-600', 'bg-blue-50'); + modeFormula.classList.add('text-gray-500'); + + // FG 목록 로드 + loadFinishedGoods(); + } + } + + // 완제품 목록 로드 + async function loadFinishedGoods() { + const select = document.getElementById('fgCodeSelect'); + if (select.options.length > 1) return; // 이미 로드됨 + + try { + const response = await fetch('/api/admin/quote-formulas/formulas/items?item_type=FG', { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' } + }); + const result = await response.json(); + + if (result.success && result.data && result.data.items) { + result.data.items.forEach(item => { + const option = document.createElement('option'); + option.value = item.code; + option.textContent = `${item.code} - ${item.name}`; + select.appendChild(option); + }); + } + } catch (err) { + console.error('완제품 목록 로드 실패:', err); + } + } + + // BOM 시뮬레이션 폼 제출 + document.getElementById('bomSimulatorForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const formData = new FormData(this); + const fgCode = formData.get('finished_goods_code'); + + if (!fgCode) { + alert('완제품을 선택하세요.'); + return; + } + + const inputVars = { + W0: parseFloat(formData.get('W0')) || 2000, + H0: parseFloat(formData.get('H0')) || 3000, + QTY: parseInt(formData.get('QTY')) || 1 + }; + + // UI 상태 변경 + document.getElementById('runBomButton').disabled = true; + document.getElementById('bomSpinner').classList.remove('hidden'); + + try { + const response = await fetch('/api/admin/quote-formulas/formulas/simulate-bom', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}' + }, + body: JSON.stringify({ + finished_goods_code: fgCode, + input_variables: inputVars + }) + }); + + const result = await response.json(); + + if (result.success && result.data) { + renderBomResults(result.data); + } else { + alert(result.message || 'BOM 계산에 실패했습니다.'); + resetBomResults(); + } + } catch (err) { + console.error('BOM 계산 오류:', err); + alert('서버 오류가 발생했습니다.'); + resetBomResults(); + } finally { + document.getElementById('runBomButton').disabled = false; + document.getElementById('bomSpinner').classList.add('hidden'); + } + }); + + // BOM 결과 초기화 + function resetBomResults() { + document.getElementById('debugSteps').innerHTML = '

BOM 계산을 실행하세요

'; + document.getElementById('processGroups').innerHTML = '

BOM 계산을 실행하세요

'; + document.getElementById('costSummary').innerHTML = '

BOM 계산을 실행하세요

'; + } + + // BOM 결과 렌더링 + function renderBomResults(data) { + renderDebugSteps(data.debug_steps || []); + renderProcessGroups(data.grouped_items || {}); + renderCostSummary(data.subtotals || {}, data.grand_total || 0, data.finished_goods || {}); + } + + // 디버그 스텝 아이콘 + const stepIcons = { + 1: '📥', 2: '🔢', 3: '📦', 4: '🌳', 5: '💵', + 6: '✖️', 7: '💰', 8: '🏭', 9: '📊', 10: '🎯' + }; + + // 디버깅 패널 렌더링 + function renderDebugSteps(steps) { + const container = document.getElementById('debugSteps'); + + if (!steps || steps.length === 0) { + container.innerHTML = '

디버깅 정보가 없습니다.

'; + return; + } + + container.innerHTML = steps.map(step => { + const icon = stepIcons[step.step] || '•'; + const statusColor = step.status === 'success' ? 'text-green-600' : step.status === 'error' ? 'text-red-600' : 'text-blue-600'; + const bgColor = step.status === 'success' ? 'bg-green-50 border-green-200' : step.status === 'error' ? 'bg-red-50 border-red-200' : 'bg-blue-50 border-blue-200'; + + // 상세 정보 렌더링 + let detailHtml = ''; + if (step.details) { + if (typeof step.details === 'object') { + const entries = Object.entries(step.details); + if (entries.length > 0) { + detailHtml = ` +
+ ${entries.slice(0, 5).map(([k, v]) => { + const val = typeof v === 'object' ? JSON.stringify(v) : v; + return `
${k}: ${val}
`; + }).join('')} + ${entries.length > 5 ? `
... +${entries.length - 5}개
` : ''} +
+ `; + } + } else { + detailHtml = `
${step.details}
`; + } + } + + return ` +
+
+ ${icon} +
+
+ Step ${step.step} + ${step.name || ''} +
+
${step.message || ''}
+ ${detailHtml} +
+
+
+ `; + }).join(''); + } + + // 공정명 한글화 + const processLabels = { + 'screen': '🎬 스크린 공정', + 'bending': '🔧 절곡 공정', + 'steel': '🔩 철재 공정', + 'electric': '⚡ 전기 공정', + 'assembly': '🔨 조립 공정', + 'unknown': '❓ 기타' + }; + + // 공정별 그룹 렌더링 + function renderProcessGroups(groupedItems) { + const container = document.getElementById('processGroups'); + + const processKeys = Object.keys(groupedItems); + if (processKeys.length === 0) { + container.innerHTML = '

그룹화된 품목이 없습니다.

'; + return; + } + + container.innerHTML = processKeys.map(processType => { + const group = groupedItems[processType]; + const items = group.items || []; + const subtotal = group.subtotal || 0; + const label = processLabels[processType] || processType; + + return ` +
+
+ ${label} + ${items.length}개 품목 +
+
+ ${items.slice(0, 8).map(item => renderProcessItem(item)).join('')} + ${items.length > 8 ? `
... +${items.length - 8}개 더
` : ''} +
+
+ 소계: + ${subtotal.toLocaleString()}원 +
+
+ `; + }).join(''); + } + + // 공정별 품목 아이템 렌더링 + function renderProcessItem(item) { + const price = item.amount || 0; + const qty = item.quantity || 0; + const categoryNote = item.calculation_note || ''; + + return ` +
+ ${item.item_code || ''} + ${item.item_name || ''} + ${qty} ${item.unit || 'EA'} + ${price.toLocaleString()}원 +
+ `; + } + + // 원가 요약 렌더링 + function renderCostSummary(subtotals, grandTotal, finishedGoods) { + const container = document.getElementById('costSummary'); + + // 완제품 정보 + let fgHtml = ''; + if (finishedGoods && finishedGoods.code) { + fgHtml = ` +
+
완제품
+
${finishedGoods.code}
+
${finishedGoods.name || ''}
+
+ `; + } + + // 공정별 소계 + const subtotalEntries = Object.entries(subtotals); + let subtotalsHtml = ''; + if (subtotalEntries.length > 0) { + subtotalsHtml = ` +
+
공정별 소계
+ ${subtotalEntries.map(([process, amount]) => { + const label = processLabels[process] || process; + return ` +
+ ${label} + ${amount.toLocaleString()}원 +
+ `; + }).join('')} +
+ `; + } + + // 총합계 + const totalHtml = ` +
+
+ 총 원가 + ${grandTotal.toLocaleString()}원 +
+
+ `; + + container.innerHTML = fgHtml + subtotalsHtml + totalHtml; + } + // 초기화 실행 init(); + setupModeTabs(); @endpush \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index c54d9a75..d75435e1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -525,6 +525,7 @@ Route::post('/validate', [QuoteFormulaController::class, 'validate'])->name('validate'); Route::post('/test', [QuoteFormulaController::class, 'test'])->name('test'); Route::post('/simulate', [QuoteFormulaController::class, 'simulate'])->name('simulate'); + Route::post('/simulate-bom', [QuoteFormulaController::class, 'simulateBom'])->name('simulate-bom'); Route::get('/items', [QuoteFormulaController::class, 'items'])->name('items'); // 기본 CRUD