@operato/twin-kernel 0.7.62 → 0.7.64

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.
@@ -5,6 +5,31 @@ export interface CanonicalEnvelope<T = unknown> {
5
5
  eventTime: ISOTime;
6
6
  tenantId: string;
7
7
  correlationId?: string;
8
+ /**
9
+ * **사람이 그때 적어 둔 말** — 연결된 시스템에서 온 자유 문장.
10
+ *
11
+ * ── 왜 이 자리가 필요한가 (2026-08-25) ────────────────────────────────────
12
+ * 재고 조정만은 **원인이 트윈의 모델 밖에 있다.** 다른 이동은 원인을 트윈이 스스로 압니다 —
13
+ * 꺼내 간 것은 작업 지시가 시켰고, 적치는 입고가 시켰습니다. 그런데 조정은 **사람이 판단한 것**이고,
14
+ * 그 이유는 그 사람이 적은 문장에만 있습니다(「실재고로 조정(192kg→198kg)」).
15
+ *
16
+ * 그 문장이 없으면 트윈은 「6kg 이 늘었다」까지만 알고 **왜인지 영원히 답하지 못합니다.**
17
+ *
18
+ * ── 왜 봉투에 두고 상태에 두지 않나 ──────────────────────────────────────
19
+ * 그 문장은 **지난 사건에 대한 사실**이고 지금 수량에 대한 사실이 아닙니다. 상태에 두면 조정 열 번에
20
+ * 문장 열 개가 쌓여 시간에 비례해 자랍니다. 지난 기록이 답하는 물음입니다.
21
+ *
22
+ * 그리고 EPCIS 사건 안에 넣지 않습니다 — 표준에 그 칸이 없고, 이름을 지어 넣으면 그 사건이
23
+ * 표준을 벗어납니다. 봉투는 우리 것이므로 여기가 맞고, 지난 기록에는 봉투가 그대로 남습니다.
24
+ *
25
+ * ── 커널은 읽지 않습니다 ─────────────────────────────────────────────────
26
+ * 자유 문장이므로 어떤 판단에도 쓰지 않습니다. 나르기만 합니다. 비어 있으면 이 칸을 만들지
27
+ * 않습니다 — 빈 문장은 「적지 않았다」와 다르게 보입니다.
28
+ *
29
+ * 표준 근거: ISA-95 가 거의 모든 타입에 두는 `Description`(DescriptionType). 사람의 글을 담는
30
+ * 표준 자리이고, EPCIS 핵심에는 없어 확장으로 갑니다.
31
+ */
32
+ description?: string;
8
33
  data: T;
9
34
  }
10
35
  /**
@@ -366,6 +391,18 @@ export interface TestResultFact extends TestResult {
366
391
  /** 무엇을 시험했나 — 표준 `TestResult.TestableObjectID`. 물품이면 EPC. */
367
392
  testableObjectId: string;
368
393
  }
394
+ /**
395
+ * `OP_EVENT.complete` 의 내용 — 「이 목록이 전부다」.
396
+ *
397
+ * `axis` 를 받아 두는 이유: 지금은 물품만 이 방식이 필요하지만, 다른 목록도 같은 성질을 갖는다
398
+ * (연결된 시스템이 현재 목록을 통째로 말하는 것). 낱말을 닫으면 그때 계약을 또 고친다.
399
+ */
400
+ export interface AxisCompleteFact {
401
+ /** 어느 목록인가 — 지금은 `'items'` 만 다룬다. */
402
+ completeAxis: string;
403
+ /** 그 주기를 **시작한** 시각(ISO). 이보다 오래된 것은 이 주기에 오지 않은 것이다. */
404
+ since: ISOTime;
405
+ }
369
406
  /**
370
407
  * 이 시험 결과가 **이 시각에 유효한 합격인가.**
371
408
  *
@@ -2180,6 +2217,27 @@ export declare const OP_EVENT: {
2180
2217
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
2181
2218
  */
2182
2219
  readonly test: "test.result";
