feat(simulator): Phase 4 - BOM 디버깅 UI 및 API 추가

- simulateBom API 엔드포인트 추가 (calculateBomWithDebug 연동)
- simulator.blade.php: BOM 디버깅 모드 탭 추가
  - 10단계 디버그 스텝 패널
  - 공정별 품목 그룹화 표시
  - 원가 요약 (공정별 소계, 총합계)
- FormulaEvaluatorService: currentTenantId 속성 추가
  - 콘솔/API에서 tenant_id 전달 가능하도록 수정
- routes/api.php: simulate-bom 라우트 추가
This commit is contained in:
2025-12-24 15:45:54 +09:00
parent 00374cd9fe
commit 9e042b5d6b
4 changed files with 441 additions and 3 deletions

View File

@@ -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,
]);
}
/**
* 전체 품목 목록 (시뮬레이터용)
*/

View File

@@ -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, '입력값수집', [

View File

@@ -66,6 +66,95 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium
</div>
</div>
<!-- 모드 선택 -->
<div class="bg-white rounded-lg shadow-sm mb-4">
<div class="flex border-b">
<button type="button" id="modeFormula" class="flex-1 px-4 py-3 text-sm font-medium text-blue-600 border-b-2 border-blue-600 bg-blue-50 mode-tab" data-mode="formula">
📊 수식 시뮬레이션
</button>
<button type="button" id="modeBom" class="flex-1 px-4 py-3 text-sm font-medium text-gray-500 hover:text-gray-700 mode-tab" data-mode="bom">
🔧 BOM 디버깅
</button>
</div>
</div>
<!-- BOM 시뮬레이션 섹션 (숨김 기본) -->
<div id="bomSection" class="hidden">
<!-- BOM 입력 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">완제품 BOM 시뮬레이션</h3>
<form id="bomSimulatorForm">
<div class="flex flex-wrap gap-3 items-end">
<!-- 완제품 코드 선택 -->
<div class="w-48">
<label class="block text-xs font-medium text-gray-600 mb-1">완제품 (FG)</label>
<select id="fgCodeSelect" name="finished_goods_code" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">완제품 선택...</option>
</select>
</div>
<!-- (W0) -->
<div class="w-24">
<label class="block text-xs font-medium text-gray-600 mb-1"> W0 (mm)</label>
<input type="number" name="W0" value="2000" min="100" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 높이 (H0) -->
<div class="w-24">
<label class="block text-xs font-medium text-gray-600 mb-1">높이 H0 (mm)</label>
<input type="number" name="H0" value="3000" min="100" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 수량 -->
<div class="w-20">
<label class="block text-xs font-medium text-gray-600 mb-1">수량</label>
<input type="number" name="QTY" value="1" min="1" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- 실행 버튼 -->
<button type="submit" id="runBomButton" class="bg-green-600 hover:bg-green-700 text-white px-4 py-1.5 rounded font-medium text-sm transition-colors flex items-center gap-2">
<span>BOM 계산</span>
<svg id="bomSpinner" class="hidden animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
</form>
</div>
<!-- BOM 결과 영역 -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<!-- 디버깅 패널 (10단계) -->
<div class="bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
🔍 계산 과정 (10단계)
</h3>
<div id="debugSteps" class="space-y-2 text-xs max-h-[60vh] overflow-y-auto">
<p class="text-gray-400 text-center py-4">BOM 계산을 실행하세요</p>
</div>
</div>
<!-- 공정별 품목 그룹 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
🏭 공정별 품목
</h3>
<div id="processGroups" class="space-y-3 max-h-[60vh] overflow-y-auto">
<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>
</div>
</div>
<!-- 소계 합계 -->
<div class="bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
💰 원가 요약
</h3>
<div id="costSummary" class="space-y-2 text-sm">
<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>
</div>
</div>
</div>
</div>
<!-- 수식 시뮬레이션 섹션 -->
<div id="formulaSection">
<!-- 결과 영역 (2 그리드) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- 좌측: 실행 결과 (계산된 변수) -->
@@ -165,6 +254,7 @@ class="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-n
</div>
</div>
</div>
</div> <!-- /formulaSection -->
</div>
@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 = '<p class="text-gray-400 text-center py-4">BOM 계산을 실행하세요</p>';
document.getElementById('processGroups').innerHTML = '<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>';
document.getElementById('costSummary').innerHTML = '<p class="text-gray-400 text-center py-4 text-xs">BOM 계산을 실행하세요</p>';
}
// 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 = '<p class="text-gray-400 text-center py-4">디버깅 정보가 없습니다.</p>';
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 = `
<div class="mt-1 pl-6 text-[10px] text-gray-500">
${entries.slice(0, 5).map(([k, v]) => {
const val = typeof v === 'object' ? JSON.stringify(v) : v;
return `<div><span class="text-gray-400">${k}:</span> ${val}</div>`;
}).join('')}
${entries.length > 5 ? `<div class="text-gray-400">... +${entries.length - 5}개</div>` : ''}
</div>
`;
}
} else {
detailHtml = `<div class="mt-1 pl-6 text-[10px] text-gray-500">${step.details}</div>`;
}
}
return `
<div class="p-2 rounded border ${bgColor}">
<div class="flex items-start gap-2">
<span class="text-sm">${icon}</span>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium ${statusColor}">Step ${step.step}</span>
<span class="text-gray-600">${step.name || ''}</span>
</div>
<div class="text-gray-500 mt-0.5">${step.message || ''}</div>
${detailHtml}
</div>
</div>
</div>
`;
}).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 = '<p class="text-gray-400 text-center py-4 text-xs">그룹화된 품목이 없습니다.</p>';
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 `
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-gray-100 flex items-center justify-between">
<span class="font-medium text-gray-700 text-xs">${label}</span>
<span class="text-xs text-gray-500">${items.length}개 품목</span>
</div>
<div class="p-2 space-y-1 text-xs">
${items.slice(0, 8).map(item => renderProcessItem(item)).join('')}
${items.length > 8 ? `<div class="text-gray-400 text-center py-1">... +${items.length - 8}개 더</div>` : ''}
</div>
<div class="px-3 py-1.5 bg-gray-50 text-right border-t">
<span class="text-xs text-gray-500">소계:</span>
<span class="font-medium text-gray-700 ml-1">${subtotal.toLocaleString()}원</span>
</div>
</div>
`;
}).join('');
}
// 공정별 품목 아이템 렌더링
function renderProcessItem(item) {
const price = item.amount || 0;
const qty = item.quantity || 0;
const categoryNote = item.calculation_note || '';
return `
<div class="flex items-center gap-2 py-1 px-2 bg-white rounded hover:bg-gray-50">
<span class="font-mono text-gray-400 truncate w-20">${item.item_code || ''}</span>
<span class="text-gray-700 truncate flex-1">${item.item_name || ''}</span>
<span class="text-gray-500 whitespace-nowrap">${qty} ${item.unit || 'EA'}</span>
<span class="text-green-600 font-medium whitespace-nowrap">${price.toLocaleString()}원</span>
</div>
`;
}
// 원가 요약 렌더링
function renderCostSummary(subtotals, grandTotal, finishedGoods) {
const container = document.getElementById('costSummary');
// 완제품 정보
let fgHtml = '';
if (finishedGoods && finishedGoods.code) {
fgHtml = `
<div class="p-3 bg-purple-50 rounded-lg border border-purple-200 mb-3">
<div class="text-xs text-purple-600 font-medium">완제품</div>
<div class="font-medium text-gray-800">${finishedGoods.code}</div>
<div class="text-sm text-gray-600">${finishedGoods.name || ''}</div>
</div>
`;
}
// 공정별 소계
const subtotalEntries = Object.entries(subtotals);
let subtotalsHtml = '';
if (subtotalEntries.length > 0) {
subtotalsHtml = `
<div class="space-y-2 mb-4">
<div class="text-xs font-medium text-gray-500 uppercase">공정별 소계</div>
${subtotalEntries.map(([process, amount]) => {
const label = processLabels[process] || process;
return `
<div class="flex justify-between items-center py-1.5 px-2 bg-gray-50 rounded">
<span class="text-gray-600">${label}</span>
<span class="font-medium text-gray-700">${amount.toLocaleString()}원</span>
</div>
`;
}).join('')}
</div>
`;
}
// 총합계
const totalHtml = `
<div class="p-3 bg-green-50 rounded-lg border border-green-200">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-700">총 원가</span>
<span class="text-xl font-bold text-green-600">${grandTotal.toLocaleString()}원</span>
</div>
</div>
`;
container.innerHTML = fgHtml + subtotalsHtml + totalHtml;
}
// 초기화 실행
init();
setupModeTabs();
</script>
@endpush

View File

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