import { ORDER_FOLD_DEFAULT, finishedFlowOrdersWithin, foldedOrderCount, type OrderFoldPolicy } from '@operato/ops-contract' /* * 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능). * publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다. * * ── 규율: 레퍼런스가 아는 것은 **전부** 내보낸다 ──────────────────────────── * 트윈에서 사소한 속성은 없다. 소속 구역·소속 팔레트·수량·만료처럼 곁가지로 보이는 값이 실제로는 * 구역 롤업·적재 표현·로트 추적의 전제다. 그래서 여기서 **필드를 손으로 골라 담지 않는다.** * * 예전에는 엔티티마다 필요할 것 같은 필드만 열거했고, 그 결과 계약에 있는 것이 오류 없이 빠졌다 * (2026-08-01 감사: 노드 parentId 누락 · 작업 전량 누락 · 물품 전량 누락). 화이트리스트 방식은 * **계약이 자라도 화면이 따라오지 못한다** — 커널이 속성을 늘릴 때마다 이 파일을 고쳐야 하고, 고치는 * 것을 잊으면 아무 신호 없이 정보가 사라진다. * * 그래서 반대로 한다: **상태 객체가 가진 것을 그대로 통과**시키고, 통과시키지 **않을** 것만 이유와 함께 * 열거한다(EXCLUDED). 새 속성은 자동으로 화면에 도달하고, 빼려면 이유를 적어야 한다. * * ── 표식은 도메인 낱말을 훔치지 않는다 ───────────────────────────────────── * 예전 payload 는 엔티티 종류 표식을 `kind` 로 실었다. 그런데 `kind` 는 **도메인의 낱말**이다 — * 무버 kind=cutter, 작업 kind=putaway, 오더 kind=workorder. 표식이 그 이름을 차지하면 도메인 속성을 * 실을 자리가 없어진다(같은 병을 오늘 ox-timeline 의 `group` 에서도 고쳤다). * 그래서 표식은 `entity`(우리 이름)로 옮기고 `kind` 는 도메인에 돌려준다. * * payload 계약(씬 컴포넌트가 소비): `entity`(표식) + 그 엔티티의 모든 속성(도메인 `kind` 포함). * entity = location · equipment · person · asset · order · task · item, * 그리고 집약 태그(__orders__ · __tasks__ · __items__ · __persons__ · __assets__). */ export interface EntityDelta { tag: string data: any } /** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */ /* * 집약 채널의 접두사 — **실제 태그는 트윈 식별자를 붙여 만든다.** * * 예전에는 이 상수가 곧 태그였다. 그런데 발행 키는 (도메인, 태그)이고, **한 공간에 트윈이 여럿** * 있으면(실측: 3곳 × 3개) 셋이 같은 태그로 발행해 서로 덮었다. 보드의 주문 목록판이 세 공장의 주문 * 중 마지막에 도착한 것을 보여 준다 — 오류 없이. * * 구간 성과 태그가 이미 `__kpi__:<트윈>` 으로 트윈을 붙이고 있었다(`kpi-broadcast.ts`). 같은 규약으로 맞춘다. */ export const ORDERS_TAG_PREFIX = '__orders__' export const TASKS_TAG_PREFIX = '__tasks__' export const ITEMS_TAG_PREFIX = '__items__' export const PERSONS_TAG_PREFIX = '__persons__' export const ASSETS_TAG_PREFIX = '__assets__' /** 에너지 집약 — 수요 구간·피크·계약은 **트윈 하나의 사실**이라 개체 태그가 없다. */ export const ENERGY_TAG_PREFIX = '__energy__' /** 이 트윈의 집약 채널 태그. 트윈 식별자가 없으면 만들지 않는다 — 덮어쓰기의 원인이었다. */ export function aggregateTag(prefix: string, instanceId: string): string { if (!instanceId) throw new Error('aggregateTag: instanceId required — tags without instanceId overwrite other twin states across tenants') return `${prefix}:${instanceId}` } export const ordersTag = (instanceId: string) => aggregateTag(ORDERS_TAG_PREFIX, instanceId) export const tasksTag = (instanceId: string) => aggregateTag(TASKS_TAG_PREFIX, instanceId) export const itemsTag = (instanceId: string) => aggregateTag(ITEMS_TAG_PREFIX, instanceId) export const personsTag = (instanceId: string) => aggregateTag(PERSONS_TAG_PREFIX, instanceId) export const assetsTag = (instanceId: string) => aggregateTag(ASSETS_TAG_PREFIX, instanceId) export const energyTag = (instanceId: string) => aggregateTag(ENERGY_TAG_PREFIX, instanceId) /** * 통과시키지 않는 필드 — **빼는 데는 이유가 필요하다.** * * · `id`/`epc` — 태그로 이미 나가므로 개별 payload 에 중복해 싣지 않는다(집약 뷰에는 넣는다). * · `oee` — 원시 누적(idleMs 등)이 매 delta 증가해 "무변화 생략" 을 무력화한다. shapeOee 로 정형화해 얹는다. */ const EXCLUDED = new Set(['id', 'epc', 'oee']) /** * OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트. * 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초 재브로드캐스팅 억제). * oee 미측정이면 undefined. */ function shapeOee(o: any): any { if (!o) return undefined return { overall: Math.round((o.overall ?? 0) * 100), availability: Math.round((o.availability ?? 0) * 100), performance: Math.round((o.performance ?? 0) * 100), quality: Math.round((o.quality ?? 0) * 100), good: o.goodCount ?? 0, scrap: o.nonconformingCount ?? 0 } } /** * 엔티티 하나의 payload — `entity` 표식 + 나머지 전부(도메인 `kind` 포함). * * `undefined` 값은 싣지 않는다(키의 유무가 시그니처를 흔들지 않게). 값이 없는 것과 키가 없는 것은 * 소비처에서 같으므로 안전하고, 무변화 생략이 잘 듣는다. */ function payloadOf(entityKind: string, entity: any): any { /* 표식은 `entity` — 도메인의 `kind` 를 덮지 않는다. */ const data: any = { entity: entityKind } for (const [k, v] of Object.entries(entity ?? {})) { if (EXCLUDED.has(k) || v === undefined) continue data[k] = v } return data } /** * 집약 뷰의 항목 — 개별 payload 와 같은 내용 + 식별자. * 집약은 "전체를 한 컴포넌트가 추적" 하는 용도라 식별자가 있어야 한다. */ function aggregateItem(entity: any, idKey: 'id' | 'epc'): any { const out: any = { [idKey]: entity?.[idKey] } for (const [k, v] of Object.entries(entity ?? {})) { if (k === idKey || k === 'oee' || v === undefined) continue out[k] = v } const shaped = shapeOee(entity?.oee) if (shaped) out.oee = shaped return out } /** * 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 브로드캐스팅에 실으면 전달이 현장을 느리게 한다. * * **알리지 않고 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가 * "이게 전부" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup * 이 걸려 **변한 것만** 재브로드캐스팅된다. */ export const ITEMS_AGGREGATE_LIMIT = 500 /** * 자리별 에너지 — **그 자리의 계량기들이 잰 것**을 그 자리 태그에 실을 수 있게 모은다. * * ── 왜 자리에도 필요한가 (2026-08-14) ─────────────────────────────────────── * 카탈로그는 자리에도 `metered` 를 준다(수전·분기·구역 계량은 재는 자리다). 그런데 값은 계량기 * **설비** 태그로만 나갔다. 그래서 계통도의 분기 자리에 계량 컴포넌트를 놓으면 **영원히 「—」** 였다 — * 능력은 「잰다」고 말하는데 값이 오지 않는 자리. * * 「이 구간이 얼마를 먹나」는 자리의 사실이고, 사람은 계통도의 자리에 붙여 본다. * * ── 계층은 여기서 문제가 되지 않는다 ──────────────────────────────────────── * 각 자리에는 **그 자리에 붙은 계량기만** 더한다(수전 자리에는 수전 계량기, 분기 자리에는 그 분기의 * 것). 커널이 부하를 뿌리로만 세는 것과 다른 층의 계산이다 — 여기서는 자리마다 자기 것을 말한다. * * 근거를 함께 낸다(`meters`) — 그 자리의 수가 어느 계량기에서 왔는지 되짚을 수 있어야 한다. */ function energyByLocation(state: any): Map> { const out = new Map>() const points = state?.energy?.points if (!Array.isArray(points) || !points.length) return out /* 계량기 → 자리. 자리를 모르는 계량기는 자리 payload 에 실을 데가 없다(설비 태그로만 나간다). */ const locOf = new Map() for (const e of state?.equipment ?? []) if (e?.id && e?.homeLocation) locOf.set(e.id, e.homeLocation) for (const pt of points) { const loc = pt?.id ? locOf.get(pt.id) : undefined if (!loc) continue const acc = out.get(loc) ?? { meters: [] as string[], samplesInWindow: 0 } ;(acc.meters as string[]).push(pt.id) acc.samplesInWindow = Number(acc.samplesInWindow ?? 0) + (Number(pt.samplesInWindow) || 0) /* 값은 **있는 것만** 더한다 — 하나도 없으면 키를 만들지 않는다(0 은 「안 썼다」가 된다). */ if (Number.isFinite(Number(pt.kW))) acc.kW = Number(acc.kW ?? 0) + Number(pt.kW) if (Number.isFinite(Number(pt.kWh))) acc.kWh = Number(acc.kWh ?? 0) + Number(pt.kWh) /* 나이는 **가장 최근**을 쓴다 — 자리에 계량기가 여럿이면 그중 마지막 관측이 그 자리의 나이다. */ if (pt.atMs !== undefined) { const iso = new Date(Number(pt.atMs)).toISOString() if (!acc.measuredAt || String(acc.measuredAt) < iso) acc.measuredAt = iso } out.set(loc, acc) } return out } /** * 물품 축만 **범위를 줄일 수 있다** (2026-08-21). * * ── 왜 이 인자가 생겼나 ───────────────────────────────────────────────────── * 브로드캐스팅 한 번이 만드는 payload 는 엔티티 수만큼이고 그 대부분이 물품이다(실측: 엔티티 3,611 중 * 물품 2,400 · 만드는 데 1.8ms · 문자열로 바꾸는 데 1.0ms). 바뀐 것이 물품 하나여도 전부 만들어 * 「같다」를 확인하고 버렸다. * * `items` 를 주면 **그 태그의 물품만** 개별 payload 를 만든다. 주지 않으면(`undefined`) 전부 만든다 — * 부르는 쪽이 무엇이 바뀌었는지 말할 수 없을 때의 답이다. * * **집약 뷰는 범위를 받지 않는다.** 집약은 목록 전체가 한 payload 라서, 한 물품이 바뀌면 그 payload 는 * 통째로 새로 만들어야 한다(상한 500으로 이미 묶여 있다). 자리·설비·인원·자산·작업·오더도 범위를 받지 * 않는다 — 수가 적고, 잘못 걸러서 잃는 것이 크다(자리 점유는 물품이 떠난 자리와 도착한 자리가 함께 * 바뀌는데 사건은 한 지점만 말한다). */ export interface EntityDeltaScope { /** 개별 payload 를 만들 물품 태그. 없으면 전부. */ items?: ReadonlySet } export function buildEntityDeltas( state: any, instanceId: string, scope?: EntityDeltaScope, /** 끝난 오더를 접는 경계(모델의 `orderFold`) — 「최근 T 완료」의 T 다. 없으면 커널의 기본. */ orderFold?: OrderFoldPolicy ): EntityDelta[] { const out: EntityDelta[] = [] const locEnergy = energyByLocation(state) for (const n of state?.locations ?? []) { /* 에너지는 자리 payload 에 **합친다** — 같은 태그로 두 번 보내면 뒤엣것이 앞을 덮는다. */ const energy = locEnergy.get(n.id) out.push({ tag: n.id, data: energy ? { ...payloadOf('location', n), ...energy } : payloadOf('location', n) }) } for (const m of state?.equipment ?? []) { /* Mobile(location/motion) + Operable(status/held) + Processable(oee) — 한 payload 로. * oee 만 정형화가 필요해 따로 얹는다(나머지는 통과). */ const data = payloadOf('equipment', m) const oee = shapeOee(m?.oee) if (oee) data.oee = oee out.push({ tag: m.id, data }) } /* 사람 — 설비와 별개 엔티티다. 같은 통과 규칙(가진 것을 전부 내보낸다). */ for (const p of state?.persons ?? []) { out.push({ tag: p.id, data: payloadOf('person', p) }) } /* 물리 자산(반복사용) — 물품도 설비도 아닌 네 번째 자원. */ for (const a of state?.assets ?? []) { out.push({ tag: a.id, data: payloadOf('asset', a) }) } for (const o of state?.orders ?? []) { out.push({ tag: o.id, data: payloadOf('order', o) }) } for (const t of state?.tasks ?? []) { out.push({ tag: t.id, data: payloadOf('task', t) }) } for (const it of state?.items ?? []) { /* 물품의 태그는 epc(개체 식별자) — 자리·설비와 달리 id 가 아니다. */ if (!it?.epc) continue /* 범위가 주어졌으면 그 안의 물품만 만든다 — 나머지는 값이 그대로이므로 보낼 것이 없다. */ if (scope?.items && !scope.items.has(it.epc)) continue out.push({ tag: it.epc, data: payloadOf('item', it) }) } /* ── 집약 뷰 ────────────────────────────────────────────────────────────── * 런타임에 생기고 사라지는 엔티티(오더·작업·물품)는 보드에 개별 배치할 수 없다 → 한 컴포넌트가 * 전체를 추적하는 집약 태그를 항상 방출한다. */ const orders = state?.orders ?? [] /* * **「최근 T 안에 끝난」 수와 접힌 수를 함께 싣는다**(ADR-0092 결정 2 보탬 · 3). 칩은 뜨거운 줄로 세면 건수 경계(N)에서 * 멈추므로, 접힌 쪽의 시간 칸까지 더한 수를 계약의 한 함수(`finishedFlowOrdersWithin`, KPI 와 같은 판정)로 낸다. 「지금」은 * 그 구동의 지금(`nowTime` — 시뮬은 시계, 미러는 들은 시각의 최대)이다. 모르면 싣지 않는다 — 0 으로 메우지 않는다. */ const nowMs = Date.parse(String(state?.nowTime ?? '')) const policy = orderFold ?? ORDER_FOLD_DEFAULT const finished = Number.isFinite(nowMs) ? finishedFlowOrdersWithin(orders, state?.foldedOrders, nowMs, policy) : undefined const folded = foldedOrderCount(state?.foldedOrders) out.push({ tag: ordersTag(instanceId), data: { entity: 'orders', orders: orders.map((o: any) => aggregateItem(o, 'id')), ...(finished ? { finished: { ...finished, windowMs: policy.afterMs } } : {}), ...(folded ? { folded } : {}) } }) const tasks = state?.tasks ?? [] out.push({ tag: tasksTag(instanceId), data: { entity: 'tasks', tasks: tasks.map((t: any) => aggregateItem(t, 'id')) } }) /* 사람 집약 — 인원은 보드에 개별 배치할 수도 있지만(설비처럼), 전체를 한눈에 보는 소비처도 있다. * 인원을 선언하지 않은 트윈에서는 빈 배열이 나간다(없음과 모름을 구별). */ const persons = state?.persons ?? [] out.push({ tag: personsTag(instanceId), data: { entity: 'persons', persons: persons.map((p: any) => aggregateItem(p, 'id')) } }) const assets = state?.assets ?? [] out.push({ tag: assetsTag(instanceId), data: { entity: 'assets', assets: assets.map((a: any) => aggregateItem(a, 'id')) } }) const items = state?.items ?? [] const shown = items.slice(0, ITEMS_AGGREGATE_LIMIT) out.push({ tag: itemsTag(instanceId), data: { entity: 'items', items: shown.map((it: any) => aggregateItem(it, 'epc')), total: items.length, /* 잘렸으면 그 사실을 말한다 — "이게 전부" 로 읽히면 재고를 과소 판단한다. */ ...(items.length > shown.length ? { truncated: items.length - shown.length } : {}) } }) /* * ── 에너지 (EMS) ───────────────────────────────────────────────────────── * * 계측은 `state.energy` 에 산다 — 자원 축(`equipment`·`locations`)이 아니다. 그래서 이 매핑이 없으면 * 보드에 놓은 계량기가 **아무 값도 받지 못한다**(컴포넌트는 있는데 영원히 빈 모습). * * 두 갈래로 낸다: * · **개체별** — 계량 지점 하나하나의 마지막 관측(kW·kWh·역률·시각). 태그는 그 설비 id 다. * · **집약** — 수요 구간·피크·계약전력. 이것은 트윈 하나의 사실이라 개체에 실을 수 없다. * * 값이 없으면 **키를 넣지 않는다**(0 으로 만들지 않는다) — 「못 읽었다」와 「0 이었다」는 다른 사실이고, * 그 구별이 이 트윈의 값어치다. */ const energy = state?.energy if (energy) { for (const pt of energy.points ?? []) { if (!pt?.id) continue out.push({ tag: pt.id, data: { entity: 'meter', ...(pt.kW !== undefined ? { kW: pt.kW } : {}), ...(pt.kWh !== undefined ? { kWh: pt.kWh } : {}), ...(pt.powerFactor !== undefined ? { powerFactor: pt.powerFactor } : {}), /* 마지막 표본 시각 — 계측이 끊긴 것을 화면이 스스로 알 수 있게(우리가 판정하지 않는다). */ ...(pt.atMs !== undefined ? { measuredAt: new Date(pt.atMs).toISOString() } : {}), samplesInWindow: pt.samplesInWindow ?? 0 } }) } out.push({ tag: energyTag(instanceId), data: { entity: 'energy', ...(energy.contractKW !== undefined ? { contractKW: energy.contractKW } : {}), ...(energy.open ? { open: energy.open } : {}), ...(energy.peakSince ? { peakSince: energy.peakSince } : {}), /* 마감된 구간은 **최근 것만** 낸다 — 하루치를 매 브로드캐스팅에 실으면 채널이 그것으로 찬다. 전체 수는 함께 낸다(알리지 않고 자르지 않는다). */ recent: (energy.closed ?? []).slice(-8), closedTotal: energy.closedTotal ?? 0 } }) } return out }