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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@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