feat: [item-management] 절곡BOM 탭 추가

- 중앙 패널에 '절곡 BOM' 탭 추가 (정적 BOM 옆)
- SF-BND 절곡 품목과 하위 자재를 트리 구조로 표시
- 접힘/펼침 토글, 품목 클릭 시 우측 상세 갱신
- FG 품목 선택 시 해당 FG의 절곡 관련 BOM만 필터
This commit is contained in:
김보곤
2026-03-18 15:03:38 +09:00
parent e5bb064eea
commit 4e443c8020
4 changed files with 270 additions and 2 deletions

View File

@@ -74,6 +74,18 @@ public function history(int $id): JsonResponse
return response()->json($history);
}
/**
* 절곡BOM 트리 (JSON - 중앙 패널)
* FG 품목 선택 시 해당 FG의 절곡 관련 BOM만, 미선택 시 전체 절곡 품목
*/
public function bendingBomTree(Request $request): JsonResponse
{
$itemId = $request->input('item_id') ? (int) $request->input('item_id') : null;
$tree = $this->service->getBendingBomTree($itemId);
return response()->json($tree);
}
/**
* 수식 기반 BOM 산출 (API 서버의 FormulaEvaluatorService HTTP 호출)
*/

View File

@@ -229,6 +229,127 @@ private function getActionLabel(string $action): string
};
}
/**
* 절곡BOM 트리 조회 (SF-BND 품목 기반)
* FG 품목 선택 시: 해당 FG의 BOM에서 절곡 관련 품목만 필터
* 그 외: 전체 절곡 품목(SF-BND) 트리 반환
*/
public function getBendingBomTree(?int $itemId = null): array
{
$tenantId = session('selected_tenant_id');
// FG 품목이 선택된 경우: 해당 FG의 BOM에서 절곡 관련만 추출
if ($itemId) {
$item = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->find($itemId);
if ($item && $item->item_type === 'FG') {
return $this->buildFgBendingTree($item, $tenantId);
}
}
// 전체 절곡 품목 트리
$bendingItems = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('code', 'like', 'SF-BND%')
->active()
->orderBy('code')
->get();
$nodes = [];
foreach ($bendingItems as $bendingItem) {
$nodes[] = $this->buildBomNode($bendingItem, 0, 3, []);
}
return $nodes;
}
/**
* FG 품목의 BOM에서 절곡 관련 품목만 추출하여 트리 구성
*/
private function buildFgBendingTree(Item $fgItem, int $tenantId): array
{
$bomData = $fgItem->bom ?? [];
if (empty($bomData)) {
return [];
}
$childIds = array_column($bomData, 'child_item_id');
$children = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->whereIn('id', $childIds)
->get()
->keyBy('id');
$nodes = [];
foreach ($bomData as $bom) {
$child = $children->get($bom['child_item_id']);
if (! $child) {
continue;
}
// 절곡 관련 품목이면 바로 추가
if (str_starts_with($child->code, 'SF-BND')) {
$node = $this->buildBomNode($child, 0, 3, []);
$node['quantity'] = $bom['quantity'] ?? 1;
$nodes[] = $node;
continue;
}
// SF 품목이면 그 하위에서 절곡 품목 탐색
if ($child->item_type === 'SF' || $child->item_type === 'PT') {
$subBending = $this->findBendingChildren($child, $tenantId, 1, []);
$nodes = array_merge($nodes, $subBending);
}
}
return $nodes;
}
/**
* 재귀적으로 절곡 관련 자식 품목 탐색
*/
private function findBendingChildren(Item $item, int $tenantId, int $depth, array $visited): array
{
if (in_array($item->id, $visited) || $depth >= 5) {
return [];
}
$visited[] = $item->id;
$bomData = $item->bom ?? [];
if (empty($bomData)) {
return [];
}
$childIds = array_column($bomData, 'child_item_id');
$children = Item::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->whereIn('id', $childIds)
->get()
->keyBy('id');
$nodes = [];
foreach ($bomData as $bom) {
$child = $children->get($bom['child_item_id']);
if (! $child) {
continue;
}
if (str_starts_with($child->code, 'SF-BND')) {
$node = $this->buildBomNode($child, 0, 3, []);
$node['quantity'] = $bom['quantity'] ?? 1;
$nodes[] = $node;
} elseif ($child->item_type === 'SF' || $child->item_type === 'PT') {
$subNodes = $this->findBendingChildren($child, $tenantId, $depth + 1, $visited);
$nodes = array_merge($nodes, $subNodes);
}
}
return $nodes;
}
// ── Private ──
private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array

View File