2220
+ /**
2221
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
2222
+ *
2223
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
2224
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
2225
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
2226
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
2227
+ *
2228
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
2229
+ *
2230
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
2231
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
2232
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
2233
+ * 사라진다.
2234
+ *
2235
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
2236
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
2237
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
2238
+ * 지워지지 않아야 한다.
2239
+ */
2240
+ readonly complete: "axis.complete";
2183
2241
  /**
2184
2242
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
2185
2243
  *
package/dist/contract.js CHANGED
@@ -944,6 +944,27 @@ export const OP_EVENT = {
944
944
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
945
945
  */
946
946
  test: 'test.result',
947
+ /**
948
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
949
+ *
950
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
951
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
952
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
953
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
954
+ *
955
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
956
+ *
957
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
958
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
959
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
960
+ * 사라진다.
961
+ *
962
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
963
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
964
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
965
+ * 지워지지 않아야 한다.
966
+ */
967
+ complete: 'axis.complete',
947
968
  /**
948
969
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
949
970
  *
@@ -225,11 +225,18 @@ export function ingest(records, rules, opts) {
225
225
  rejected.push({ record, errors });
226
226
  continue;
227
227
  }
228
+ /*
229
+ * 사람이 적어 둔 말은 **봉투에** 싣는다(§`CanonicalEnvelope.description`) — EPCIS 사건 안에 넣으면
230
+ * 그 사건이 표준을 벗어난다. 지난 기록에는 봉투가 그대로 남으므로 잃지 않는다.
231
+ * 비어 있으면 이 칸을 만들지 않는다 — 빈 문장은 「적지 않았다」와 다르게 보인다.
232
+ */
233
+ const note = typeof record['description'] === 'string' ? String(record['description']).trim() : '';
228
234
  accepted.push({
229
235
  eventId: `${opts.tenantId}-ingest-${++seq}`,
230
236
  eventType: `epcis.${ev.type}`,
231
237
  eventTime: ev.eventTime,
232
238
  tenantId: opts.tenantId,
239
+ ...(note ? { description: note } : {}),
233
240
  data: ev
234
241
  });
235
242
  }
@@ -18,6 +18,14 @@ interface ProjItem {
18
18
  expiry?: number;
19
19
  /** 이 로트의 시험 결과 — **명세당 최신 하나**(상태가 계측 주기로 자라지 않게). */
20
20
  testResults?: TestResult[];
21
+ /**
22
+ * **마지막으로 관측된 시각**(ms) — 「이 목록이 전부다」를 받았을 때 무엇을 지울지 가리는 값.
23
+ *
24
+ * 물품 안에 두는 이유: 순서 판정에 쓰는 시각(`lastAt`)은 **중간 저장본에 들어가지 않는다.**
25
+ * 그것에 기대면 재기동 직후 첫 신호가 재고를 전부 지운다(본 적이 없는 것으로 보이므로).
26
+ * 물품은 중간 저장본에 들어가므로 이 값도 함께 살아남는다.
27
+ */
28
+ seenAtMs?: number;
21
29
  }
