import { Arg, Ctx, Int, Query, Resolver } from 'type-graphql' import { ScalarObject, getRepository } from '@things-factory/shell' import { itemRefOf, resolveByDeclaration } from './item-ref.js' import { chooseRecipe } from './choose-recipe.js' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinEvent } from '../twin-event/twin-event.js' /* * 계보 — **이 개체는 무엇에서 나왔나**, 그리고 **설계대로 만들어졌나.** * * ── 구성과 계보는 주어가 다르다 ──────────────────────────────────────────────── * 구성(BOM)의 주어는 품목 **정의**다 — "트레일러는 프레임 1개로 만든다"(선언). * 계보의 주어는 품목 **개체**다 — "이 트레일러는 저 프레임 로트에서 나왔다"(관측). * 둘은 같은 모양이라 겹쳐 볼 수 있고, **그 차이가 곧 진단**이다: "설계는 1개인데 이건 2개를 먹었다". * 트윈이 아니면 못 하는 답이고, 이 질의가 그 답의 재료다. * * ── 어디서 읽나 ──────────────────────────────────────────────────────────────── * 계보는 상태가 아니라 **저널**에 있다(EPCIS `TransformationEvent` 의 입력·산출 목록). 상태는 * 지금 무엇이 있는지만 알고, 없어진 원자재가 무엇이었는지는 이벤트만 안다. * * ── 규모 ─────────────────────────────────────────────────────────────────────── * 입력 목록은 **컬럼이 아니라 봉투 안**에 있어서 인덱스로 좁힐 수 없다. 그래서 변환 이벤트만 * 창으로 끊어 읽고, **끊었으면 끊었다고 말한다.** 그 사실을 감추면 "이 개체는 원자재가 없다" 로 * 읽히고, 계보에서 그 오해는 리콜 범위를 좁게 잡는 결과로 이어진다. */ @Resolver() export class TwinLineageQuery { @Query(returns => ScalarObject, { nullable: true, description: 'Where one item came from (observed) laid next to what its type declares (BOM). Walks TransformationEvent input/output lists in the journal; reports the window it read and whether it was truncated. The declared|observed difference is the diagnosis: "the recipe says one frame, this one consumed two".' }) async twinItemLineage( @Arg('instanceId') instanceId: string, @Arg('epc') epc: string, @Ctx() context: ResolverContext, @Arg('maxDepth', type => Int, { nullable: true }) maxDepth?: number, @Arg('window', type => Int, { nullable: true }) window?: number ): Promise { const domainId = context.state.domain.id const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!inst) return null const depthCap = Math.min(Math.max(maxDepth ?? 6, 1), 12) const windowCap = Math.min(Math.max(window ?? 5000, 1), 50000) /* * 변환 이벤트만 읽는다. **최신부터** 읽는다 — 계보를 묻는 개체는 대개 최근에 만들어진 것이고, * 창을 넘겨 잘릴 때 잃는 쪽이 오래된 것이어야 덜 아프다. */ /* * 관계를 **컬럼으로** 묻는다 (2026-08-28). `find({ where: { domain: { id } } })` 은 관계를 가진 * 엔티티에서 두 단계 질의가 되고 **안쪽에 상한이 없다** — `take` 가 있어도 조건에 맞는 행을 전부 * 만든 뒤 밖에서 자른다. 변환 사건이 적은 트윈에서는 표가 안 나지만, 변환을 많이 내는 트윈에서는 * 이 한 번이 그 종류 전체를 만든다. 질의 빌더로 물으면 상한이 안쪽에 걸린다. * * 종류가 정확일치이므로 `(domain, instance, event_type, event_time)` 색인이 시각 순서까지 준다. */ const rows: any[] = await getRepository(TwinEvent) .createQueryBuilder('e') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .andWhere('e.eventType = :eventType', { eventType: 'epcis.TransformationEvent' }) .orderBy('e.eventTime', 'DESC') .limit(windowCap) .getMany() const truncated = rows.length >= windowCap /* 산출 → 그 변환. 한 개체는 한 번만 만들어지지만, 데이터가 그렇지 않을 수 있으므로 목록으로 든다. */ const madeBy = new Map() for (const r of rows) { const d = r.payload?.data ?? {} const outs: string[] = d.outputEPCList ?? [] const ins: string[] = d.inputEPCList ?? [] const step = { inputs: ins, at: r.eventTime, tx: d.bizTransactionList?.[0]?.bizTransaction } for (const o of outs) { const list = madeBy.get(o) ?? [] list.push(step) madeBy.set(o, list) } } /* 거슬러 올라간다 — 너비 우선, 지나온 것은 다시 파지 않는다(사이클·공유 부품). */ const items: any[] = [{ epc, itemRef: itemRefOf(epc), depth: 0 }] const edges: any[] = [] const seen = new Set([epc]) let depthTruncated = false let frontier = [{ epc, depth: 0 }] while (frontier.length) { const next: typeof frontier = [] for (const cur of frontier) { if (cur.depth >= depthCap) { depthTruncated = true; continue } for (const step of madeBy.get(cur.epc) ?? []) { for (const input of step.inputs) { edges.push({ from: cur.epc, to: input, at: step.at, tx: step.tx, depth: cur.depth + 1 }) if (seen.has(input)) continue seen.add(input) items.push({ epc: input, itemRef: itemRefOf(input), depth: cur.depth + 1 }) next.push({ epc: input, depth: cur.depth + 1 }) } } } frontier = next } /* * **겹쳐 본다.** 이 개체의 품목이 어느 레시피의 산출이면, 그 레시피가 선언한 투입과 실제로 * 소비된 것을 자재별로 나란히 센다. * * 개체(EPC)와 자재 키를 잇는 것은 `productionSpec.binding`(자재 키 → GS1 품목 참조)이다. * 바인딩이 없으면 **비교하지 않는다** — 짐작으로 이으면 "설계와 다르다" 는 거짓 진단이 나오고, * 그건 아무 말도 안 하는 것보다 나쁘다. */ const spec: any = (inst.model as any)?.productionSpec const binding: Record = spec?.binding ?? {} const refToMaterial = new Map(Object.entries(binding).map(([mat, ref]) => [String(ref), mat])) /* * ── **화면은 끝마디로 묻는다** (2026-08-23) ───────────────────────────────── * * 서랍의 포커스 식별자는 클래스의 끝마디다(`net/class/product-M0000438`). 대응 표의 값은 전체 * 식별자(`https://…/class/product-M0000438`)이므로 곧바로는 맞지 않고, 그래서 「선언된 자재가 * 아닙니다」가 떴다 — 그 품목은 대응 표에 있었다. * * 끝마디로도 찾되 **하나뿐일 때만** 인정한다. 둘 이상이 같은 끝마디를 가지면 어느 것인지 모르므로 * 아무 말도 하지 않는다 — 짐작으로 이으면 남의 레시피와 견주게 되고, 그것이 「설계와 다르다」는 * 거짓 진단이 된다. */ const materialOf = (e: string) => resolveByDeclaration(itemRefOf(e), refToMaterial) ?? null const myMaterial = materialOf(epc) const recipes: any[] = spec?.definition?.recipes ?? [] /* * ── 「무엇으로 만들었나」는 **적혀 있다** (2026-09-05) ─────────────────────── * * 예전에는 이 자리가 자재로 되짚어 `recipes.find(...)` 로 **먼저 걸리는 하나**를 골랐다. 배열 * 순서가 답을 정한다는 뜻이고, 한 자재에 레시피가 둘이면 틀린 답이 확신에 찬 얼굴로 나온다. * 실제로 그런 자재가 있다 — `BOLT-M8` 에 `RCP-BOLT-STD` · `RCP-BOLT-FAST` 둘이다. * * 그런데 답은 이미 기록되어 있다. 만들 때 정해지는 사실이고 커널이 오더에 싣는다 * (`FlowOrder.recipeKey`). 변환 사건은 그 오더를 거래 식별자로 가리키고(`bizTransactionList`), * 이 함수는 그 값을 이미 `tx` 로 꺼내 놓고도 쓰지 않았다. * * 세 자리가 다 있었고 마지막 연결만 없었다. 「가릴 수 없다」가 아니라 **안 읽고 있었다.** * * ── 규칙 셋 — 짐작하지 않는다 ─────────────────────────────────────────────── * 오더가 말한다 그것을 쓴다. 버전이 둘이든 열이든 상관없다 * 오더가 말 안 하고 * 후보가 하나 그것을 쓴다 — 고를 것이 없으면 짐작이 아니다 * 후보가 둘 이상 **답하지 않는다.** 어느 것인지 모르는 채로 견주면 거짓 진단이 된다 * * 마지막 줄이 이 함수가 식별자 되찾기에 이미 적용하던 규율이다(「후보가 둘 이상이면 답하지 * 않는다」). 두 줄 위에서만 안 지키고 있었다. */ const candidates = myMaterial ? recipes.filter((r: any) => (r.outputs ?? []).some((o: any) => o.material === myMaterial)) : [] /* 이 개체를 만든 변환이 가리키는 거래 — 그 오더가 레시피를 말한다. */ const madeTx = (madeBy.get(epc) ?? []).map(s => s.tx).find(Boolean) const saidRecipeKey = madeTx ? await recipeKeyOfTransaction(domainId, instanceId, String(madeTx)) : undefined const chosen = chooseRecipe(recipes, candidates, saidRecipeKey) const recipe = chosen.recipe /* * ── 「모르는 개체」와 「대응이 없는 개체」는 다른 사실이다 (2026-08-23) ────── * * 화면이 「이 개체는 선언된 자재가 아닙니다(라인 내부의 중간 산물일 수 있습니다)」라고 말했는데, * 그 품목은 원본의 품목 선언에 **이름까지 있었다**(「냉동그린빈스」). 바인딩 표가 그 품목을 담지 * 못한 것이고, 그것은 「중간 산물」과 전혀 다른 이야기다 — 사용자가 할 일도 다르다(원본을 볼 일이 * 아니라 대응을 고칠 일이다). * * 그래서 개체가 가리키는 품목이 **모델의 품목 선언에 있는지** 함께 본다. 이름은 그 자리에 있으니 * 함께 낸다(화면이 사람에게 무엇을 말하는지 알 수 있게). */ const definitions: any[] = (inst.model as any)?.materialDefinitions ?? [] const defByRef = new Map(definitions.filter(d => d?.id).map(d => [String(d.id), d])) /* 정의도 같은 문으로 되찾는다 — 규칙 하나, 후보가 둘 이상이면 답하지 않는다. */ const myRef = itemRefOf(epc) const myDef = resolveByDeclaration(myRef, defByRef) let conformance: any = null if (!Object.keys(binding).length) { conformance = { comparable: false, reason: 'no-binding' } } else if (!myMaterial && myDef) { /* 원본은 이 품목을 안다 — 개체를 자재에 잇는 대응이 없어서 비교하지 못한다. */ conformance = { comparable: false, reason: 'item-known-not-bound', ...(typeof myDef.name === 'string' && myDef.name ? { itemName: myDef.name } : {}) } } else if (!myMaterial) { conformance = { comparable: false, reason: 'item-not-bound' } } else if (chosen.basis === 'said-but-undeclared') { /* 오더는 말했는데 그 레시피가 모델의 선언에 없다 — 원본과 선언이 어긋난 것이지 모르는 것이 아니다. */ conformance = { comparable: false, reason: 'recipe-not-declared', recipe: saidRecipeKey } } else if (chosen.basis === 'ambiguous') { /* 짐작하지 않는다 — 어느 것으로 만들었는지 말해 주는 사실이 없다. 후보를 밝혀 사람이 판단하게 한다. */ conformance = { comparable: false, reason: 'many-recipes-for-material', recipes: candidates.map((r: any) => r.key).sort() } } else if (!recipe) { conformance = { comparable: false, reason: 'no-recipe-for-material' } } else if (!(madeBy.get(epc) ?? []).length) { /* * ── **안 본 것을 「다르다」고 하지 않는다** (2026-09-18 · :3000 화면에서 확인) ───────── * * 이 개체를 만든 변환이 저널에 하나도 없으면 투입을 하나도 관측하지 못한 것이다. 그런데 * 아래 셈은 그대로 돌아 관측을 전부 0 으로 적었고, 레시피가 요구한 자재 23 줄이 전부 * 「안 맞는다」가 됐다 — 화면은 「레시피와 다릅니다」를 확신에 찬 얼굴로 그렸다. 실제로는 * 원본이 변환을 아직 안 보냈을 뿐이다(`mes-miratek` 의 카메라 로트가 그 경우다). * * 이 파일의 규율이 이미 그것을 말한다 — 짐작으로 이으면 「설계와 다르다」는 거짓 진단이 * 되고, 그건 아무 말도 안 하는 것보다 나쁘다. 관측이 **없는** 것도 같은 부류다. */ conformance = { comparable: false, reason: 'made-not-observed', recipe: recipe.key } } else { /* * 관측: **바인딩된 자재까지 내려가 센다** — 중간 산물(WIP)은 통과한다. * * 다단 라인에서 완제품의 직접 투입은 원자재가 아니라 **WIP** 다: 원자재는 첫 공정에서 * 소비되고 완제품은 마지막 공정에서 나온다. 그래서 직접 투입만 세면 레시피가 선언한 자재와 * **영원히 안 맞는다**(검사가 이것을 잡았다). * * WIP 은 라우트의 내부 산물이고 선언된 자재가 아니다 — 레시피는 자재로 말하므로 비교도 * 자재로 해야 한다. 바인딩에 있으면 거기서 멈추고, 없으면 그 위로 계속 따라간다. * * 다만 **다른 완제품까지 넘어가지 않는다**: 바인딩된 것을 만나면 멈추므로, 하위조립도 * 자재로 선언돼 있으면 그 자리에서 세어진다(그 아래는 그 자재 자신의 레시피가 볼 일이다). */ const observed = new Map() let unbound = 0 const parentsOf = (e: string) => edges.filter(x => x.from === e).map(x => x.to) const walked = new Set([epc]) let layer = parentsOf(epc) while (layer.length) { const deeper: string[] = [] for (const e of layer) { if (walked.has(e)) continue walked.add(e) const mat = materialOf(e) if (mat) { observed.set(mat, (observed.get(mat) ?? 0) + 1); continue } const up = parentsOf(e) /* 바인딩에도 없고 더 올라갈 곳도 없다 — 정말 모르는 투입이다(수만 낸다). */ if (!up.length) { unbound++; continue } deeper.push(...up) } layer = deeper } const declared = new Map((recipe.inputs ?? []).map((i: any) => [i.material, i.qty ?? null])) const keys = [...new Set([...declared.keys(), ...observed.keys()])] conformance = { comparable: true, material: myMaterial, recipe: recipe.key, /* 그 레시피를 **어떻게 알았나** — 화면이 「기록으로 안다」와 「하나뿐이라 안다」를 구별할 수 있게. */ recipeBasis: chosen.basis, /* 자재마다 선언·관측을 나란히. 어느 한쪽에만 있는 것도 사실이다(빠뜨리지 않는다). */ lines: keys.map(m => ({ material: m, declared: declared.has(m) ? declared.get(m) : null, observed: observed.get(m) ?? 0, matches: declared.get(m) === (observed.get(m) ?? 0) })), /* 바인딩에 없는 투입 — 설계에 없는 것이 들어갔을 수도, 바인딩이 덜 선언됐을 수도 있다. 어느 쪽인지 우리는 모르므로 **수만 낸다.** */ unboundInputs: unbound } } return { instanceId, epc, itemRef: itemRefOf(epc), material: myMaterial, items, edges, conformance, /* 무엇을 근거로 답했나 — 창을 넘겨 끊겼으면 계보가 실제보다 얕게 보인다. */ basis: { source: 'journal', transformationsRead: rows.length, truncated, depthTruncated, depthCap, windowCap } } } } /** * 그 거래를 가리키는 오더가 **어느 레시피로** 만들었다고 말하는가. * * ── 왜 거래 식별자로 묻나 ─────────────────────────────────────────────────── * EPCIS 변환 사건에는 레시피가 없다 — 표준에 그 자리가 없고, 대신 생산 오더를 거래로 가리킨다 * (`bizTransactionList`). 오더 사건은 `recipeKey` 와 그 거래 식별자를 **둘 다** 든다. 그래서 이 * 두 칸이 EPCIS 에서 오더로 건너가는 다리다(§`twin-event-keys.ts`). * * ── 색인을 탄다 ───────────────────────────────────────────────────────────── * `ix_twin_event_9 (domain, instanceId, bizTransaction)` 로 좁힌다. 이 칸이 없던 때는 payload 를 * 문자열로 훑어야 했고 32만 행에 3분이 걸렸다 — 화면이 기다릴 비용이 아니다. * * ── 못 찾으면 **답하지 않는다** ────────────────────────────────────────────── * 옛 행에는 이 칸이 비어 있다(과거 행은 채우지 않는다는 규율). 그때는 `undefined` 를 내고, 부르는 * 쪽이 후보 수로 판단한다 — 여기서 아무거나 고르면 예전과 같은 결함이 된다. */ async function recipeKeyOfTransaction( domainId: string, instanceId: string, bizTransaction: string ): Promise { const row = await getRepository(TwinEvent) .createQueryBuilder('e') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .andWhere('e.bizTransaction = :tx', { tx: bizTransaction }) .andWhere('e.eventType = :eventType', { eventType: 'order.status' }) /* 어느 줄이든 같은 오더를 말하므로 하나면 된다 — 가장 이른 것이 그 오더가 선 순간이다. */ .orderBy('e.eventTime', 'ASC') .limit(1) .getOne() const said = (row?.payload as any)?.data?.recipeKey return typeof said === 'string' && said ? said : undefined }