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, ]; } /** * 품목 사용 현황 조회 (삭제 전 참조 체크) */ 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}건)"; } // 주문 항목 $orderItems = DB::table('order_items')->where('item_id', $itemId)->count(); if ($orderItems > 0) { $usage[] = "수주 항목 ({$orderItems}건)"; } // 견적 $quotes = DB::table('quotes')->where('item_id', $itemId)->count(); if ($quotes > 0) { $usage[] = "견적 ({$quotes}건)"; } // 자재 입고 $receipts = DB::table('material_receipts')->where('item_id', $itemId)->count(); if ($receipts > 0) { $usage[] = "자재 입고 ({$receipts}건)"; } // LOT $lots = DB::table('lots')->where('item_id', $itemId)->count(); if ($lots > 0) { $usage[] = "LOT ({$lots}건)"; } // 작업지시 $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, }; } // ── Private ── private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array { // 순환 참조 방지: visited 배열 + maxDepth 이중 안전장치 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'); 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, ]; } }