@things-factory/headless-twin 10.0.14 → 10.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist-server/engine/canonical-ingest.js +30 -0
  2. package/dist-server/engine/canonical-ingest.js.map +1 -1
  3. package/dist-server/engine/ingest-health.d.ts +35 -0
  4. package/dist-server/engine/ingest-health.js +45 -0
  5. package/dist-server/engine/ingest-health.js.map +1 -1
  6. package/dist-server/engine/twin-engine.d.ts +7 -0
  7. package/dist-server/engine/twin-engine.js +19 -0
  8. package/dist-server/engine/twin-engine.js.map +1 -1
  9. package/dist-server/service/reference/reference-adapter.d.ts +25 -0
  10. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  11. package/dist-server/service/reference/reference-live.js +12 -0
  12. package/dist-server/service/reference/reference-live.js.map +1 -1
  13. package/dist-server/service/twin-event/twin-event-keys.d.ts +21 -1
  14. package/dist-server/service/twin-event/twin-event-keys.js +35 -3
  15. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  16. package/dist-server/service/twin-model/epcis-coverage.js +5 -1
  17. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  18. package/dist-server/service/twin-model/isa95-coverage.d.ts +10 -0
  19. package/dist-server/service/twin-model/isa95-coverage.js +3 -3
  20. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  21. package/dist-server/service/twin-model/twin-model-query.js +71 -3
  22. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  23. package/package.json +3 -3
  24. package/server/engine/canonical-ingest.ts +30 -0
  25. package/server/engine/ingest-health.ts +65 -0
  26. package/server/engine/twin-engine.ts +20 -0
  27. package/server/service/reference/reference-adapter.ts +22 -0
  28. package/server/service/reference/reference-live.ts +12 -0
  29. package/server/service/twin-event/twin-event-keys.ts +35 -3
  30. package/server/service/twin-model/epcis-coverage.ts +5 -1
  31. package/server/service/twin-model/isa95-coverage.ts +13 -3
  32. package/server/service/twin-model/twin-model-query.ts +71 -3
  33. package/test/standard-coverage.test.ts +44 -0
  34. package/test/twin-event-keys.test.ts +48 -2
  35. package/test/withheld-door.test.ts +95 -0
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -31,6 +31,17 @@ const TWIN_INGEST_RULES: AdapterRule[] = [
31
31
  epc: '$.epc',
32
32
  /* 비직렬 수량 — 개체 없이 「이 자리에 이 품목이 얼마 남았다」만 오는 관측이 있다. */
33
33
  quantityList: '$.quantityList',
34
+ /*
35
+ * **개체·로트의 마스터데이터**(로트 번호·소비기한 등) — 커널 0.7.58 에서 이 자리가 열렸다.
36
+ *
37
+ * 실측(포천): 원본 재고 1,704행 **전부**에 소비기한이 있는데 투영된 상태에는 **0건**이었다. 지난
38
+ * 재고 950건·59,785kg 이 어디에도 나타나지 않았고, 빈 화면이 「이상 없음」으로 읽혔다.
39
+ *
40
+ * ★ **표준이 `action: 'ADD'` 일 때만 허용한다**(또는 변환). 마스터데이터는 로트가 **생길 때**
41
+ * 말하는 사실이고, 주기 관측마다 실으면 커널이 거부한다 — 조용히 통과시키지 않는 것이 옳다
42
+ * (커넥터가 첫 목격에 `ADD`, 그 뒤로 `OBSERVE` 를 내야 한다).
43
+ */
44
+ ilmd: '$.ilmd',
34
45
  readPoint: '$.readPoint',
35
46
  bizLocation: '$.bizLocation'
36
47
  }
