From 4e443c8020e1513da0099ba1c2c683f582d3b59e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?=
Date: Wed, 18 Mar 2026 15:03:38 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20[item-management]=20=EC=A0=88=EA=B3=A1B?=
=?UTF-8?q?OM=20=ED=83=AD=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 중앙 패널에 '절곡 BOM' 탭 추가 (정적 BOM 옆)
- SF-BND 절곡 품목과 하위 자재를 트리 구조로 표시
- 접힘/펼침 토글, 품목 클릭 시 우측 상세 갱신
- FG 품목 선택 시 해당 FG의 절곡 관련 BOM만 필터
---
.../Api/Admin/ItemManagementApiController.php | 12 ++
app/Services/ItemManagementService.php | 121 +++++++++++++++
.../views/item-management/index.blade.php | 138 +++++++++++++++++-
routes/api.php | 1 +
4 files changed, 270 insertions(+), 2 deletions(-)
diff --git a/app/Http/Controllers/Api/Admin/ItemManagementApiController.php b/app/Http/Controllers/Api/Admin/ItemManagementApiController.php
index d88e2199..9f38ba07 100644
--- a/app/Http/Controllers/Api/Admin/ItemManagementApiController.php
+++ b/app/Http/Controllers/Api/Admin/ItemManagementApiController.php
@@ -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 호출)
*/
diff --git a/app/Services/ItemManagementService.php b/app/Services/ItemManagementService.php
index fd9c0089..4142c9da 100644
--- a/app/Services/ItemManagementService.php
+++ b/app/Services/ItemManagementService.php
@@ -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
diff --git a/resources/views/item-management/index.blade.php b/resources/views/item-management/index.blade.php
index 854fb8a3..a4dde5e7 100644
--- a/resources/views/item-management/index.blade.php
+++ b/resources/views/item-management/index.blade.php
@@ -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
+
+
+
+
오픈사이즈를 입력하고 산출 버튼을 클릭하세요.
@@ -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 = '
';
+
+ 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 = '
절곡 BOM 데이터가 없습니다.
';
+ 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 = `
+
+
절곡 품목
+
(${nodes.length}건)
+ `;
+ 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 = '
절곡 BOM 로드 실패
';
+ });
+ }
+
+ // ── 절곡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 코드 파싱 → 입력폼 자동 세팅 ──
diff --git a/routes/api.php b/routes/api.php
index 23231f5f..6122c482 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -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');
});
/*