@@ -50,6 +50,11 @@ class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 text-blue-
onclick="switchBomTab('static')">
정적 BOM
</button>
<button type="button" id="tab-bending-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200"
onclick="switchBomTab('bending')">
절곡 BOM
</button>
<button type="button" id="tab-formula-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200"
onclick="switchBomTab('formula')"
@@ -127,6 +132,11 @@ class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-b
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
</div>
<!-- 절곡 BOM 트리 영역 (초기 숨김) -->
<div id="bending-bom-container" class="flex-1 overflow-y-auto p-4" style="display:none;">
<p class="text-gray-400 text-center py-10">절곡 BOM을 로드 ...</p>
</div>
<!-- 수식 산출 결과 영역 (초기 숨김) -->
<div id="formula-result-container" class="flex-1 overflow-y-auto p-4" style="display:none;">
<p class="text-gray-400 text-center py-10">오픈사이즈를 입력하고 산출 버튼을 클릭하세요.</p>
@@ -366,7 +376,8 @@ function getTypeTitle(type) {
btn.classList.remove('bg-blue-100', 'text-blue-800');
btn.classList.add('bg-gray-100', 'text-gray-600');
});
const activeBtn = document.getElementById(tab === 'static' ? 'tab-static-bom' : 'tab-formula-bom');
const tabBtnMap = { static: 'tab-static-bom', bending: 'tab-bending-bom', formula: 'tab-formula-bom' };
const activeBtn = document.getElementById(tabBtnMap[tab]);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-100', 'text-gray-600');
activeBtn.classList.add('bg-blue-100', 'text-blue-800');
@@ -374,8 +385,14 @@ function getTypeTitle(type) {
// 콘텐츠 영역 전환
document.getElementById('bom-tree-container').style.display = (tab === 'static') ? '' : 'none';
document.getElementById('bending-bom-container').style.display = (tab === 'bending') ? '' : 'none';
document.getElementById('formula-input-panel').style.display = (tab === 'formula') ? '' : 'none';
document.getElementById('formula-result-container').style.display = (tab === 'formula') ? '' : 'none';
// 절곡BOM 탭 선택 시 데이터 로드
if (tab === 'bending') {
loadBendingBomTree();
}
};
// ── 가변사이즈 탭 표시/숨김 ──
@@ -388,7 +405,124 @@ function hideFormulaTab() {
document.getElementById('tab-formula-bom').style.display = 'none';
document.getElementById('formula-input-panel').style.display = 'none';
document.getElementById('formula-result-container').style.display = 'none';
switchBomTab('static');
if (currentBomTab === 'formula') switchBomTab('static');
}
// ── 절곡BOM 트리 로드 ──
function loadBendingBomTree() {
const container = document.getElementById('bending-bom-container');
container.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
let url = '/api/admin/items/bending-bom-tree';
if (currentItemId) {
url += `?item_id=${currentItemId}`;
}
fetch(url, { headers: {'X-CSRF-TOKEN': csrfToken} })
.then(res => res.json())
.then(nodes => {
container.innerHTML = '';
if (!nodes || nodes.length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-10">절곡 BOM 데이터가 없습니다.</p>';
return;
}
// 헤더 정보
const header = document.createElement('div');
header.className = 'mb-3 px-3 py-2 bg-amber-50 rounded-lg flex items-center gap-2';
header.innerHTML = `
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/>
</svg>
<span class="text-sm font-medium text-amber-800">절곡 품목</span>
<span class="text-xs text-amber-600">(${nodes.length}건)</span>
`;
container.appendChild(header);
const ul = document.createElement('ul');
ul.className = 'text-sm space-y-1';
nodes.forEach(node => renderBendingNode(node, ul, 0));
container.appendChild(ul);
})
.catch(err => {
console.error('bendingBomTree error:', err);
container.innerHTML = '<p class="text-red-400 text-center py-10">절곡 BOM 로드 실패</p>';
});
}
// ── 절곡BOM 노드 렌더링 (트리 구조, 접힘/펼침) ──
function renderBendingNode(node, container, depth) {
const li = document.createElement('li');
if (depth > 0) li.className = 'ml-4';
const nodeEl = document.createElement('div');
const depthColors = depth === 0
? 'bg-amber-50 hover:bg-amber-100 border border-amber-200'
: 'hover:bg-gray-50';
nodeEl.className = `flex items-center gap-2 py-2 px-3 rounded cursor-pointer transition-colors ${depthColors}`;
nodeEl.onclick = () => selectTreeNode(node.id);
// 펼침/접힘 토글
let childList = null;
if (node.has_children) {
const toggle = document.createElement('span');
toggle.className = 'text-gray-400 cursor-pointer select-none text-xs flex-shrink-0';
toggle.textContent = '▼';
toggle.onclick = function(e) {
e.stopPropagation();
if (childList.style.display === 'none') {
childList.style.display = '';
toggle.textContent = '▼';
} else {
childList.style.display = 'none';
toggle.textContent = '▶';
}
};
nodeEl.appendChild(toggle);
} else {
const spacer = document.createElement('span');
spacer.className = 'w-3 inline-block flex-shrink-0';
nodeEl.appendChild(spacer);
}
// 유형 뱃지
const badge = document.createElement('span');
badge.className = getTypeBadgeClass(node.item_type);
badge.textContent = node.item_type;
badge.title = getTypeTitle(node.item_type);
nodeEl.appendChild(badge);
// 코드
const codeSpan = document.createElement('span');
codeSpan.className = 'font-mono text-xs text-gray-500 flex-shrink-0';
codeSpan.textContent = node.code;
nodeEl.appendChild(codeSpan);
// 이름
const nameSpan = document.createElement('span');
nameSpan.className = 'text-gray-700 text-sm truncate';
nameSpan.textContent = node.name;
nodeEl.appendChild(nameSpan);
// 수량
if (node.quantity) {
const qtySpan = document.createElement('span');
qtySpan.className = 'text-blue-600 text-xs font-medium ml-auto flex-shrink-0';
qtySpan.textContent = `x${node.quantity}`;
nodeEl.appendChild(qtySpan);
}
li.appendChild(nodeEl);
// 자식 노드 재귀 렌더링
if (node.children && node.children.length > 0) {
childList = document.createElement('ul');
childList.className = 'border-l-2 border-amber-200 ml-3';
node.children.forEach(child => renderBendingNode(child, childList, depth + 1));
li.appendChild(childList);
}
container.appendChild(li);
}
// ── FG 코드 파싱 → 입력폼 자동 세팅 ──

View File

@@ -930,6 +930,7 @@
Route::get('/{id}/history', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'history'])->name('history');
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'destroy'])->name('destroy');
Route::post('/{id}/calculate-formula', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'calculateFormula'])->name('calculate-formula');
Route::get('/bending-bom-tree', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'bendingBomTree'])->name('bending-bom-tree');
});
/*