tenantId = $this->tenantId ?? $this->tenantId(); } /** 테넌트별로 "제품처럼 자식이 있을 수 있는" ref_type 목록 */ protected function productLikeTypes(): array { $all = config('products.product_like_types', []); $byTenant = Arr::get($all, (string) $this->tenantId, null); if (is_array($byTenant) && $byTenant) { return $byTenant; } return Arr::get($all, '*', ['PRODUCT']); } /** 한 부모 ID에 달린 컴포넌트 라인들 로드 (메모이즈) */ protected function getLinesForParent(int $parentId): array { // 간단 메모이즈(요청 범위); 대량 호출 방지 static $memo = []; if (array_key_exists($parentId, $memo)) { return $memo[$parentId]; } $rows = ProductComponent::where('tenant_id', $this->tenantId) ->where('parent_product_id', $parentId) ->orderBy('sort_order')->orderBy('id') ->get([ 'id', 'tenant_id', 'parent_product_id', 'category_id', 'category_name', 'ref_type', 'ref_id', 'quantity', 'sort_order', ]) ->map(fn ($r) => $r->getAttributes()) // ✅ 핵심 수정 ->all(); return $memo[$parentId] = $rows; } /** ref_type/ref_id 에 해당하는 노드의 "표시용 정보"를 로드 */ protected function resolveNodeInfo(string $refType, int $refId): array { if ($refType === 'PRODUCT') { $p = Product::query() ->where('tenant_id', $this->tenantId) ->find($refId, ['id', 'code', 'name', 'product_type', 'category_id']); if (! $p) { return [ 'id' => $refId, 'code' => null, 'name' => null, 'product_type' => null, 'category_id' => null, ]; } return [ 'id' => (int) $p->id, 'code' => $p->code, 'name' => $p->name, 'product_type' => $p->product_type, 'category_id' => $p->category_id, ]; } // ✅ MATERIAL 분기: materials 테이블 스키마 반영 if ($refType === 'MATERIAL') { $m = DB::table('materials') ->where('tenant_id', $this->tenantId) ->where('id', $refId) ->whereNull('deleted_at') // 소프트 삭제 고려 ->first([ 'id', 'material_code', // 코드 'item_name', // 표시명(있으면 우선) 'name', // fallback 표시명 'specification', // 규격 'unit', 'category_id', ]); if (! $m) { return [ 'id' => (int) $refId, 'code' => null, 'name' => null, 'unit' => null, 'category_id' => null, ]; } // item_name 우선, 없으면 name 사용 $displayName = $m->item_name ?: $m->name; return [ 'id' => (int) $m->id, 'code' => $m->material_code, // 표준 코드 필드 'name' => $displayName, // 사용자에게 보일 이름 'unit' => $m->unit, 'spec' => $m->specification, // 있으면 프론트에서 활용 가능 'category_id' => $m->category_id, ]; } // 알 수 없는 타입 폴백 return [ 'id' => $refId, 'code' => null, 'name' => null, ]; } /** * 단일 제품을 루트로 트리를 생성 (재귀 / 사이클 방지 / 깊이 제한) * * @param int $productId 루트 제품 ID * @param int|null $maxDepth 최대 깊이(루트=0). null 이면 config default * @return array 트리 구조 */ public function resolveTree(int $productId, ?int $maxDepth = null): array { $maxDepth = $maxDepth ?? (int) config('products.default_tree_depth', 10); $root = Product::query() ->where('tenant_id', $this->tenantId) ->findOrFail($productId, ['id', 'code', 'name', 'product_type', 'category_id']); $visited = []; // 사이클 방지용 (product id 기준) $node = [ 'type' => 'PRODUCT', 'id' => $root->id, 'code' => $root->code, 'name' => $root->name, 'product_type' => $root->product_type, 'category_id' => $root->category_id, 'quantity' => 1, // 루트는 수량 1로 간주 'category' => null, // 루트는 임의 'children' => [], 'depth' => 0, ]; $node['children'] = $this->resolveChildren($root->id, 0, $maxDepth, $visited); return $node; } /** * 하위 노드(들) 재귀 확장 * * @param array $visited product-id 기준 사이클 방지 */ protected function resolveChildren(int $parentId, int $depth, int $maxDepth, array &$visited): array { // 깊이 제한 if ($depth >= $maxDepth) { return []; } $lines = $this->getLinesForParent($parentId); if (! $lines) { return []; } $productLike = $this->productLikeTypes(); $children = []; foreach ($lines as $line) { $refType = (string) $line['ref_type']; $refId = (int) $line['ref_id']; $qty = (float) $line['quantity']; if (! $refType || $refId <= 0) { // 로그 남기고 스킵 // logger()->warning('Invalid component line', ['line' => $line]); continue; } $info = $this->resolveNodeInfo($refType, $refId); $child = [ 'type' => $refType, 'id' => $info['id'] ?? $refId, 'code' => $info['code'] ?? null, 'name' => $info['name'] ?? null, 'product_type' => $info['product_type'] ?? null, 'category_id' => $info['category_id'] ?? null, 'quantity' => $qty, 'category' => [ 'id' => $line['category_id'], 'name' => $line['category_name'], ], 'sort_order' => (int) $line['sort_order'], 'children' => [], 'depth' => $depth + 1, ]; // 제품처럼 자식이 달릴 수 있는 타입이면 재귀 if (in_array($refType, $productLike, true)) { // 사이클 방지: 같은 product id 재방문 금지 $pid = (int) $child['id']; if ($pid > 0) { if (isset($visited[$pid])) { $child['cycle'] = true; // 표식만 남기고 children 안탐 } else { $visited[$pid] = true; $child['children'] = $this->resolveChildren($pid, $depth + 1, $maxDepth, $visited); unset($visited[$pid]); // 백트래킹 } } } $children[] = $child; } return $children; } }