@operato/twin-kernel 0.7.66 → 0.7.68

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.
@@ -0,0 +1,168 @@
1
+ import { ILMD_ATTR } from "./epcis.js";
2
+ /*
3
+ * **마스터데이터 — 변하지 않는 속성이 들어오는 문** (2026-08-28).
4
+ *
5
+ * ── 무엇이 없어서 무엇이 망가졌나 ───────────────────────────────────────────
6
+ * 커널에 마스터데이터라는 개념이 없었다. 그래서 유통기한 같은 값이 갈 곳이 **사건밖에** 없었고,
7
+ * 표준은 그 값을 `ObjectEvent(action=ADD)` 와 변환 사건에만 실을 수 있게 한다(EPCIS 2.0 §7.3.8).
8
+ *
9
+ * 그 제약이 유입 쪽에서 이렇게 나타났다. 전량을 읽는 원본(식품 제조 MES)이 재고를 다시 말할 때마다,
10
+ * 유통기한을 실으려고 **들어오지 않은 것을 `ADD` 라고** 말했다. 그리고 `ilmd` 는 사건 단위라서 자리별로
11
+ * 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에 2,665건이다.
12
+ *
13
+ * 나르는 방법의 제약이 사실의 뜻을 정한 것이고, 순서가 거꾸로였다.
14
+ *
15
+ * ── 표준이 둔 자리 ──────────────────────────────────────────────────────────
16
+ * 표준은 이런 값을 **어휘 요소의 속성**으로 담는 자리를 사건과 별개로 갖고 있다.
17
+ *
18
+ * 어휘 타입 urn:epcglobal:epcis:vtype:EPCClass · ReadPoint · BizLocation
19
+ * 어휘 요소 { id, attributes }
20
+ *
21
+ * 속성 **이름**은 EPCIS 본문이 정하지 않고 상위 문서(CBV 마스터데이터)의 몫이다(§7.3.8 이 미룬다).
22
+ * 그래서 이 모듈은 이름을 해석하지 않고 **그대로 보관한다** — 아는 이름만 상태 축으로 옮기고, 모르는
23
+ * 것은 잃지 않는다. 우리가 생산할 때 쓰는 이름은 `ILMD_ATTR` 한 곳에 모여 있고 사건 경로와 같은
24
+ * 상수다(이름이 바뀌면 한 곳만 고친다).
25
+ *
26
+ * ── 무엇을 이 문으로 보내지 않나 ────────────────────────────────────────────
27
+ * **변하는 값은 사건이다.** 수량·위치·상태·진행은 이 문으로 오지 않는다. 여기 오는 것은 그 물건의
28
+ * 생애 동안 같은 값뿐이고, 그래서 「그때는 얼마였나」라는 물음이 성립하지 않는다 — 시각축이 없는 값이다.
29
+ *
30
+ * 그 성질이 「지난 기록에 적지 않는다」의 근거다. 사건에서 파생되는 상태가 아니라 **선언된 것**이므로
31
+ * 모델·자재 선언과 같은 자리에 산다. 다만 중간 저장본에는 담는다(되세울 때 원본을 다시 읽지 않게).
32
+ *
33
+ * 설계: `operato-twin/design/plans/master-data.md`
34
+ */
35
+ /** 표준 어휘 타입 — 이 세 가지가 우리 상태 축에 닿는다. 그 밖은 보관하고 알린다. */
36
+ export const VOCABULARY_TYPE = {
37
+ /** 품목·로트 클래스의 속성 — 유통기한·로트번호가 여기 온다. */
38
+ epcClass: 'urn:epcglobal:epcis:vtype:EPCClass',
39
+ /** 관측 지점의 속성. */
40
+ readPoint: 'urn:epcglobal:epcis:vtype:ReadPoint',
41
+ /** 업무 자리의 속성. */
42
+ bizLocation: 'urn:epcglobal:epcis:vtype:BizLocation'
43
+ };
44
+ /** 이 레코드가 마스터데이터인가 — 갈래 판정은 **커널이 한 곳에서** 한다(어휘 넷을 가르는 방식과 같다). */
45
+ export function isMasterDataRecord(record) {
46
+ if (!record || typeof record !== 'object')
47
+ return false;
48
+ const r = record;
49
+ return typeof r.vocabularyType === 'string' && !!r.vocabularyType && typeof r.id === 'string' && !!r.id && isPlainObject(r.attributes);
50
+ }
51
+ function isPlainObject(v) {
52
+ return !!v && typeof v === 'object' && !Array.isArray(v);
53
+ }
54
+ /**
55
+ * 마스터데이터를 받아들인다 — **거부한 것은 이유와 함께 남는다**(다른 어휘와 같은 규율).
56
+ *
57
+ * 빈 속성은 거부한다: 「이 로트에 대해 아무것도 모른다」를 보내는 것은 아무 뜻이 없고, 받아 두면
58
+ * 기존 값을 지우려는 것인지 아무 말도 아닌지 구별할 수 없다.
59
+ */
60
+ export function ingestMasterData(records) {
61
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
62
+ const accepted = [];
63
+ const rejected = [];
64
+ for (const r of arr) {
65
+ const errors = [];
66
+ if (!isMasterDataRecord(r))
67
+ errors.push('vocabularyType · id · attributes 가 있어야 한다');
68
+ else if (!Object.keys(r.attributes).length)
69
+ errors.push('attributes 가 비었다 — 아무 뜻이 없는 진술이다');
70
+ /* 값이 `null` 인 속성은 「지운다」는 뜻이므로 빈 진술이 아니다(위 검사가 이름 수를 세므로 통과한다). */
71
+ if (errors.length)
72
+ rejected.push({ record: r, errors });
73
+ else
74
+ accepted.push({ vocabularyType: r.vocabularyType, id: r.id, attributes: { ...r.attributes } });
75
+ }
76
+ return { accepted, rejected };
77
+ }
78
+ /**
79
+ * 클래스 단위 마스터 표 — 관측 리듀서가 든다.
80
+ *
81
+ * 물품은 자리마다 따로 서지만(`클래스@자리`) 이 속성은 **클래스의 것**이다. 그래서 물품마다 복사해
82
+ * 두지 않고 클래스로 한 번 들고, 물품을 세울 때 본다. 마스터가 관측보다 늦게 와도 값이 서는 이유다.
83
+ */
84
+ export class ClassMasterTable {
85
+ byId = new Map();
86
+ /**
87
+ * 속성을 **합친다** — 덮지 않는다(한 번에 일부만 오는 원본이 있다). 바뀐 식별자를 돌려준다.
88
+ *
89
+ * ── 지우는 법 ───────────────────────────────────────────────────────────────
90
+ * 값에 `null` 을 실으면 **그 속성을 지운다**(「모름으로 되돌린다」). 합치기만 하면 원본에서 지운 값이
91
+ * 트윈에 영원히 남고, 그것을 표현할 방법이 없으면 「지웠다」와 「이번엔 말하지 않았다」가 같아진다.
92
+ *
93
+ * `undefined` 는 지우지 않는다 — 그것은 「이 진술에 그 속성이 없다」이고, 원본이 일부만 보내는
94
+ * 정상이다. 두 뜻을 갈라 둔다.
95
+ *
96
+ * 속성이 하나도 남지 않으면 그 요소를 표에서 버린다(빈 껍데기를 들고 있지 않는다).
97
+ */
98
+ merge(elements) {
99
+ const touched = [];
100
+ for (const e of elements) {
101
+ const next = { ...(this.byId.get(e.id) ?? {}) };
102
+ for (const [name, value] of Object.entries(e.attributes)) {
103
+ if (value === null)
104
+ delete next[name];
105
+ else if (value !== undefined)
106
+ next[name] = value;
107
+ }
108
+ if (Object.keys(next).length)
109
+ this.byId.set(e.id, next);
110
+ else
111
+ this.byId.delete(e.id);
112
+ touched.push(e.id);
113
+ }
114
+ return touched;
115
+ }
116
+ /**
117
+ * 그 식별자의 항목을 버린다 — **아무도 가리키지 않게 된 뒤**에만 부른다.
118
+ *
119
+ * 로트는 계속 새로 생기므로 이 표는 스스로 줄지 않는다. 하루 수백 로트면 1년에 수십만 항목이고,
120
+ * 그것을 아무도 지우지 않으면 재기동으로만 줄어든다(그리고 중간 저장본이 그만큼 커진다).
121
+ */
122
+ forget(id) {
123
+ return this.byId.delete(id);
124
+ }
125
+ attributesOf(id) {
126
+ return id ? this.byId.get(id) : undefined;
127
+ }
128
+ get size() {
129
+ return this.byId.size;
130
+ }
131
+ serialize() {
132
+ return Object.fromEntries(this.byId);
133
+ }
134
+ restore(saved) {
135
+ this.byId.clear();
136
+ if (!isPlainObject(saved))
137
+ return;
138
+ for (const [id, attrs] of Object.entries(saved))
139
+ if (isPlainObject(attrs))
140
+ this.byId.set(id, { ...attrs });
141
+ }
142
+ clear() {
143
+ this.byId.clear();
144
+ }
145
+ }
146
+ /** 마스터 속성에서 유통기한을 읽는다 — 사건의 `ilmd` 와 **같은 이름**을 본다. */
147
+ export function expiryFromAttributes(attrs) {
148
+ return readEpochMs(attrs?.[ILMD_ATTR.expiry]);
149
+ }
150
+ /** 마스터 속성에서 로트 번호를 읽는다 — 사건의 `ilmd` 와 같은 이름. */
151
+ export function lotFromAttributes(attrs) {
152
+ const v = attrs?.[ILMD_ATTR.lot];
153
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
154
+ }
155
+ /**
156
+ * 시각 값을 밀리초로 — 숫자와 문자열을 모두 읽는다(원본이 둘 다 쓴다).
157
+ *
158
+ * 읽을 수 없으면 `undefined` 다. 「지금」으로 메우면 기한이 지난 재고가 멀쩡해 보이고, 0 으로 메우면
159
+ * 멀쩡한 재고가 기한 지난 것으로 보인다 — 둘 다 사실이 아니다.
160
+ */
161
+ function readEpochMs(raw) {
162
+ if (typeof raw === 'number')
163
+ return Number.isFinite(raw) ? raw : undefined;
164
+ if (typeof raw !== 'string' || !raw.trim())
165
+ return undefined;
166
+ const at = Date.parse(raw);
167
+ return Number.isNaN(at) ? undefined : at;
168
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AssetState, TestResult, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
2
+ import { type VocabularyElement } from './master-data.ts';
2
3
  interface ProjItem {
3
4
  epc: string;
4
5
  /** 로트의 부분(표준 MaterialSubLot.ID) — 비직렬 로트가 자리마다 갈릴 때만. */
@@ -168,6 +169,25 @@ export interface ReducerCheckpoint {
168
169
  atMs: number;
169
170
  longestGapMs?: number;
170
171
  }[];
172
+ /**
173
+ * 도메인 커널이 따로 드는 상태 — 이 리듀서는 내용을 보지 않고 그대로 실어 보낸다 (2026-08-27).
174
+ *
175
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
176
+ * 에너지 커널은 이 리듀서 밖에 자기 상태를 든다(계량 지점 · 열린 수요 구간 · 닫힌 구간들 · 월 최고).
177
+ * 그 값들은 계측을 모아 **트윈이 만든 것**이라, 연결된 시스템이 되풀어 주지 않는다. 재개점에 담기지
178
+ * 않으면 중간 저장본에서 이어 처리할 때 그만큼이 조용히 사라진다.
179
+ *
180
+ * 내용을 여기서 정의하지 않는 이유: 도메인마다 다르고, 이 리듀서가 그것을 알면 도메인 규칙이
181
+ * 공통 코드로 새어 든다. 그대로 담아 두고 되돌려 줄 뿐이다.
182
+ */
183
+ domain?: unknown;
184
+ /**
185
+ * **클래스 단위 마스터데이터** — 변하지 않는 속성(유통기한·로트번호)이 사는 자리(§`master-data`).
186
+ *
187
+ * 지난 기록에 적지 않는 값이므로 여기 담지 않으면 되세울 때 빈다. 변하지 않는 값이라 어느 시점의
188
+ * 저장본에서 꺼내도 같은 답이다.
189
+ */
190
+ classMaster?: Record<string, Record<string, unknown>>;
171
191
  }
172
192
  export declare class ObservedReducer {
173
193
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
@@ -186,6 +206,13 @@ export declare class ObservedReducer {
186
206
  */
187
207
  private heard;
188
208
  private items;
209
+ /**
210
+ * **클래스 단위 마스터 표** — 변하지 않는 속성이 사는 자리(§`master-data`).
211
+ *
212
+ * 물품은 자리마다 따로 서지만(`클래스@자리`) 유통기한·로트번호는 **클래스의 것**이다. 그래서 물품마다
213
+ * 복사해 두지 않고 클래스로 한 번 들고, 물품을 세울 때 본다 — 마스터가 관측보다 늦게 와도 값이 선다.
214
+ */
215
+ private classMaster;
189
216
  private aggregation;
190
217
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
191
218
  private pendingParent;
@@ -334,6 +361,24 @@ export declare class ObservedReducer {
334
361
  * 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
335
362
  * `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
336
363
  */
364
+ /**
365
+ * 이 물품에 해당하는 마스터 속성 — **클래스로 찾고, 없으면 물품 자신의 식별자로** 찾는다.
366
+ *
367
+ * 비직렬 재고는 물품의 키가 곧 클래스 식별자다. 직렬 개체는 클래스가 따로 있다. 두 모양이 다 오므로
368
+ * 둘 다 본다 — 한쪽만 보면 그 원본에서는 값이 조용히 비어 있게 된다.
369
+ */
370
+ private masterAttributesOf;
371
+ /**
372
+ * **마스터데이터를 받는다** — 어휘 요소의 속성을 상태에 세운다(§`master-data`).
373
+ *
374
+ * 받은 것을 표에 합치고, **그 배치에 든 식별자의 물품만** 다시 세운다. 전부를 훑지 않는 이유는
375
+ * 재고가 수만 건일 수 있고 마스터는 자주 오기 때문이다.
376
+ *
377
+ * 이 값은 지난 기록에 적지 않는다 — 변하지 않는 값이므로 시각축이 없다. 호스트가 그 갈래를 정한다.
378
+ */
379
+ applyMasterData(elements: readonly VocabularyElement[]): number;
380
+ /** 지금 든 마스터 항목 수 — 화면이 「무엇을 알고 있나」를 말할 수 있게. */
381
+ get masterDataSize(): number;
337
382
  private expiryOf;
338
383
  private mergeItem;
339
384
  /** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { OP_EVENT, capabilityOf, itemKeyOf, judgeAgainstSpec, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
23
23
  import { ILMD_ATTR, parseEpc } from "./epcis.js";
24
+ import { ClassMasterTable, expiryFromAttributes, lotFromAttributes } from "./master-data.js";
24
25
  /**
25
26
  * 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
26
27
  *
@@ -55,6 +56,13 @@ export class ObservedReducer {
55
56
  */
56
57
  heard = new Map();
57
58
  items = new Map();
59
+ /**
60
+ * **클래스 단위 마스터 표** — 변하지 않는 속성이 사는 자리(§`master-data`).
61
+ *
62
+ * 물품은 자리마다 따로 서지만(`클래스@자리`) 유통기한·로트번호는 **클래스의 것**이다. 그래서 물품마다
63
+ * 복사해 두지 않고 클래스로 한 번 들고, 물품을 세울 때 본다 — 마스터가 관측보다 늦게 와도 값이 선다.
64
+ */
65
+ classMaster = new ClassMasterTable();
58
66
  aggregation = new Map();
59
67
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
60
68
  pendingParent = new Map(); // 자식 EPC → 부모(물류단위)
@@ -838,6 +846,56 @@ export class ObservedReducer {
838
846
  * 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
839
847
  * `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
840
848
  */
849
+ /**
850
+ * 이 물품에 해당하는 마스터 속성 — **클래스로 찾고, 없으면 물품 자신의 식별자로** 찾는다.
851
+ *
852
+ * 비직렬 재고는 물품의 키가 곧 클래스 식별자다. 직렬 개체는 클래스가 따로 있다. 두 모양이 다 오므로
853
+ * 둘 다 본다 — 한쪽만 보면 그 원본에서는 값이 조용히 비어 있게 된다.
854
+ */
855
+ masterAttributesOf(classUri, epc) {
856
+ return this.classMaster.attributesOf(classUri) ?? this.classMaster.attributesOf(epc);
857
+ }
858
+ /**
859
+ * **마스터데이터를 받는다** — 어휘 요소의 속성을 상태에 세운다(§`master-data`).
860
+ *
861
+ * 받은 것을 표에 합치고, **그 배치에 든 식별자의 물품만** 다시 세운다. 전부를 훑지 않는 이유는
862
+ * 재고가 수만 건일 수 있고 마스터는 자주 오기 때문이다.
863
+ *
864
+ * 이 값은 지난 기록에 적지 않는다 — 변하지 않는 값이므로 시각축이 없다. 호스트가 그 갈래를 정한다.
865
+ */
866
+ applyMasterData(elements) {
867
+ if (!elements.length)
868
+ return 0;
869
+ const touched = new Set(this.classMaster.merge(elements));
870
+ for (const [key, item] of this.items) {
871
+ /* 클래스로 든 것과 물품 자신의 식별자, 둘 중 하나가 이 배치에 들었으면 다시 세운다. */
872
+ if (!touched.has(item.gtin ?? '') && !touched.has(item.epc))
873
+ continue;
874
+ const attrs = this.masterAttributesOf(item.gtin, item.epc);
875
+ /*
876
+ * **물품이 설 때와 같은 순서로** 세운다(§`mergeItem`).
877
+ *
878
+ * 식별자에서 뽑은 것 → 사건의 `ilmd` → 마스터 → 기존값
879
+ *
880
+ * 여기서 「기존값이 마스터를 이긴다」로 적었더니 값이 **들어온 순서에 따라 달라졌다**: 마스터가
881
+ * 먼저 오면 마스터 값, 늦게 오면 옛 값. 같은 사실에 두 답이 생기는 자리다.
882
+ */
883
+ const fromId = parseEpc(item.gtin ?? item.epc).lot ?? parseEpc(item.epc).lot;
884
+ this.items.set(key, {
885
+ ...item,
886
+ expiry: this.expiryOf(item.ilmd) ?? expiryFromAttributes(attrs) ?? item.expiry,
887
+ lot: fromId ??
888
+ (typeof item.ilmd?.[ILMD_ATTR.lot] === 'string' ? item.ilmd[ILMD_ATTR.lot] : undefined) ??
889
+ lotFromAttributes(attrs) ??
890
+ item.lot
891
+ });
892
+ }
893
+ return elements.length;
894
+ }
895
+ /** 지금 든 마스터 항목 수 — 화면이 「무엇을 알고 있나」를 말할 수 있게. */
896
+ get masterDataSize() {
897
+ return this.classMaster.size;
898
+ }
841
899
  expiryOf(ilmd) {
842
900
  const raw = ilmd?.[ILMD_ATTR.expiry];
843
901
  if (typeof raw === 'number' && Number.isFinite(raw))
@@ -894,14 +952,50 @@ export class ObservedReducer {
894
952
  })(),
895
953
  /* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
896
954
  ilmd: patch.ilmd ?? cur?.ilmd,
897
- expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
955
+ /*
956
+ * ── 보는 순서 (2026-08-28) ─────────────────────────────────────────────
957
+ * ① 사건에 실려 온 `ilmd` 표준의 생성 시점 진술 — 가장 강하다
958
+ * ② 그 클래스의 마스터 표 마스터데이터 통로로 들어온 것(§`master-data`)
959
+ * ③ 이미 갖고 있던 값 뒤 사건이 지우지 않는다
960
+ *
961
+ * ②가 없던 동안, 전량을 읽는 원본은 유통기한을 실으려고 **들어오지 않은 것을 `ADD`** 라고
962
+ * 말해야 했다. 그 값이 갈 자리를 만든 것이 이 줄이다.
963
+ */
964
+ expiry: this.expiryOf(patch.ilmd) ?? expiryFromAttributes(this.masterAttributesOf(classUri, epc)) ?? cur?.expiry,
898
965
  /* 로트는 LGTIN(식별자)에서 오지만, 직렬 개체는 마스터데이터에 실려 온다. */
899
- lot: parsedClass?.lot ?? parsedSelf.lot ?? (typeof patch.ilmd?.[ILMD_ATTR.lot] === 'string' ? patch.ilmd[ILMD_ATTR.lot] : undefined) ?? cur?.lot
966
+ lot: parsedClass?.lot ??
967
+ parsedSelf.lot ??
968
+ (typeof patch.ilmd?.[ILMD_ATTR.lot] === 'string' ? patch.ilmd[ILMD_ATTR.lot] : undefined) ??
969
+ lotFromAttributes(this.masterAttributesOf(classUri, epc)) ??
970
+ cur?.lot
900
971
  };
901
972
  }
902
973
  /** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
903
974
  remove(epc) {
975
+ const gone = this.items.get(epc);
904
976
  this.items.delete(epc);
977
+ /*
978
+ * ── 마스터 표도 함께 줄인다 (2026-08-28) ───────────────────────────────────
979
+ *
980
+ * 로트는 계속 새로 생기므로 이 표는 스스로 줄지 않는다. 하루 수백 로트면 1년에 수십만 항목이고,
981
+ * 중간 저장본이 그만큼 커진다. 그래서 **아무 물품도 가리키지 않게 된 식별자**를 버린다.
982
+ *
983
+ * 이탈이 일어난 자리에서만 검사한다. 마스터가 먼저 오고 물품이 아직 없는 것은 **정상**이므로
984
+ * (순서를 보장할 수 없다) 그때 「가리키는 물품이 없다」로 지우면 방금 받은 값을 버린다.
985
+ */
986
+ for (const id of [gone?.gtin, epc]) {
987
+ if (!id || !this.classMaster.attributesOf(id))
988
+ continue;
989
+ let stillUsed = false;
990
+ for (const it of this.items.values()) {
991
+ if (it.gtin === id || it.epc === id) {
992
+ stillUsed = true;
993
+ break;
994
+ }
995
+ }
996
+ if (!stillUsed)
997
+ this.classMaster.forget(id);
998
+ }
905
999
  const children = this.aggregation.get(epc);
906
1000
  if (children) {
907
1001
  this.aggregation.delete(epc);
@@ -1024,8 +1118,13 @@ export class ObservedReducer {
1024
1118
  acked: [...this.acked],
1025
1119
  corrections: this.corrections.map(c => ({ ...c })),
1026
1120
  unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
1027
- /* 마지막으로 들은 시각 — 이어받지 않으면 재기동 뒤에 침묵의 길이를 말할 수 없다. */
1028
- heard: [...this.heard.entries()].map(([id, v]) => ({ id, ...v }))
1121
+ /* 마지막으로 들은 시각 — 이어받지 않으면 재기동 뒤에 아무 말이 없던 길이를 말할 수 없다. */
1122
+ heard: [...this.heard.entries()].map(([id, v]) => ({ id, ...v })),
1123
+ /*
1124
+ * 마스터데이터도 담는다 — 지난 기록에 없는 값이므로, 담지 않으면 되세울 때 유통기한이 빈다.
1125
+ * 변하지 않는 값이라 어느 시점의 저장본에서 꺼내도 같은 답이다.
1126
+ */
1127
+ classMaster: this.classMaster.serialize()
1029
1128
  };
1030
1129
  }
1031
1130
  /**
@@ -1039,6 +1138,7 @@ export class ObservedReducer {
1039
1138
  this.master = new Map((cp?.master ?? []).map(n => [n.id, { ...n }]));
1040
1139
  this.heard = new Map((cp?.heard ?? []).map(h => [h.id, { atMs: h.atMs, ...(h.longestGapMs !== undefined ? { longestGapMs: h.longestGapMs } : {}) }]));
1041
1140
  this.items = new Map((cp?.items ?? []).map(i => [i.epc, { ...i }]));
1141
+ this.classMaster.restore(cp?.classMaster);
1042
1142
  this.aggregation = new Map((cp?.aggregation ?? []).map(a => [a.parent, [...a.children]]));
1043
1143
  this.pendingParent = new Map((cp?.pendingParent ?? []).map(x => [x.child, x.parent]));
1044
1144
  this.qtyAggregation = new Map((cp?.qtyAggregation ?? []).map(x => [x.parent, x.quantities]));