22
30
  /**
23
31
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
@@ -435,6 +435,44 @@ export class ObservedReducer {
435
435
  item.testResults = [...kept, next];
436
436
  break;
437
437
  }
438
+ case OP_EVENT.complete: {
439
+ /*
440
+ * ── ★ **이 목록이 전부다** (2026-08-25) ────────────────────────────────
441
+ *
442
+ * 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다. 이 주기에 오지 않은 것은
443
+ * **그 시스템에 더 이상 없는 것**이므로 지운다.
444
+ *
445
+ * 이것이 없던 동안 트윈은 받은 것을 **더하기만 했다** — 목록에서 빠진 줄은 아무 말도 오지 않은
446
+ * 것이 되어 영원히 남았다(실측: 실제 1,704건인데 트윈이 10,038건).
447
+ *
448
+ * ── 아무것도 못 들은 주기에는 지우지 않는다 ────────────────────────────
449
+ * 그 주기에 관측이 **한 건도** 없었다면 그것은 「다 없어졌다」가 아니라 **우리가 못 들은 것**이다.
450
+ * 그때 지우면 살아 있는 재고가 사라진다 — 이 저장소가 여러 번 거절한 모양이다(결측 ≠ 없음).
451
+ *
452
+ * 보내는 쪽의 규율은 계약에 적었다: **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 보내면
453
+ * 살아 있는 재고를 지운다. 그 판단은 보내는 쪽만 할 수 있다.
454
+ *
455
+ * 지운 수를 `unhandled` 로 세지 않는다 — 그것은 반영하지 못한 사건을 세는 자리다. 여기서 지운
456
+ * 것은 **정상적으로 반영한 결과**다.
457
+ */
458
+ const d = e.data;
459
+ if (d?.completeAxis !== 'items')
460
+ break;
461
+ const since = Date.parse(String(d.since ?? ''));
462
+ if (!Number.isFinite(since))
463
+ break;
464
+ let heard = 0;
465
+ for (const it of this.items.values())
466
+ if ((it.seenAtMs ?? -1) >= since)
467
+ heard++;
468
+ if (!heard)
469
+ break;
470
+ for (const [key, it] of [...this.items.entries()]) {
471
+ if ((it.seenAtMs ?? -1) < since)
472
+ this.remove(key);
473
+ }
474
+ break;
475
+ }
438
476
  case OP_EVENT.attentionAck: {
439
477
  /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
440
478
  const d = e.data;
@@ -632,6 +670,45 @@ export class ObservedReducer {
632
670
  if (ev.action === 'DELETE') {
633
671
  for (const epc of ev.epcList)
634
672
  this.remove(epc);
673
+ /*
674
+ * ── ★ **수량으로 온 물품도 없어질 수 있어야 한다** (2026-08-25 실측) ────────
675
+ *
676
+ * 여기는 `epcList` 만 봤다. 그래서 **낱개 번호가 없는 현장에서는 재고를 지울 방법이 하나도
677
+ * 없었다** — 승화푸드는 모든 재고가 수량으로 오는 현장이다.
678
+ *
679
+ * 결과: 한 번 본 재고가 영원히 남았다. 연결된 시스템의 목록에서 그 줄이 없어져도 우리는
680
+ * 「없어졌다」를 들을 길이 없었다(있는 것만 오고, 없어진 것은 아무 말도 오지 않는다).
681
+ * 실측으로 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
682
+ *
683
+ * ── 무엇을 지우는지는 말한 범위대로 ────────────────────────────────────
684
+ * 자리를 함께 말했다 그 자리의 그 로트만 지운다 (꺼내 가서 그 칸이 빈 경우)
685
+ * 자리를 말하지 않았다 그 로트를 모든 자리에서 지운다 (그 로트가 통째로 없어진 경우)
686
+ *
687
+ * 짐작이 아니라 **진술의 범위를 읽는 것**이다. 자리를 말했으면 그 자리를 말한 것이고,
688
+ * 말하지 않았으면 자리를 가리지 않고 말한 것이다.
689
+ *
690
+ * 순서 판정을 함께 지난다: 늦게 온 옛 「없어졌다」가 그 뒤에 다시 들어온 재고를 지우면 안 된다.
691
+ * 열쇠는 관측과 같은 것을 쓴다 — 같은 대상의 두 방향이므로 한 열쇠로 재야 한다.
692
+ */
693
+ const goneAt = ev.readPoint?.id;
694
+ for (const qe of ev.quantityList ?? []) {
695
+ if (!qe?.epcClass)
696
+ continue;
697
+ if (goneAt) {
698
+ const key = `${qe.epcClass}@${goneAt}`;
699
+ if (envelope && this.stale(`item:${key}`, envelope))
700
+ continue;
701
+ this.remove(key);
702
+ continue;
703
+ }
704
+ for (const key of [...this.items.keys()]) {
705
+ if (!key.startsWith(`${qe.epcClass}@`))
706
+ continue;
707
+ if (envelope && this.stale(`item:${key}`, envelope))
708
+ continue;
709
+ this.remove(key);
710
+ }
711
+ }
635
712
  return;
636
713
  }
637
714
  const loc = ev.readPoint?.id;
@@ -646,7 +723,9 @@ export class ObservedReducer {
646
723
  /* 물품별 순서 판정 — 늦게 온 옛 관측이 최신 위치를 덮지 않게. */
647
724
  if (envelope && this.stale(`item:${epc}`, envelope))
648
725
  continue;
649
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all));
726
+ const seen = this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all);
727
+ const atSeen = Date.parse(String(envelope?.eventTime ?? ''));
728
+ this.items.set(itemKeyOf(seen), Number.isFinite(atSeen) ? { ...seen, seenAtMs: atSeen } : seen);
650
729
  }