@@ -190,6 +201,25 @@ export function ingestCanonicalRecords(
190
201
  ? ingest(
191
202
  epcis.map(r => ({
192
203
  ...r,
204
+ /*
205
+ * ── **시각의 이름을 둘 흡수한다** (2026-08-24 실측) ────────────────────
206
+ * `eventTimePath` 는 **한 이름**만 받는다. 호스트가 `'eventTime'` 으로 못박아 두었는데, 뒤에
207
+ * 생긴 커넥터는 `at` 을 싣는다(에너지 채널이 이미 `at` 을 쓰고 있어 그 어휘를 따랐다).
208
+ * 그래서 그 커넥터의 사건은 **전부 폴링 순간**으로 찍혔다 — 저널 18,150건이 그랬다.
209
+ *
210
+ * 그 결과 둘이 함께 망가졌다: 재고 관측이 트윈의 시계를 밀지 못해 시계가 `task.status` 의
211
+ * 최댓값(2026-04-15)에 **멈춰 있었고**, 이동 사건에서 「언제」가 사라져 순서만 남았다.
212
+ *
213
+ * 이름을 하나로 강요하지 않고 흡수한다 — `orderId`·`resourceRef`·`equipmentId` 를 함께 보는
214
+ * 것과 같은 규율이다(§`twin-event-keys`). 세대가 섞이는 것은 이 이음새의 성질이다.
215
+ *
216
+ * **없으면 만들지 않는다**: 절대값 스냅샷 관측은 원본의 갱신 시각이 아니라 **폴링 순간**이
217
+ * 맞는 관측 시각이다(「어제 값을 오늘 봤다」는 「어제 일어났다」가 아니다). 값을 말하는
218
+ * 레코드만 자기 시각을 갖는다.
219
+ */
220
+ ...(((r as any).eventTime ?? (r as any).at) !== undefined
221
+ ? { eventTime: (r as any).eventTime ?? (r as any).at }
222
+ : {}),
193
223
  /* 판정은 커널이 한 곳에서 한다 — 여기서 다시 짐작하면 소비처마다 답이 달라진다. */
194
224
  sourceType: isTransformationRecord(r)
195
225
  ? 'twin-transformation'
@@ -165,6 +165,27 @@ export interface IngestLedger {
165
165
  * 모양은 위와 같게 둔다 — 「몇 번」과 「언제부터」가 여기서도 같은 값을 한다(한 번의 밀림과 사흘째
166
166
  * 못 넘어가는 것은 다르다). 넘어가면 지운다.
167
167
  */
168
+ /**
169
+ * **원본이 말했는데 트윈에 세우지 않은 것** — 이유별로 센다.
170
+ *
171
+ * ── 왜 이 자리가 필요한가 (2026-08-24) ─────────────────────────────────────
172
+ * 커넥터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**: 입자가 맞지 않거나, 받으면 상태가
173
+ * 거짓이 되거나, 커널 어휘로 옮길 수 없다. 그때 커넥터는 경고를 낸다 — **그런데 그 경고가 프로비저닝
174
+ * 화면에서 한 번 스쳐 지나갈 뿐이었다.** 그 뒤로는 어디에서도 볼 수 없었다.
175
+ *
176
+ * 그래서 사용자가 「원본보다 계획이 적다」를 물으면 **답이 어디에도 없다.** 그 사실은 한 번의
177
+ * 사건이 아니라 **상태의 성질**이다 — 세우지 않은 것은 지금도 트윈에 없다.
178
+ *
179
+ * ── `unhandled` 의 형제다 ──────────────────────────────────────────────────
180
+ * `unhandled` 리듀서가 **받았는데** 담을 자리를 몰랐다
181
+ * 여기 커넥터가 **보지만 일부러 보내지 않았다** — 이유와 함께
182
+ *
183
+ * 둘 다 「사실이 조용히 사라지지 않게」 세는 것이고, 이유가 다르므로 자리도 다르다.
184
+ *
185
+ * 이유를 **문자열로 받는다**: 무엇을 왜 안 받는지는 원본마다 다르고, 계약이 그 목록을 닫으면 새 원본을
186
+ * 붙일 때마다 계약을 고치게 된다(흐름 열쇠와 같은 규율).
187
+ */
188
+ withheld?: { reason: string; count: number; lastAtMs: number }[]
168
189
  cursorStall?: {
169
190
  /** 연속 정체 수 — 넘어가면 0 으로 돌아간다. */
170
191
  consecutive: number
@@ -229,6 +250,27 @@ export function recordCursorStall(ledger: IngestLedger, reason: string, nowMs: n
229
250
  }
230
251
  }
231
252
 
253
+ /**
254
+ * **세우지 않은 것을 적는다** — 이유별 누적.
255
+ *
256
+ * 주기마다 다시 알려도 시끄러워지지 않게 **이유로 묶어 센다**(줄이 늘지 않는다). 그리고 마지막 시각을
257
+ * 함께 든다 — 「지금도 그런가」와 「한때 그랬나」는 다른 사실이고, 시각 없이는 구별할 수 없다.
258
+ *
259
+ * 이유가 같고 수가 달라지면 **마지막 수로 덮는다**: 이것은 누적 사건이 아니라 **지금 세우지 않은 것의
260
+ * 수**다. 주기마다 8건이면 8이고, 더해서 800이 되면 거짓이다.
261
+ */
262
+ export function recordWithheld(ledger: IngestLedger, reason: string, count: number, nowMs: number): void {
263
+ if (!reason || !(count > 0)) return
264
+ const list = (ledger.withheld ??= [])
265
+ const at = list.find(w => w.reason === reason)
266
+ if (at) {
267
+ at.count = count
268
+ at.lastAtMs = nowMs
269
+ return
270
+ }
271
+ list.push({ reason, count, lastAtMs: nowMs })
272
+ }
273
+
232
274
  /** 창을 넘겼으면 지운다 — 풀린 정체가 화면에 남아 있으면 그것도 거짓이다. */
233
275
  export function clearCursorStall(ledger: IngestLedger): void {
234
276
  if (ledger.cursorStall) delete ledger.cursorStall
@@ -722,6 +764,19 @@ export function ingestHealth(
722
764
  ...(ledger.cursorStall.stream ? { stream: ledger.cursorStall.stream } : {})
723
765
  }
724
766
  }
767
+ : {}),
768
+ /*
769
+ * **세우지 않은 것도 낸다 — 양쪽 갈래에서.** 유입이 한 건도 없는 장부에서도 이 사실은 실재한다
770
+ * (원본이 말하는데 전부 안 받는 경우가 있다). 여기서 떨어뜨리면 바로 그 상황에서 조용해진다.
771
+ */
772
+ ...(ledger?.withheld?.length
773
+ ? {
774
+ withheld: ledger.withheld.map(w => ({
775
+ reason: w.reason,
776
+ count: w.count,
777
+ lastAt: new Date(w.lastAtMs).toISOString()
778
+ }))
779
+ }
725
780
  : {})
726
781
  }
727
782
  }
@@ -734,6 +789,16 @@ export function ingestHealth(
734
789
  recent: recentRaw ? toWindowView(recentRaw, lastAnyMs) : null,
735
790
  trend: ledger.trend.map(w => toWindowView(w, lastAnyMs)),
736
791
  trendDropped: ledger.trendDropped,
792
+ /* 세우지 않은 것 — 양쪽 갈래에서 같은 모양으로 낸다(§`IngestLedger.withheld`). */
793
+ ...(ledger.withheld?.length
794
+ ? {
795
+ withheld: ledger.withheld.map(w => ({
796
+ reason: w.reason,
797
+ count: w.count,
798
+ lastAt: new Date(w.lastAtMs).toISOString()
799
+ }))
800
+ }
801
+ : {}),
737
802
  total: {
738
803
  offered: ledger.offered,
739
804
  accepted,
@@ -61,6 +61,7 @@ import {
61
61
  clearCursorStall,
62
62
  clearReadFailure,
63
63
  recordCursorStall,
64
+ recordWithheld,
64
65
  rollIngestWindow,
65
66
  /* 별칭 — 같은 이름의 정적 메서드와 헷갈리지 않게(그 메서드가 이것을 부른다). */
66
67
  recordJournalWrite as recordLedgerWrite,
@@ -3919,6 +3920,25 @@ export class TwinEngine {
3919
3920
  recordCursorStall(ledger, reason, nowMs, stream)
3920
3921
  }
3921
3922
 
3923
+ /**
3924
+ * **원본에 있는데 세우지 않은 것을 적는다** — 이유와 수(§`IngestLedger.withheld`).
3925
+ *
3926
+ * 어댑터가 주기마다 불러도 된다: 장부가 이유로 묶어 **마지막 수로 덮는다**(누적하지 않는다).
3927
+ * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 조용히 그치면 낡은 수가 남는다.
3928
+ */
3929
+ static recordIngestWithheld(domainId: string, instanceId: string, reason: string, count: number, nowMs = Date.now()): void {
3930
+ const key = runtimeKey(domainId, instanceId)
3931
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3932
+ if (!(count > 0)) {
3933
+ if (ledger.withheld) {
3934
+ ledger.withheld = ledger.withheld.filter(w => w.reason !== reason)
3935
+ if (!ledger.withheld.length) delete ledger.withheld
3936
+ }
3937
+ return
3938
+ }
3939
+ recordWithheld(ledger, reason, count, nowMs)
3940
+ }
3941
+
3922
3942
  /** 창을 넘겼다 — 정체 기록을 지운다(풀린 정체가 화면에 남아 있으면 그것도 거짓이다). */
3923
3943
  static clearIngestCursorStall(domainId: string, instanceId: string): void {
3924
3944
  const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)]
@@ -177,6 +177,28 @@ export interface LiveFeedContinuity {
177
177
  * **닿지 못한 것과 섞어 부르지 않는다.** 하나의 주기가 두 이유로 실패할 수는 없다(먼저 닿아야 읽는다).
178
178
  */
179
179
  onCursorStall?: (info: { reason: string; stream?: string }) => void
180
+ /**
181
+ * **원본에 있는데 세우지 않은 것을 알린다** — 이유와 수.
182
+ *
183
+ * ── 왜 이 통로가 필요한가 (2026-08-24) ─────────────────────────────────────
184
+ * 어댑터가 원본의 사실 일부를 **일부러 받지 않는 일이 정상이다**(입자가 안 맞는다 · 받으면 상태가
185
+ * 거짓이 된다 · 커널 어휘로 옮길 수 없다). 그때 어댑터는 경고를 냈는데, 그 경고가 **프로비저닝
186
+ * 화면에서 한 번 스쳐 지나갈 뿐**이었다 — 그 뒤로는 어디에서도 볼 수 없었다.
187
+ *
188
+ * 그래서 사용자가 「원본보다 이것이 적다」를 물으면 답이 어디에도 없었다. 그 사실은 한 번의 사건이
189
+ * 아니라 **상태의 성질**이다: 세우지 않은 것은 **지금도** 트윈에 없다.
190
+ *
191
+ * **주기마다 불러도 된다** — 장부가 이유로 묶어 세고 마지막 수로 덮는다(누적하지 않는다). 그래서
192
+ * 「지금 세우지 않은 것이 8건」이 8로 남고 800으로 부풀지 않는다.
193
+ *
194
+ * **풀리면 부르지 않는 것으로 끝나지 않는다** — 이유가 사라졌으면 `count: 0` 으로 부르는 것이 아니라
195
+ * 그 이유를 더 이상 보내지 않으면 된다… 가 **아니다.** 장부는 마지막 값을 들고 있으므로, 풀린 것을
196
+ * 알리려면 그 이유로 `count: 0` 을 한 번 보내라(그때 줄이 사라진다). 조용히 그치면 낡은 수가 남는다.
197
+ *
198
+ * 이유는 **자유 문자열**이다 — 무엇을 왜 안 받는지는 원본마다 다르고, 계약이 목록을 닫으면 새 원본을
199
+ * 붙일 때마다 계약을 고치게 된다(흐름 열쇠와 같은 규율). 사람이 읽을 문장으로 적어라.
200
+ */
201
+ onWithheld?: (info: { reason: string; count: number }) => void
180
202
  }
181
203
 
182
204
  export interface ReferenceAdapter {
@@ -174,6 +174,18 @@ async function loadLiveCursor(
174
174
  * `status` 를 건드리지 않는 규율은 위와 같다(재부착의 자격이다). `lastError` 에는 적는다 — 목록에서
175
175
  * 이유를 볼 수 있어야 하고, 「닿지 못한다」와 다른 문장이어야 한다.
176
176
  */
177
+ /*
178
+ * **세우지 않은 것을 장부에 적는다** — 원본에 있는데 트윈에 세우지 않은 것(이유와 수).
179
+ *
180
+ * 로그로도 남기지만 **로그는 사람이 볼 때만 값이 있다.** 이 사실은 상태의 성질이라(세우지 않은 것은
181
+ * 지금도 없다) 조회되는 자리에 있어야 한다 — 그러지 않으면 프로비저닝 화면에서 한 번 스쳐 지나간다.
182
+ *
183
+ * `status`·`lastError` 를 건드리지 않는다: 이것은 **실패가 아니다.** 어댑터가 옳은 판단으로 안 받은
184
+ * 것이고, 오류로 적으면 화면이 원본을 의심하게 만든다.
185
+ */
186
+ onWithheld: ({ reason, count }) => {
187
+ TwinEngine.recordIngestWithheld(domainId, instanceId, reason, count)
188
+ },
177
189
  onCursorStall: ({ reason, stream }) => {
178
190
  TwinEngine.recordIngestCursorStall(domainId, instanceId, reason, Date.now(), stream)
179
191
  twinWarn(
@@ -65,10 +65,30 @@ export function epcOf(envelope: any): string | undefined {
65
65
  return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined
66
66
  }
67
67
 
68
- /** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
68
+ /**
69
+ * 오더 식별자 — 운영 델타의 `orderId` 우선, EPCIS 는 `bizTransactionList`.
70
+ *
71
+ * ── 실측 (2026-08-24) — 이 컬럼이 **전부 비어 있었다** ──────────────────────
72
+ * 여기가 찾던 이름이 `d.order` 였다. 그런데 커널이 내는 이름은 **`orderId`** 다
73
+ * (`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). `d.order` 를 내는 코드는 커널에 **한 곳도
74
+ * 없다** — 죽은 가지였다. 그래서 운영 델타는 이 컬럼을 한 번도 채우지 못했다.
75
+ *
76
+ * order.status 29,403,565 행 — order_id 비어 있음 29,403,565 (100%)
77
+ * task.status 191,175 행 — 비어 있음 191,175 (100%)
78
+ *
79
+ * 그 결과 색인 `ix_twin_event_4` 가 **자기 주석이 적어 둔 용도**(「이 오더가 어디까지 갔나」)로 쓸 수
80
+ * 없었다. 화면은 오더를 눌러도 「연결된 이벤트가 없습니다」를 냈고, 그 답은 질의 결과로는 정직했다 —
81
+ * 시점을 어디로 옮겨도 0 이었다.
82
+ *
83
+ * 순서가 중요하다: 운영 델타가 먼저다. EPCIS 의 `bizTransactionList` 는 **거래**(PO/SO)이고 오더와
84
+ * 같은 것이 아닐 수 있으므로, 오더를 스스로 말하는 사건은 그 말을 그대로 쓴다.
85
+ *
86
+ * **과거 행은 채워지지 않는다**(사용자 결정 2026-08-24, `moverId` 때와 같은 방식). 앞으로 들어오는
87
+ * 사건부터 조회된다.
88
+ */
69
89
  export function orderOf(envelope: any): string | undefined {
70
90
  const d = envelope?.data ?? envelope ?? {}
71
- return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? undefined
91
+ return d.orderId ?? d.bizTransactionList?.[0]?.bizTransaction ?? undefined
72
92
  }
73
93
 
74
94
  /**
@@ -89,7 +109,19 @@ export function locationOf(envelope: any): string | undefined {
89
109
  */
90
110
  export function equipmentIdOf(envelope: any): string | undefined {
91
111
  const d = envelope?.data ?? envelope ?? {}
92
- return d.moverId ?? undefined
112
+ /*
113
+ * ── 실측 (2026-08-24) — 두 갈래를 놓치고 있었다 ────────────────────────────
114
+ * 위 주석이 `task.status` 를 출처로 **적어 두었는데** 이 함수는 `d.moverId` 만 봤다. 작업이 자원을
115
+ * 가리키는 이름은 `resourceRef` 다(`TaskStatusDelta.resourceRef`) — 그래서 작업 191,175 행 전부
116
+ * 이 컬럼이 비었고, 「이 지게차가 오늘 무엇을 했나」에서 **작업이 통째로 빠졌다.**
117
+ *
118
+ * 그리고 에너지 사건은 `equipmentId` 를 쓴다(어휘가 `movers`→`equipment` 로 개명된 뒤에 생긴
119
+ * 채널이다). 실측 `energy.equipment` 31,079 행 전부 비어 있었다 — 「이 설비가 얼마를 먹었나」를
120
+ * 설비 축으로 물을 수 없었다.
121
+ *
122
+ * 셋을 함께 본다. 개명 세대가 섞여 있는 것은 저널의 성질이고, 읽는 쪽이 그것을 흡수한다.
123
+ */
124
+ return d.moverId ?? d.resourceRef ?? d.equipmentId ?? undefined
93
125
  }
94
126
 
95
127
  /**
@@ -143,11 +143,15 @@ const FIELDS: Isa95Concept[] = [
143
143
  note: 'twin.epcis.note.ilmd'
144
144
  },
145
145
  {
146
+ /*
147
+ * 2026-08-24 — **`shownOn` 을 걷었다.** 같은 줄의 `note` 가 「화면에 보이는 곳은 아직 없습니다」라고
148
+ * 말하는데 배지는 「물품에서 표시됩니다」라고 말하고 있었다 — 한 줄이 서로 반대를 말했다.
149
+ * `surface: 'none'` 이면 갈 곳을 적지 않는다(§`shownOn` 규칙).
150
+ */
146
151
  std: 'errorDeclaration',
147
152
  part: 'fields',
148
153
  label: 'twin.epcis.errorDeclaration',
149
154
  axis: null,
150
- shownOn: ['items'],
151
155
  structure: 'full',
152
156
  behavior: 'partial',
153
157
  surface: 'none',
@@ -54,6 +54,16 @@ export interface Isa95Concept {
54
54
  * 라고만 말하면 **표가 스스로 모순된 말을 한다**(다 됐다면서 없다고 한다).
55
55
  *
56
56
  * 그래서 축일 수 없는 것은 갈 곳을 가리킨다. 여기가 비어 있고 축도 없으면 그것은 **진짜 빈칸**이다.
57
+ *
58
+ * ── ★ `surface` 와 짝을 맞춘다 (2026-08-24 실측으로 붙임) ─────────────────
59
+ * 이 칸은 「그 사실이 어느 축에 **담겨 있나**」가 아니라 「**어디서 보이나**」다. 화면이 그것을
60
+ * 「해당 축 항목에 표시됩니다」로 읽어 내므로, 둘이 어긋나면 표가 거짓을 말한다.
61
+ *
62
+ * `surface: 'none'` 인데 여기가 차 있다 → **거짓**. 없는 화면으로 사람을 보낸다
63
+ * `surface` 가 있는데 여기가 비어 있다 → **과소**. 「진짜 빈칸」으로 읽힌다(위 규칙)
64
+ *
65
+ * 같은 날 셋을 그렇게 틀리게 넣었다: 새 개념 둘(`TestResult`·`OperationsEvent`)에 화면이 없는데
66
+ * 갈 곳을 적었고, `MaterialSublot` 은 물품 이름으로 보이는데 갈 곳을 비워 두었다.
57
67
  */
58
68
  shownOn?: string[]
59
69
  }
@@ -134,7 +144,7 @@ const PART2: Isa95Concept[] = [
134
144
  * 이 부류(코드에 있는데 표가 `none`)는 자동으로 잡히지 않는다 — 표의 가드는 반대 방향만 본다
135
145
  * (축이 사라지면 구조를 내린다). 그래서 축을 늘릴 때 이 표를 함께 보는 것이 규율이다.
136
146
  */
137
- { std: 'MaterialSublot', part: '2', label: 'twin.isa95.MaterialSublot', axis: null, structure: 'partial', behavior: 'full', surface: 'partial' },
147
+ { std: 'MaterialSublot', part: '2', label: 'twin.isa95.MaterialSublot', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'full', surface: 'partial' },
138
148
  { std: 'ProcessSegment', part: '2', label: 'twin.isa95.ProcessSegment', axis: 'operations', structure: 'full', behavior: 'full', surface: 'full' },
139
149
  /*
140
150
  * 속성은 자원마다 붙는 확장이라 **자기 축이 아니다** — 그래서 `axis` 는 없지만 구조는 완전하다:
@@ -167,7 +177,7 @@ const PART2: Isa95Concept[] = [
167
177
  * `observation-out-of-limit` 신호를 세운다.
168
178
  * 화면이 `none`: 클라이언트에 이 축을 그리는 곳이 없다(실측 0곳).
169
179
  */
170
- { std: 'OperationsEvent', part: '2', label: 'twin.isa95.OperationsEvent', axis: null, shownOn: ['locations'], structure: 'partial', behavior: 'full', surface: 'none' }
180
+ { std: 'OperationsEvent', part: '2', label: 'twin.isa95.OperationsEvent', axis: null, structure: 'partial', behavior: 'full', surface: 'none' }
171
181
  ]
172
182
 
173
183
  /*
@@ -232,7 +242,7 @@ const PART4: Isa95Concept[] = [
232
242
  *
233
243
  * 화면은 `none` 이다 — 클라이언트에 이 축을 그리는 곳이 없다(실측: `operato-twin/client` 에 0곳).
234
244
  */
235
- { std: 'TestResult', part: '4', label: 'twin.isa95.TestResult', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'partial', surface: 'none' },
245
+ { std: 'TestResult', part: '4', label: 'twin.isa95.TestResult', axis: null, structure: 'partial', behavior: 'partial', surface: 'none' },
236
246
  { std: 'WorkMaster', part: '4', label: 'twin.isa95.WorkMaster', axis: 'recipes', structure: 'full', behavior: 'full', surface: 'full' },
237
247
  { std: 'WorkDirective', part: '4', label: 'twin.isa95.WorkDirective', axis: null, structure: 'none', behavior: 'none', surface: 'none' }
238
248
  ]
@@ -181,9 +181,27 @@ export class TwinModelQuery {
181
181
  const inst = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
182
182
  if (!inst) return null
183
183
 
184
- /* 어느 원본을 반영하나 — 없을 수 있다(템플릿으로 세운 트윈). 그 사실도 사실이다. */
184
+ /*
185
+ * 어느 원본을 반영하나 — 없을 수 있다(템플릿으로 세운 트윈). 그 사실도 사실이다.
186
+ *
187
+ * ── **레퍼런스 열쇠는 인스턴스 이름이 아니다** (2026-08-24 실측) ──────────────
188
+ * 여기서 `source: instanceId` 로 찾고 있었다. 레퍼런스 하나가 인스턴스 하나를 낳던 시절에는
189
+ * 두 값이 같았지만, **레퍼런스 하나가 여러 트윈을 먹이는 순간 어긋난다.**
190
+ *
191
+ * 인스턴스 operato-chef-v1-pocheon
192
+ * origin { kind: 'reference', source: 'seunghwa' } ← 이것이 레퍼런스 열쇠다
193
+ * 레퍼런스 seunghwa
194
+ *
195
+ * 그래서 요약의 `source` 가 **언제나 `null`** 이었다. 레퍼런스로 세운 트윈인데 화면이 그 원본을
196
+ * 못 찾았고, 「마지막 동기화」도 흐름별 신선도도 그릴 자리가 아예 없었다 — 그런데 오류는 없었다
197
+ * (`ref ? … : null` 이 조용히 갈렸다).
198
+ *
199
+ * 인스턴스가 자기 원본을 말하고 있으므로 **그 말을 먼저 듣는다.** 옛 트윈은 두 값이 같으므로
200
+ * 뒤의 것으로 여전히 찾힌다 — 가산적이다.
201
+ */
202
+ const refKey = (inst.origin as any)?.source || instanceId
185
203
  const ref = await getRepository(TwinReference)
186
- .findOne({ where: { domain: { id: domainId }, source: instanceId } })
204
+ .findOne({ where: { domain: { id: domainId }, source: refKey } })
187
205
  .catch(() => null)
188
206
 
189
207
  const model = inst.model ?? {}
@@ -523,11 +541,61 @@ export class TwinModelQuery {
523
541
  recipeLabel: material?.label ?? material?.name ?? null
524
542
  }
525
543
  })(),
544
+ /*
545
+ * ── **식별자가 무엇에 근거하나** (2026-08-24) ──────────────────────────────
546
+ * 요약이 `production.companyPrefix` 를 그대로 보이는데 **그것이 발급받은 번호인지 말할 근거가
547
+ * 없었다.** 그래서 사용자가 긴 URI 를 보고 「GS1 을 강제하는 것인가」를 물었다 — 강제는 **이미
548
+ * 없고**(GS1 프리픽스가 없는 현장은 자기 도메인 아래에서 식별자를 만든다, CBV 2.0 §8.3.4)
549
+ * 없다는 사실이 화면에 없었던 것이 문제였다.
550
+ *
551
+ * ★ **이 축만은 무표를 쓰지 않는다.** GS1 형태의 식별자를 본 사람은 그것이 진짜 발급받은
552
+ * 번호라고 가정하므로, 표식이 없으면 그 가정이 곧 거짓 안심이 된다. 계약도 그렇게 적고 있다 —
553
+ * 「없으면 소비처는 판정하지 않는다. 기본값으로 `issued` 를 가정하면 그 화면이 곧 거짓이 된다.」
554
+ * 그래서 화면이 네 갈래 모두에 낱말을 붙일 수 있게 **그대로** 낸다
555
+ * (`claimed-issued`·`declared-namespace`·`gs1-shaped`·`kernel-constant`).
556
+ *
557
+ * 커널이 내는 것을 그대로 넘긴다 — 여기서 다시 판정하면 스냅샷과 요약이 다른 말을 한다.
558
+ * 트윈이 돌지 않거나 커널이 말하지 않으면 `null` 이고, 그때 화면은 **재지 않는다.**
559
+ */
560
+ identityGrounding: (await stateOnce()).snap?.identityGrounding ?? null,
526
561
  description: inst.description ?? null,
527
562
  kind: inst.kind,
528
563
  restartPolicy: inst.restartPolicy,
529
564
  source: ref
530
- ? { referenceId: ref.source, adapterType: ref.adapterType, status: ref.status, lastSyncedAt: ref.lastSyncedAt }
565
+ ? {
566
+ referenceId: ref.source,
567
+ adapterType: ref.adapterType,
568
+ status: ref.status,
569
+ lastSyncedAt: ref.lastSyncedAt,
570
+ /*
571
+ * ── **원본의 어느 흐름이 어디까지 살아 있나** (2026-08-24) ──────────────
572
+ * 사용자가 「지금 데이터가 들어오는 것이 사실이냐」고 물었고, 화면이 답할 것이 「붙어 있다」
573
+ * 뿐이었다. 그런데 한 원본 안에서 흐름마다 신선도가 **크게 다르다**(실측: 재고 08-23 ·
574
+ * 계측 08-12 · 작업지시 05-10 · 공정·로트 04-15). 「붙어 있다」 하나로 뭉치면 넉 달 멈춘
575
+ * 흐름이 살아 있는 것으로 읽힌다.
576
+ *
577
+ * **계약에는 자리가 있다** — `LiveFeedCursor.since` 가 「그 흐름에서 마지막으로 본 시각
578
+ * (원본의 시각)」이고 `onCursor` 가 그것을 `TwinReference.liveCursor` 에 적는다. 없던 것은
579
+ * **화면으로 가는 문**이고, 그 문이 여기다.
580
+ *
581
+ * ★ **다만 지금은 비어 있다** — 실 저장값을 확인했다:
582
+ * `{"streams":{},"firstAttachedAt":"2026-03-25T…"}`. 즉 어댑터가 흐름별 커서를 적지 않는다.
583
+ * 그래서 이 문을 열어도 당장은 빈 목록이 나오고, **그것이 정직한 답이다**: 어댑터가 말하지
584
+ * 않는 것을 여기서 지어내지 않는다. 채우는 것은 어댑터의 몫이고 그쪽에 알렸다.
585
+ *
586
+ * 빈 목록과 「흐름이 없다」를 화면이 구별할 수 있어야 한다 — 이 값이 비면 그것은
587
+ * 「어댑터가 아직 말하지 않는다」이고 「원본에 흐름이 없다」가 아니다.
588
+ *
589
+ * `seen`(중복 걸러낸 행 id)은 내지 않는다 — 읽기 내부 상태이고 수천 개가 될 수 있다.
590
+ * 흐름 열쇠는 **어댑터가 정한다**(계약이 이름을 닫지 않는다). 그래서 이름을 번역하지 않고
591
+ * 그대로 낸다 — 화면이 모르는 이름을 만나면 그 이름을 보이는 것이 맞다.
592
+ */
593
+ streams: Object.entries((ref.liveCursor as any)?.streams ?? {})
594
+ .map(([stream, c]: [string, any]) => ({ stream, since: c?.since ?? null }))
595
+ /* 시각을 말하지 않는 흐름도 낸다 — 「모른다」와 「없다」는 다르고, 빼면 그 흐름이 사라진다. */
596
+ .sort((a, b) => String(b.since ?? '').localeCompare(String(a.since ?? ''))),
597
+ firstAttachedAt: (ref.liveCursor as any)?.firstAttachedAt ?? null
598
+ }
531
599
  : null,
532
600
  /*
533
601
  * **다시 읽을 수 있는가** — 이 트윈이 자기 원본을 기억하고 있나(`TwinInstance.origin`).
@@ -26,6 +26,7 @@ const req = createRequire(import.meta.url)
26
26
  const { standardsForKind } = req('../dist-server/service/twin-model/standard-coverage.js')
27
27
  const { iec61850Coverage } = req('../dist-server/service/twin-model/iec61850-coverage.js')
28
28
  const { epcisCoverage } = req('../dist-server/service/twin-model/epcis-coverage.js')
29
+ const { isa95Coverage } = req('../dist-server/service/twin-model/isa95-coverage.js')
29
30
  import { readFileSync } from 'node:fs'
30
31
  import { fileURLToPath } from 'node:url'
31
32
 
@@ -104,3 +105,46 @@ test('계측은 EPCIS 센서 필드로 오지 않는다 — 그 사실을 표가
104
105
  assert.equal(sensor.behavior, 'none')
105
106
  assert.ok(sensor.note, '에너지가 다른 층으로 들어온다는 사실을 근거로 적는다')
106
107
  })
108
+
109
+ test('★ 「어디서 보이나」가 화면 등급과 어긋나지 않는다 — 표가 없는 화면으로 사람을 보내지 않게', () => {
110
+ /*
111
+ * ── 무엇이 틀려 있었나 (2026-08-24 실측) ──────────────────────────────────
112
+ * `shownOn` 은 「그 사실이 어느 축에 **담겨 있나**」가 아니라 「**어디서 보이나**」다. 화면이 그것을
113
+ * 「해당 축 항목에 표시됩니다」로 읽어 내므로, `surface: 'none'` 인 줄에 갈 곳이 적혀 있으면 표가
114
+ * **거짓을 말한다** — 사람을 그 개념이 보이지 않는 화면으로 보낸다.
115
+ *
116
+ * 같은 날 넷이 그랬다. 셋은 새로 넣으면서(`TestResult`·`OperationsEvent`), 하나는 오래(`errorDeclaration`
117
+ * — 그 줄의 `note` 는 「화면에 보이는 곳은 아직 없습니다」라고 말하는데 배지는 반대를 말했다).
118
+ *
119
+ * 반대 방향도 본다: 화면에 보이는데 축도 갈 곳도 주석도 없으면 그 줄은 **「진짜 빈칸」으로 읽힌다**
120
+ * (`shownOn` 주석이 정한 규칙이다). `MaterialSublot` 이 그랬다 — 물품 이름으로 보이는데 갈 곳이 비어
121
+ * 있었다.
122
+ *
123
+ * 사람이 눈으로 보고 잡았다. 그래서 기계가 잡게 한다.
124
+ */
125
+ const AXES = [
126
+ 'items', 'locations', 'equipment', 'persons', 'assets', 'tasks', 'orders', 'operations', 'recipes',
127
+ 'testSpecifications', 'materialDefinitions', 'materialClasses', 'personnelClasses', 'equipmentClasses',
128
+ 'assetClasses', 'productionSpec', 'demandWindows', 'meters'
129
+ ]
130
+ const tables: [string, { concepts: any[] }][] = [
131
+ ['ISA-95', isa95Coverage(AXES)],
132
+ ['EPCIS', epcisCoverage(AXES)],
133
+ ['IEC 61850', iec61850Coverage(AXES)]
134
+ ]
135
+
136
+ const lies: string[] = []
137
+ const mute: string[] = []
138
+ for (const [name, table] of tables) {
139
+ for (const c of table.concepts) {
140
+ if (c.surface === 'none' && c.shownOn?.length) lies.push(`${name}/${c.std} → ${c.shownOn.join(',')}`)
141
+ if (c.surface !== 'none' && !c.axis && !c.shownOn?.length && !c.note) mute.push(`${name}/${c.std}`)
142
+ }
143
+ }
144
+ assert.deepEqual(lies, [], `화면이 없는데 갈 곳을 적은 줄 — 없는 화면으로 사람을 보낸다:\n${lies.join('\n')}`)
145
+ assert.deepEqual(mute, [], `화면에 보이는데 갈 곳도 주석도 없는 줄 — 「진짜 빈칸」으로 읽힌다:\n${mute.join('\n')}`)
146
+
147
+ /* 빈 비교로 통과하지 않게 — 표가 실제로 여러 줄을 들고 있다. */
148
+ const total = tables.reduce((n, [, t]) => n + t.concepts.length, 0)
149
+ assert.ok(total > 30, `표 셋에 줄이 실제로 있어야 한다 (실제 ${total})`)
150
+ })
@@ -62,8 +62,50 @@ test('집합 이벤트는 parentID 를, 수량 이벤트는 epcClass 를 품목
62
62
  assert.equal(epcOf({ data: { quantityList: [{ epcClass: 'urn:epc:idpat:sgtin:0614141.107346.*', quantity: 40 }] } }), 'urn:epc:idpat:sgtin:0614141.107346.*')
63
63
  })
64
64
 
65
- test('운영 델타의 오더는 bizTransactionList 가 아니라 order 필드에 있다', () => {
66
- assert.equal(orderOf({ eventType: 'order.status', data: { order: 'SO-10021', status: 'released' } }), 'SO-10021')
65
+ test('★ 운영 델타의 오더는 `orderId` 다 — 이 시험이 결함을 고정하고 있었다', () => {
66
+ /*
67
+ * ── 무엇이 잘못돼 있었나 (2026-08-24 실측) ────────────────────────────────
68
+ * 이 시험은 `data: { order: 'SO-10021' }` 를 단정했다. **커널은 그런 모양을 낸 적이 없다** —
69
+ * 오더는 `orderId` 다(`OrderStatusDelta.orderId` · `TaskStatusDelta.orderId`). 픽스처가 실제 델타
70
+ * 대신 **스스로 지어낸 모양**을 썼고, 추출기도 같은 이름을 찾고 있었으니 둘이 나란히 초록이었다.
71
+ *
72
+ * 그동안 실 저널에서:
73
+ * order.status 29,403,565 행 — order_id 100% 비어 있음
74
+ * task.status 191,175 행 — 100% 비어 있음
75
+ *
76
+ * 색인 `ix_twin_event_4`(「이 오더가 어디까지 갔나」)가 자기 용도로 못 쓰였고, 화면은 오더를 눌러도
77
+ * 「연결된 이벤트가 없습니다」를 냈다.
78
+ *
79
+ * **교훈**: 승격 키의 픽스처는 **커널이 실제로 내는 델타**여야 한다. 지어낸 모양으로 검사하면
80
+ * 추출기와 시험이 함께 틀린 채로 영원히 초록이다.
81
+ */
82
+ assert.equal(orderOf({ eventType: 'order.status', data: { orderId: 'WO-260410-0133', status: 'in-progress' } }), 'WO-260410-0133')
83
+ assert.equal(orderOf({ eventType: 'task.status', data: { taskId: 'TK-1', orderId: 'WO-1' } }), 'WO-1')
84
+ /* 오더를 스스로 말하면 그 말이 이긴다 — 거래(PO/SO)와 오더는 같은 것이 아닐 수 있다. */
85
+ assert.equal(
86
+ orderOf({ data: { orderId: 'WO-1', bizTransactionList: [{ bizTransaction: 'urn:epc:id:gdti:0614141.402.2' }] } }),
87
+ 'WO-1'
88
+ )
89
+ /* 오더를 말하지 않는 EPCIS 는 거래로 내려간다(전과 같다). */
90
+ assert.equal(orderOf({ data: { bizTransactionList: [{ bizTransaction: 'PO-9' }] } }), 'PO-9')
91
+ })
92
+
93
+ test('★ 승격 키는 **커널이 실제로 내는 델타 모양**으로 검사한다', () => {
94
+ /*
95
+ * 위 결함의 부류는 「추출기가 찾는 이름을 발신자가 쓰지 않는다」이고, 지어낸 픽스처로는 절대 잡히지
96
+ * 않는다. 그래서 실 저널에서 뜬 payload 모양을 그대로 놓고 단정한다 — 세 축이 실제로 채워지는지.
97
+ */
98
+ const orderDelta = { eventType: 'order.status', data: { orderId: 'order-1', kind: 'workorder', status: 'in-progress' } }
99
+ const taskDelta = {
100
+ eventType: 'task.status',
101
+ data: { taskId: 'task-1', orderId: 'order-1', kind: 'cut', status: 'in-progress', resourceRef: 'cutter-1', fromNode: 'st-a', toNode: 'st-b' }
102
+ }
103
+ const energyEq = { eventType: 'energy.equipment', data: { equipmentId: 'pv-roof', at: '2026-08-17T10:45:20.664Z', generatedKW: 426 } }
104
+
105
+ assert.equal(twinEventKeys(orderDelta).orderId, 'order-1', '오더 사건이 오더 축을 채운다')
106
+ assert.equal(twinEventKeys(taskDelta).orderId, 'order-1', '작업도 자기 오더를 채운다 — 오더의 이력에 작업이 실린다')
107
+ assert.equal(twinEventKeys(taskDelta).moverId, 'cutter-1', '작업이 쓴 자원이 설비 축에 실린다(`resourceRef`)')
108
+ assert.equal(twinEventKeys(energyEq).moverId, 'pv-roof', '에너지 사건의 설비가 설비 축에 실린다(`equipmentId`)')
67
109
  })
68
110
 
69
111
  test('없는 값은 빈 문자열이 아니라 undefined — 결측과 빈값을 섞지 않는다', () => {
@@ -81,6 +123,10 @@ test('설비·무버 축 — 운영 델타의 moverId 를 뽑는다', () => {
81
123
  assert.equal(equipmentIdOf(ev), 'fork-3')
82
124
  const k = twinEventKeys(ev)
83
125
  assert.equal(k.moverId, 'fork-3')
126
+ /* 개명 세대가 섞인다 — 읽는 쪽이 흡수한다(`moverId` 옛 채널 · `resourceRef` 작업 · `equipmentId` 에너지). */
127
+ assert.equal(equipmentIdOf({ data: { resourceRef: 'cutter-1' } }), 'cutter-1')
128
+ assert.equal(equipmentIdOf({ data: { equipmentId: 'pv-roof' } }), 'pv-roof')
129
+ assert.equal(equipmentIdOf({ data: { moverId: 'fork-1', resourceRef: 'x' } }), 'fork-1', '앞의 이름이 이긴다')
84
130
  /* 운영 델타는 readPoint/bizLocation 이 없다 — 평범한 location 을 위치로 받는다.
85
131
  * 빠뜨리면 "이 설비가 어디서 무엇을 했나" 가 위치 축에서 통째로 사라진다. */
86
132
  assert.equal(k.locationId, 'rack-1')