where('tenant_id', $tenantId) ->search($search) ->active() ->when($itemType, function ($query, $types) { $typeList = explode(',', $types); $query->whereIn('item_type', $typeList); }) ->orderBy('code') ->paginate($perPage); } /** * BOM 재귀 트리 조회 */ public function getBomTree(int $itemId, int $maxDepth = 10): array { $item = Item::withoutGlobalScopes() ->where('tenant_id', session('selected_tenant_id')) ->with('details') ->findOrFail($itemId); return $this->buildBomNode($item, 0, $maxDepth, []); } /** * 품목 상세 조회 (1depth BOM + 파일 + 절곡정보) */ public function getItemDetail(int $itemId): array { $tenantId = session('selected_tenant_id'); $item = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->with(['details', 'category', 'files']) ->findOrFail($itemId); // BOM 1depth: 직접 연결된 자식 품목만 $bomChildren = []; $bomData = $item->bom ?? []; if (! empty($bomData)) { $childIds = array_column($bomData, 'child_item_id'); $children = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->get(['id', 'code', 'name', 'item_type', 'unit']) ->keyBy('id'); foreach ($bomData as $bom) { $child = $children->get($bom['child_item_id']); if ($child) { $bomChildren[] = [ 'id' => $child->id, 'code' => $child->code, 'name' => $child->name, 'item_type' => $child->item_type, 'unit' => $child->unit, 'quantity' => $bom['quantity'] ?? 1, ]; } } } return [ 'item' => $item, 'bom_children' => $bomChildren, ]; } /** * 수식 산출 결과를 FG 품목의 BOM으로 저장 */ public function saveBomFromFormula(int $itemId, array $bomItems): array { $tenantId = session('selected_tenant_id'); $item = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->findOrFail($itemId); if ($item->item_type !== 'FG') { return ['success' => false, 'error' => 'FG 품목만 BOM을 저장할 수 있습니다.']; } // child_item_id 유효성 검증 $childIds = array_column($bomItems, 'child_item_id'); $validIds = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->whereIn('id', $childIds) ->pluck('id') ->toArray(); $bom = []; foreach ($bomItems as $bi) { $childId = (int) $bi['child_item_id']; if (! in_array($childId, $validIds)) { continue; } $bom[] = [ 'child_item_id' => $childId, 'child_item_code' => $bi['child_item_code'] ?? '', 'quantity' => round((float) ($bi['quantity'] ?? 1), 4), 'unit' => $bi['unit'] ?? '', 'category' => $bi['category'] ?? '', ]; } $item->bom = $bom; $item->updated_by = auth()->id(); $item->save(); return [ 'success' => true, 'message' => "BOM {$item->name}에 ".count($bom).'건 저장 완료', 'count' => count($bom), ]; } /** * 품목 사용 현황 조회 (삭제 전 참조 체크) */ public function checkItemUsage(int $itemId): array { $tenantId = session('selected_tenant_id'); $usage = []; // 다른 품목의 BOM에 포함되어 있는지 (JSON 검색) $bomParents = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->whereRaw("JSON_CONTAINS(bom, JSON_OBJECT('child_item_id', ?))", [$itemId]) ->count(); if ($bomParents > 0) { $usage[] = "다른 품목의 BOM 구성품 ({$bomParents}건)"; } // 주문 항목 (order_items.item_id) $orderItems = DB::table('order_items')->where('item_id', $itemId)->count(); if ($orderItems > 0) { $usage[] = "수주 항목 ({$orderItems}건)"; } // 입고 (receivings.item_id) if (DB::getSchemaBuilder()->hasTable('receivings')) { $receipts = DB::table('receivings')->where('item_id', $itemId)->count(); if ($receipts > 0) { $usage[] = "입고 ({$receipts}건)"; } } // LOT (lots.item_id) $lots = DB::table('lots')->where('item_id', $itemId)->count(); if ($lots > 0) { $usage[] = "LOT ({$lots}건)"; } // 작업지시 (work_order_items.item_id) $workOrders = DB::table('work_order_items')->where('item_id', $itemId)->count(); if ($workOrders > 0) { $usage[] = "작업지시 ({$workOrders}건)"; } return $usage; } /** * 품목 삭제 (Soft Delete) */ public function deleteItem(int $itemId): array { $tenantId = session('selected_tenant_id'); $item = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->findOrFail($itemId); // 사용 현황 체크 $usage = $this->checkItemUsage($itemId); if (! empty($usage)) { return [ 'success' => false, 'error' => '사용 중인 품목은 삭제할 수 없습니다.', 'usage' => $usage, ]; } $item->deleted_by = auth()->id(); $item->save(); $item->delete(); return ['success' => true, 'message' => "품목 '{$item->name}'이(가) 삭제되었습니다."]; } /** * 품목 이력 조회 (audit_logs + 생성/수정 정보) */ public function getItemHistory(int $itemId): array { $tenantId = session('selected_tenant_id'); $item = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->findOrFail($itemId); // audit_logs에서 해당 품목 이력 $auditLogs = AuditLog::where('tenant_id', $tenantId) ->where('target_type', 'item') ->where('target_id', $itemId) ->orderByDesc('created_at') ->limit(50) ->get(); // 사용자 ID → 이름 매핑 $actorIds = $auditLogs->pluck('actor_id')->filter()->unique()->values(); $actors = []; if ($actorIds->isNotEmpty()) { $actors = DB::table('users') ->whereIn('id', $actorIds) ->pluck('name', 'id') ->toArray(); } $logs = $auditLogs->map(function ($log) use ($actors) { return [ 'id' => $log->id, 'action' => $log->action, 'action_label' => $this->getActionLabel($log->action), 'actor' => $actors[$log->actor_id] ?? '시스템', 'created_at' => $log->created_at->format('Y-m-d H:i:s'), 'before' => $log->before, 'after' => $log->after, ]; }); return [ 'item' => [ 'id' => $item->id, 'code' => $item->code, 'name' => $item->name, 'created_at' => $item->created_at?->format('Y-m-d H:i:s'), 'updated_at' => $item->updated_at?->format('Y-m-d H:i:s'), ], 'logs' => $logs, ]; } private function getActionLabel(string $action): string { return match ($action) { 'created' => '생성', 'updated' => '수정', 'deleted' => '삭제', 'restored' => '복원', 'bom_updated' => 'BOM 변경', 'stock_increase' => '재고 증가', 'stock_decrease' => '재고 차감', default => $action, }; } /** * 절곡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 { if (in_array($item->id, $visited) || $depth >= $maxDepth) { return $this->formatNode($item, $depth, []); } $visited[] = $item->id; $children = []; $bomData = $item->bom ?? []; if (! empty($bomData)) { $childIds = array_column($bomData, 'child_item_id'); $childItems = Item::withoutGlobalScopes() ->where('tenant_id', session('selected_tenant_id')) ->whereIn('id', $childIds) ->get() ->keyBy('id'); // category 필드가 있으면 카테고리별 그룹 노드 생성 $hasCategory = collect($bomData)->contains(fn ($b) => ! empty($b['category'])); if ($hasCategory) { $grouped = []; foreach ($bomData as $bom) { $cat = $bom['category'] ?? '기타'; $grouped[$cat][] = $bom; } foreach ($grouped as $catName => $catBomItems) { $catChildren = []; foreach ($catBomItems as $bom) { $childItem = $childItems->get($bom['child_item_id']); if ($childItem) { $childNode = $this->buildBomNode($childItem, $depth + 2, $maxDepth, $visited); $childNode['quantity'] = $bom['quantity'] ?? 1; $catChildren[] = $childNode; } } if (! empty($catChildren)) { $children[] = [ 'id' => 0, 'code' => '', 'name' => $catName, 'item_type' => 'CAT', 'unit' => '', 'depth' => $depth + 1, 'has_children' => true, 'children' => $catChildren, 'count' => count($catChildren), ]; } } } else { foreach ($bomData as $bom) { $childItem = $childItems->get($bom['child_item_id']); if ($childItem) { $childNode = $this->buildBomNode($childItem, $depth + 1, $maxDepth, $visited); $childNode['quantity'] = $bom['quantity'] ?? 1; $children[] = $childNode; } } } } return $this->formatNode($item, $depth, $children); } private function formatNode(Item $item, int $depth, array $children): array { return [ 'id' => $item->id, 'code' => $item->code, 'name' => $item->name, 'item_type' => $item->item_type, 'unit' => $item->unit, 'depth' => $depth, 'has_children' => count($children) > 0, 'children' => $children, ]; } }