651
730
  /* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
652
731
  * 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
@@ -683,7 +762,9 @@ export class ObservedReducer {
683
762
  */
684
763
  if (envelope && this.stale(`item:${subLotId}`, envelope))
685
764
  continue;
686
- this.items.set(subLotId, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId));
765
+ const seenQty = this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId);
766
+ const atQty = Date.parse(String(envelope?.eventTime ?? ''));
767
+ this.items.set(subLotId, Number.isFinite(atQty) ? { ...seenQty, seenAtMs: atQty } : seenQty);
687
768
  }
688
769
  }
689
770
  }
@@ -936,8 +1017,16 @@ export class ObservedReducer {
936
1017
  origin: n.origin
937
1018
  };
938
1019
  }),
939
- /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
940
- items: [...this.items.values()].map(i => ({ ...i })),
1020
+ /*
1021
+ * 갖고 있는 것을 전부 내보낸다 — 줄이면 그 자리에서 정보가 사라진다.
1022
+ *
1023
+ * **`seenAtMs` 는 뺀다.** 그것은 물품에 대한 사실이 아니라 **우리가 언제 들었나**이고, 「이 목록이
1024
+ * 전부다」를 받았을 때 무엇을 지울지 가리는 데만 쓴다. 화면이 알 필요가 없고, 상태에 내보내면
1025
+ * 자체 구동에는 없는 값이라 두 구동이 어긋난다(적합성 검사가 그것을 잡았다).
1026
+ *
1027
+ * 중간 저장본에는 남는다 — 재기동 뒤에도 무엇을 지울지 가릴 수 있어야 한다.
1028
+ */
1029
+ items: [...this.items.values()].map(({ seenAtMs: _seen, ...i }) => ({ ...i })),
941
1030
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
942
1031
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
943
1032
  persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
@@ -1,6 +1,6 @@
1
1
  import type { IngestResult } from './face2-adapter.ts';
2
2
  /** 이 문이 받는 여섯 가지 — 리듀서가 접는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
3
- export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'observation';
3
+ export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'observation' | 'complete';
4
4
  /**
5
5
  * 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
6
6
  *
@@ -151,6 +151,18 @@ const SPECS = {
151
151
  * `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
152
152
  * 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
153
153
  */
