import { nameIndex, namesFor } from './name-index.js' import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql' import { ScalarObject, getRepository } from '@things-factory/shell' import { TwinInstance } from '../twin-instance/twin-instance.js' const { TWIN_RELATIONS, axisInfo } = require('@operato/ops-contract') /** * 재귀 전개 — **자기 축으로 돌아오는 선언을 따라간다.** * * ── 왜 자재가 자재를 품지 않는가 ─────────────────────────────────────────────── * 다단 BOM 을 "자재 안에 자재" 로 적으면 같은 부품이 여러 완제품에 들어갈 때마다 복제되고, * 수량이 어디 붙는지도 애매해진다. 표준(ISA-95)이 택한 모양은 **자재와 레시피가 번갈아 나오는 * 그래프**다: 완제품 ←산출— 레시피 —투입→ 부품들, 그리고 그 부품이 또 어느 레시피의 산출이면 * 거기서 다시 내려간다. 우리 선언(`ops.rel.input`·`ops.rel.output`)이 이미 그 두 홉이다. * * ── 패턴을 코드가 알지 않는다 ────────────────────────────────────────────────── * "BOM 은 산출→투입" 이라고 적어 두면 그 순간 이 파일이 도메인을 알게 된다(ADR-0031 위반). * 대신 **선언 그래프에서 자기 축으로 돌아오는 두 홉 순환을 찾는다.** 자재만이 아니라 앞으로 * 같은 모양을 갖는 어떤 축이든 그대로 펼쳐진다. * * ── 알리지 않고 고르지 않는다 ─────────────────────────────────────────────────────── * 순환이 여럿이면 **아무거나 집지 않는다.** 후보를 돌려주고 부르는 쪽이 고르게 한다 — 한쪽을 * 집으면 역전개(누가 나를 쓰나)를 전개(내가 무엇으로 되나)로 보여 주고도 아무도 모른다. */ /** 자기 축으로 돌아오는 두 홉 — `axis ←down(via)— through —up(via)→ axis`. */ interface WalkPattern { /** 중간에 거치는 축(레시피 등). */ through: string /** 나로 들어오는 홉 — "나를 이것의 산출로 삼는" 쪽. */ inVia: string /** 거기서 다시 나가는 홉 — "그것이 투입하는" 쪽. */ outVia: string inField: string outField: string } /** 선언 그래프에서 이 축으로 돌아오는 순환들을 찾는다. */ function patternsFor(axis: string): WalkPattern[] { const toMe = TWIN_RELATIONS.filter((r: any) => r.target?.kind === 'axis' && r.target.axis === axis) const out: WalkPattern[] = [] for (const a of toMe) for (const b of toMe) if (a !== b && a.from === b.from) out.push({ through: a.from, inVia: a.via, outVia: b.via, inField: a.field, outField: b.field }) return out } const at = (o: any, path: string) => path.split('.').reduce((a: any, k: string) => (a == null ? undefined : a[k]), o) const idOf = (o: any): string | null => (typeof o === 'string' ? o : o?.id ?? o?.key ?? o?.gtin ?? null) /** `inputs[].material` — 배열을 지나는 선언 경로. 값과 **그 값을 담은 객체**를 함께 돌려준다(수량이 거기 있다). */ function entriesAt(obj: any, field: string): { value: any; holder: any }[] { let cur: { value: any; holder: any }[] = [{ value: obj, holder: obj }] for (const seg of field.split('.')) { const key = seg.endsWith('[]') ? seg.slice(0, -2) : seg const next: { value: any; holder: any }[] = [] for (const c of cur) { const v = c.value?.[key] if (v == null) continue if (Array.isArray(v)) for (const x of v) next.push({ value: x, holder: x }) else next.push({ value: v, holder: c.value }) } cur = next } return cur } /** 이 홉이 실어 나르는 수량 — 선언에 있으면 쓰고, 없으면 **없다고 한다**(1 로 메우지 않는다). */ function qtyOf(holder: any): number | null { const q = holder?.qty ?? holder?.quantity return typeof q === 'number' ? q : null } @Resolver() export class TwinModelTreeQuery { @Query(returns => ScalarObject, { nullable: true, description: 'Recursively expand an item through a declared two-hop cycle (e.g. a multi-level BOM: product ←produced-by— recipe —consumes→ parts). The pattern is derived from the kernel relation declarations, never hardcoded. Reports the pattern used, any cycles found, and whether depth or item caps truncated the walk.' }) async twinModelTree( @Arg('instanceId') instanceId: string, @Arg('axis') axis: string, @Arg('itemId') itemId: string, @Ctx() context: ResolverContext, @Arg('inVia', { nullable: true }) inVia?: string, @Arg('outVia', { nullable: true }) outVia?: string, @Arg('maxDepth', type => Int, { nullable: true }) maxDepth?: number, @Arg('maxItems', type => Int, { nullable: true }) maxItems?: number ): Promise { const domainId = context.state.domain.id const info = axisInfo(axis) if (!info) throw new Error(`unknown concept axis: "${axis}" — the kernel catalog does not declare it`) const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return null const all = patternsFor(axis) if (!all.length) return { axis, itemId, patterns: [], items: [], edges: [], reason: 'no-recursive-relation' } const chosen = inVia && outVia ? all.find(p => p.inVia === inVia && p.outVia === outVia) : all.length === 1 ? all[0] : undefined if (!chosen) /* 여럿인데 안 고르면 **아무것도 펼치지 않는다.** 한쪽을 집으면 역전개를 전개로 보여 주고도 아무도 모른다 — 방향이 뒤집힌 BOM 은 틀린 답이 아니라 반대의 답이다. */ return { axis, itemId, patterns: all, items: [], edges: [], reason: 'ambiguous-pattern' } /* * ── ★ **선언에 없는 축은 펼칠 것이 없다** (2026-08-24 실측으로 터짐) ──────── * * 이 전개는 **트윈 모델의 선언**을 읽는다(`axisInfo(...).path` 로 모델 안을 찾아 들어간다). 그런데 * 커널 카탈로그의 축 중 셋은 **선언되는 것이 아니라 일어나는 것**이다 — `orders`·`tasks`·`items` * 는 사건이 만들고 끝나면 사라진다. 모델이 그 목록을 가질 수 없으므로 `path` 가 비어 있다. * * 그래서 그런 축을 지나려 하면 `at(model, undefined)` 에서 터졌다: * * Cannot read properties of undefined (reading 'split') * * ★ **터진 것은 요청한 축이 아니라 「거쳐 가는 축」이다.** `locations` 를 물으면 그 순환이 * `tasks` 를 거치는데(작업이 `from`·`at` 으로 자리를 두 번 가리킨다) 그 `tasks` 에 경로가 없다. * 그래서 요청한 축만 지켜서는 막히지 않는다 — 둘 다 본다. * * ── 터지지 않게 하는 것만으로는 모자라다 ────────────────────────────────── * 빈 트리를 돌려주면 화면이 **「이어진 것이 없다」**로 읽는다. 그것은 거짓이다 — 작업은 자리를 * 가리키고 있고, 다만 **이 질의가 그것을 아는 곳이 아니다**(그 관계는 상태와 저널이 안다). * 그래서 이유를 말한다. 왜 못 하는지 말하지 않는 빈 화면이 이 저장소가 반복해 고친 결함이다. */ if (!info.path || !axisInfo(chosen.through)?.path) return { axis, itemId, patterns: all, items: [], edges: [], reason: 'not-declared-in-model' } const model = inst.model ?? {} const throughInfo = axisInfo(chosen.through) const listOf = (i: any) => { const raw = i ? at(model, i.path) : undefined return Array.isArray(raw) ? raw : raw ? [raw] : [] } const throughAll = listOf(throughInfo) const mineAll = listOf(info) const mineIds = new Set(mineAll.map(idOf).filter(Boolean) as string[]) const depthCap = Math.min(Math.max(maxDepth ?? 6, 1), 12) const itemCap = Math.min(Math.max(maxItems ?? 200, 1), 2000) const items: any[] = [{ id: itemId, axis, depth: 0, qty: 1, known: mineIds.has(itemId) }] const edges: any[] = [] const seen = new Set([itemId]) const cycles: string[] = [] let depthTruncated = false let itemTruncated = false /* 너비 우선 — 깊이 상한에서 끊기면 **얕은 층은 온전히** 남는다(깊이 우선이면 한 가지만 깊게 파고 끊긴다). */ let frontier: { id: string; depth: number; qty: number | null }[] = [{ id: itemId, depth: 0, qty: 1 }] while (frontier.length) { const nextFrontier: typeof frontier = [] for (const cur of frontier) { if (cur.depth >= depthCap) { depthTruncated = true; continue } /* 나를 만들어 내는 중간 항목들(레시피)을 찾는다 — 선언된 홉을 따라. */ for (const t of throughAll) { const producesMe = entriesAt(t, chosen.inField).some(e => String(idOf(e.value) ?? e.value) === cur.id) if (!producesMe) continue for (const e of entriesAt(t, chosen.outField)) { const childId = String(idOf(e.value) ?? e.value) const step = qtyOf(e.holder) /* 수량을 모르면 **곱하지 않는다.** 1 로 메우면 "1개 필요" 라는 사실 주장이 되고, 윗단에서 곱해진 값이 전부 거짓이 된다. */ const qty = cur.qty != null && step != null ? cur.qty * step : null edges.push({ from: cur.id, to: childId, through: idOf(t), depth: cur.depth + 1, step, qty }) if (seen.has(childId)) { /* 이미 지난 항목으로 돌아왔다 — 사이클이거나 공유 부품이다. 어느 쪽이든 **다시 파지 않고 말한다.** 파고 들어가면 돌아 나오지 못한다. */ if (edges.some(x => x.to === cur.id && x.from === childId)) cycles.push(`${cur.id} ↔ ${childId}`) continue } if (items.length >= itemCap) { itemTruncated = true; continue } seen.add(childId) items.push({ id: childId, axis, depth: cur.depth + 1, qty, known: mineIds.has(childId) }) nextFrontier.push({ id: childId, depth: cur.depth + 1, qty }) } } } frontier = nextFrontier } return { axis, itemId, /* 어떤 순환을 따라 펼쳤는지 **밝힌다** — 방향이 뒤집힌 전개는 틀린 답이 아니라 반대의 답이라 화면만 보고는 구별되지 않는다. */ pattern: chosen, patterns: all, items, edges, /* 수량을 하나라도 모르면 아래 합계가 성립하지 않는다는 사실을 그대로 낸다. */ /* * **이름을 함께 낸다** (2026-08-22). 나무의 줄이 id 만 보이면 사용자는 그 노드가 무엇인지 알 수 * 없다 — 사용자의 말: 「각 노드의 식별자가 반복되어서 이해가 어렵다. 식별자와 이름이 보이면.」 * 목록·항목 화면이 쓰는 것과 **같은 표**를 쓴다(`name-index`) — 두 벌이면 한쪽만 고쳐진다. */ names: namesFor(items, nameIndex(inst.model ?? {})), qtyIncomplete: edges.some(e => e.step == null), cycles: [...new Set(cycles)], truncated: { depth: depthTruncated, items: itemTruncated, depthCap, itemCap }, basis: 'document' } } }