154
+ /*
155
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 알린다.
156
+ *
157
+ * 둘 다 요구한다: 어느 목록인지와 그 주기를 **시작한** 시각. 시각이 없으면 무엇을 지울지 가릴 수
158
+ * 없고, 그때 지우면 전부 지운다 — 받지 않는 것이 옳다.
159
+ */
160
+ complete: {
161
+ eventType: OP_EVENT.complete,
162
+ identity: 'completeAxis',
163
+ required: ['completeAxis', 'since'],
164
+ fields: { completeAxis: 'string', since: 'string', recordTime: 'string' }
165
+ },
154
166
  observation: {
155
167
  eventType: OP_EVENT.observation,
156
168
  identity: 'locationId',
@@ -223,6 +235,9 @@ export function operationalKindOf(record) {
223
235
  */
224
236
  if (has('locationId') && has('propertyId'))
225
237
  return 'observation';
238
+ /* 「이 목록이 전부다」 — 어느 목록인지를 스스로 말하므로 다른 사실과 섞이지 않는다. */
239
+ if (has('completeAxis'))
240
+ return 'complete';
226
241
  return undefined;
227
242
  }
228
243
  /** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
@@ -252,8 +267,15 @@ export function ingestOperationalRecords(records, opts) {
252
267
  const spec = SPECS[kind];
253
268
  const r = record;
254
269
  const errors = [];
255
- /* 모르는 이름은 거부한다 — 한 글자 틀린 필드가 조용히 사라지는 것을 막는다. */
256
- const unknown = Object.keys(r).filter(k => k !== 'at' && spec.fields[k] === undefined);
270
+ /*
271
+ * 모르는 이름은 거부한다 — 한 글자 틀린 필드가 오류 없이 사라지는 것을 막는다.
272
+ *
273
+ * 둘은 뺀다. **사실의 칸이 아니라 봉투의 칸**이다: `at` 은 그 사실이 일어난 시각이고,
274
+ * `description` 은 사람이 그때 적어 둔 말이다(§`CanonicalEnvelope.description`). 사실 칸으로
275
+ * 검사하면 명세마다 같은 두 줄을 적어야 하고, 한 곳을 빠뜨리면 그 통로만 거부한다.
276
+ */
277
+ const ENVELOPE_FIELDS = ['at', 'description'];
278
+ const unknown = Object.keys(r).filter(k => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === undefined);
257
279
  if (unknown.length)
258
280
  errors.push(`${kind}: 계약에 없는 필드 — ${unknown.join(', ')}`);
259
281
  for (const name of spec.required) {
@@ -342,11 +364,14 @@ export function ingestOperationalRecords(records, opts) {
342
364
  rejected.push({ record, errors });
343
365
  continue;
344
366
  }
367
+ /* 사람이 적어 둔 말 — 봉투에 싣는다(§`CanonicalEnvelope.description`). 물류 쪽과 같은 자리다. */
368
+ const note = typeof r.description === 'string' ? String(r.description).trim() : '';
345
369
  accepted.push({
346
370
  eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
347
371
  eventType: spec.eventType,
348
372
  eventTime: new Date(atMs).toISOString(),
349
373
  tenantId: opts.tenantId,
374
+ ...(note ? { description: note } : {}),
350
375
  data
351
376
  });
352
377
  }
@@ -614,6 +614,27 @@ var OP_EVENT = {
614
614
  * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
615
615
  */
616
616
  test: "test.result",
617
+ /**
618
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
619
+ *
620
+ * ── 왜 필요한가 (2026-08-25 실측) ────────────────────────────────────────
621
+ * 연결된 시스템은 매 주기 현재 재고를 전부 보낸다. 그런데 트윈은 그것을 낱낱의 관측으로 받아
622
+ * **더하기만 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서, 목록에서 빠진 줄은 아무 말도
623
+ * 오지 않은 것이 되고 트윈에 영원히 남았다.
624
+ *
625
+ * 실측: 실제 재고 1,704건인데 트윈이 10,038건을 갖고 있었다.
626
+ *
627
+ * ── 왜 「사라진 것을 알려 주기」가 아니라 이 방식인가 ──────────────────────
628
+ * 연결 쪽이 앞 주기와 비교해 사라진 줄을 찾아 알리는 방법도 있다. 그런데 그것은 **하나 빠뜨리면
629
+ * 그 줄이 영원히 남는다.** 이 방식은 주기마다 스스로 바로잡는다 — 이미 잘못 쌓인 것도 다음 주기에
630
+ * 사라진다.
631
+ *
632
+ * ── 보내는 쪽이 지킬 것 ─────────────────────────────────────────────────
633
+ * **끝까지 읽었을 때만 보낸다.** 읽다가 끊긴 주기에 이것을 보내면 **살아 있는 재고를 지운다.**
634
+ * 그리고 `since` 는 그 주기를 **시작한** 시각이다(끝낸 시각이 아니다) — 주기 도중에 들어온 관측이
635
+ * 지워지지 않아야 한다.
636
+ */
637
+ complete: "axis.complete",
617
638
  /**
618
639
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
619
640
  *
@@ -1668,6 +1689,19 @@ var ObservedReducer = class {
1668
1689
  item.testResults = [...kept, next];
1669
1690
  break;
1670
1691
  }
1692
+ case OP_EVENT.complete: {
1693
+ const d = e.data;
1694
+ if (d?.completeAxis !== "items") break;
1695
+ const since = Date.parse(String(d.since ?? ""));
1696
+ if (!Number.isFinite(since)) break;
1697
+ let heard = 0;
1698
+ for (const it of this.items.values()) if ((it.seenAtMs ?? -1) >= since) heard++;
1699
+ if (!heard) break;
1700
+ for (const [key, it] of [...this.items.entries()]) {
1701
+ if ((it.seenAtMs ?? -1) < since) this.remove(key);
1702
+ }
1703
+ break;
1704
+ }
1671
1705
  case OP_EVENT.attentionAck: {
1672
1706
  const d = e.data;
1673
1707
  if (d?.id) this.acked.add(d.id);
@@ -1812,6 +1846,21 @@ var ObservedReducer = class {
1812
1846
  }
1813
1847
  if (ev.action === "DELETE") {
1814
1848
  for (const epc of ev.epcList) this.remove(epc);
1849
+ const goneAt = ev.readPoint?.id;
1850
+ for (const qe of ev.quantityList ?? []) {
1851
+ if (!qe?.epcClass) continue;
1852
+ if (goneAt) {
1853
+ const key = `${qe.epcClass}@${goneAt}`;
1854
+ if (envelope && this.stale(`item:${key}`, envelope)) continue;
1855
+ this.remove(key);
1856
+ continue;
1857
+ }
1858
+ for (const key of [...this.items.keys()]) {
1859
+ if (!key.startsWith(`${qe.epcClass}@`)) continue;
1860
+ if (envelope && this.stale(`item:${key}`, envelope)) continue;
1861
+ this.remove(key);
1862
+ }
1863
+ }
1815
1864
  return;
1816
1865
  }
1817
1866
  const loc = ev.readPoint?.id;
@@ -1820,7 +1869,9 @@ var ObservedReducer = class {
1820
1869
  this.touchLocation(loc);
1821
1870
  for (const epc of ev.epcList) {
1822
1871
  if (envelope && this.stale(`item:${epc}`, envelope)) continue;
1823
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all));
1872
+ const seen = this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all);
1873
+ const atSeen = Date.parse(String(envelope?.eventTime ?? ""));
1874
+ this.items.set(itemKeyOf(seen), Number.isFinite(atSeen) ? { ...seen, seenAtMs: atSeen } : seen);
1824
1875
  }
1825
1876
  if (!ev.epcList?.length) {
1826
1877
  for (const qe of ev.quantityList ?? []) {
@@ -1828,7 +1879,9 @@ var ObservedReducer = class {
1828
1879
  const mine = all.filter((x) => x.epcClass === qe.epcClass);
1829
1880
  const subLotId = `${qe.epcClass}@${loc}`;
1830
1881
  if (envelope && this.stale(`item:${subLotId}`, envelope)) continue;
1831
- this.items.set(subLotId, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId));
1882
+ const seenQty = this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId);
1883
+ const atQty = Date.parse(String(envelope?.eventTime ?? ""));
1884
+ this.items.set(subLotId, Number.isFinite(atQty) ? { ...seenQty, seenAtMs: atQty } : seenQty);
1832
1885
  }
1833
1886
  }
1834
1887
  }
@@ -2065,8 +2118,16 @@ var ObservedReducer = class {
2065
2118
  origin: n.origin
2066
2119
  };
2067
2120
  }),
2068
- /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
2069
- items: [...this.items.values()].map((i) => ({ ...i })),
2121
+ /*
2122
+ * 갖고 있는 것을 전부 내보낸다 — 줄이면 그 자리에서 정보가 사라진다.
2123
+ *
2124
+ * **`seenAtMs` 는 뺀다.** 그것은 물품에 대한 사실이 아니라 **우리가 언제 들었나**이고, 「이 목록이
2125
+ * 전부다」를 받았을 때 무엇을 지울지 가리는 데만 쓴다. 화면이 알 필요가 없고, 상태에 내보내면
2126
+ * 자체 구동에는 없는 값이라 두 구동이 어긋난다(적합성 검사가 그것을 잡았다).
2127
+ *
2128
+ * 중간 저장본에는 남는다 — 재기동 뒤에도 무엇을 지울지 가릴 수 있어야 한다.
2129
+ */
2130
+ items: [...this.items.values()].map(({ seenAtMs: _seen, ...i }) => ({ ...i })),
2070
2131
  /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
2071
2132
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
2072
2133
  persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
@@ -3316,11 +3377,13 @@ function ingest(records, rules, opts) {
3316
3377
  rejected.push({ record, errors });
3317
3378
  continue;
3318
3379
  }
3380
+ const note = typeof record["description"] === "string" ? String(record["description"]).trim() : "";
3319
3381
  accepted.push({
3320
3382
  eventId: `${opts.tenantId}-ingest-${++seq}`,
3321
3383
  eventType: `epcis.${ev.type}`,
3322
3384
  eventTime: ev.eventTime,
3323
3385
  tenantId: opts.tenantId,
3386
+ ...note ? { description: note } : {},
3324
3387
  data: ev
3325
3388
  });
3326
3389
  }
@@ -9044,6 +9107,18 @@ var SPECS = {
9044
9107
  * `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
9045
9108
  * 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
9046
9109
  */
9110
+ /*
9111
+ * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 알린다.
9112
+ *
9113
+ * 둘 다 요구한다: 어느 목록인지와 그 주기를 **시작한** 시각. 시각이 없으면 무엇을 지울지 가릴 수
9114
+ * 없고, 그때 지우면 전부 지운다 — 받지 않는 것이 옳다.
9115
+ */
9116
+ complete: {
9117
+ eventType: OP_EVENT.complete,
9118
+ identity: "completeAxis",
9119
+ required: ["completeAxis", "since"],
9120
+ fields: { completeAxis: "string", since: "string", recordTime: "string" }
9121
+ },
9047
9122
  observation: {
9048
9123
  eventType: OP_EVENT.observation,
9049
9124
  identity: "locationId",
@@ -9075,6 +9150,7 @@ function operationalKindOf(record) {
9075
9150
  if (has("orderId")) return "order";
9076
9151
  if (has("testableObjectId")) return "test";
9077
9152
  if (has("locationId") && has("propertyId")) return "observation";
9153
+ if (has("completeAxis")) return "complete";
9078
9154
  return void 0;
9079
9155
  }
9080
9156
  function isOperationalRecord(record) {
@@ -9098,7 +9174,8 @@ function ingestOperationalRecords(records, opts) {
9098
9174
  const spec = SPECS[kind];
9099
9175
  const r = record;
9100
9176
  const errors = [];
9101
- const unknown = Object.keys(r).filter((k) => k !== "at" && spec.fields[k] === void 0);
9177
+ const ENVELOPE_FIELDS = ["at", "description"];
9178
+ const unknown = Object.keys(r).filter((k) => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === void 0);
9102
9179
  if (unknown.length) errors.push(`${kind}: \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uD544\uB4DC \u2014 ${unknown.join(", ")}`);
9103
9180
  for (const name of spec.required) {
9104
9181
  const v = r[name];
@@ -9182,11 +9259,13 @@ function ingestOperationalRecords(records, opts) {
9182
9259
  rejected.push({ record, errors });
9183
9260
  continue;
9184
9261
  }
9262
+ const note = typeof r.description === "string" ? String(r.description).trim() : "";
9185
9263
  accepted.push({
9186
9264
  eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
9187
9265
  eventType: spec.eventType,
9188
9266
  eventTime: new Date(atMs).toISOString(),
9189
9267
  tenantId: opts.tenantId,
9268
+ ...note ? { description: note } : {},
9190
9269
  data
9191
9270
  });
9192
9271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.62",
3
+ "version": "0.7.64",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {