@operato/twin-kernel 0.2.0 → 0.2.2

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.
@@ -1,348 +1,8 @@
1
1
  /*
2
- * State Projector 이벤트 스트림 State 투영 (모니터링의 "수동 미러").
2
+ * `StateProjector`**옛 이름.** 규칙 자체는 `observed-reducer.ts` 옮겼다.
3
3
  *
4
- * sim 모드: 커널이 State 능동 생산(내부 상태 + 이벤트 방출).
5
- * live 모드: 외부(실 WMS)에서 이벤트가 도착 projector State 재구성.
6
- * "계약 동일, 데이터원만 스왑"(execution-model.md §5, ADR-0010). 같은 이벤트면 같은 State.
7
- *
8
- * 두 갈래 이벤트를 함께 접는다:
9
- * - EPCIS(epcis.*) → 재고/위치/조립 (What/Where)
10
- * - 운영 델타(task/equipment/order.status) → tasks·movers·orders (EPCIS 로 재구성 불가한 절반)
11
- * 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
12
- */
13
- import { OP_EVENT } from "./contract.js";
14
- import { ILMD_ATTR, parseEpc } from "./epcis.js";
15
- /**
16
- * 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
17
- *
18
- * 예전에는 epc·gtin·location·disposition 넷만 들고 있어서, 표준 이벤트로 정확히 받은 **수량·단위·
19
- * 소속 팔레트**를 버렸다(2026-08-01 감사). 그 결과 미러 트윈의 재고가 "팔레트 1개" 로 세어졌다.
20
- * 로트는 LGTIN 이면 식별자에서 파생한다. 만료(expiry)는 표준 자리가 `ilmd` 인데 아직 미지원이라
21
- * **꾸미지 않고 비워 둔다**(없는 것을 만들지 않는다).
4
+ * 이름을 옮긴 이유: 규칙은 이제 미러 전용이 아니다. 커널도 같은 규칙으로 이벤트를 접는다
5
+ * (`FlowEngine.apply`). "투영기" 라는 이름은 미러만의 것처럼 읽혀 구동이 규칙을 나눠 갖는 구조를
6
+ * 가린다. 소비처(호스트·테스트)가 많아 이름은 그대로 있게 남긴다.
22
7
  */
23
- /** 종류를 모르는 로케이션 관측으로 알게 됐지만 마스터가 아직 말해 주지 않은 자리. */
24
- const UNKNOWN_TYPE = 'unknown';
25
- export class StateProjector {
26
- /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
27
- master = new Map();
28
- items = new Map();
29
- aggregation = new Map(); // parent SSCC → child EPCs
30
- tasks = new Map();
31
- movers = new Map();
32
- persons = new Map();
33
- assets = new Map();
34
- orders = new Map();
35
- revision = 0;
36
- /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
37
- corrections = [];
38
- constructor(board) {
39
- for (const n of board.nodes)
40
- this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parentId: n.parentId, origin: 'master' });
41
- // 무버 기준선(마스터) — equipment.status 델타로 갱신됨.
42
- for (const m of board.movers)
43
- this.movers.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeNode, origin: 'master' });
44
- // 사람 기준선(마스터) — person.status 델타로 갱신됨.
45
- for (const p of board.persons ?? [])
46
- this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle' });
47
- for (const a of board.assets ?? [])
48
- this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: 'idle' });
49
- }
50
- /** 마스터 동기 — 로케이션 추가/변경/제거. */
51
- applyMaster(u) {
52
- if (u.op === 'remove') {
53
- this.master.delete(u.node.id);
54
- return;
55
- }
56
- const cur = this.master.get(u.node.id);
57
- /* **모르는 용량을 0 으로 뭉개지 않는다.** 0 은 "자리가 없다" 는 사실 주장이고, 미지정은 "모른다" 다.
58
- * 계약(`NodeState.capacity`)이 선택 필드로 둔 이유가 이것이며, 0 으로 채우면 포화 판정이 거짓으로
59
- * 성립하고 배정 정책이 그 노드를 영구히 배제한다. 그리고 upsert 가 **구역 소속(parentId)을 지우지
60
- * 않는다** — 마스터가 말하지 않은 것은 기존 값을 지키는 것이 upsert 의 뜻이다. */
61
- const capacity = u.node.capacity ?? cur?.capacity;
62
- this.master.set(u.node.id, {
63
- id: u.node.id,
64
- type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
65
- ...(capacity === undefined ? {} : { capacity }),
66
- ...(u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {}),
67
- /* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
68
- origin: 'master'
69
- });
70
- }
71
- /**
72
- * 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
73
- *
74
- * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
75
- * 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
76
- * 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
77
- * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
78
- *
79
- * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
80
- * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
81
- */
82
- touchLocation(id) {
83
- if (!id || this.master.has(id))
84
- return;
85
- this.master.set(id, { id, type: UNKNOWN_TYPE, origin: 'observed' });
86
- }
87
- /**
88
- * 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
89
- *
90
- * 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
91
- * 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
92
- * 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
93
- *
94
- * 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
95
- * 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
96
- */
97
- stale(key, e) {
98
- const at = Date.parse(String(e.eventTime ?? ''));
99
- if (!Number.isFinite(at))
100
- return false;
101
- const recorded = Date.parse(String(e.data?.recordTime ?? ''));
102
- const seen = this.lastAt.get(key);
103
- if (seen === undefined) {
104
- this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : undefined });
105
- return false;
106
- }
107
- if (at < seen.at)
108
- return true;
109
- if (at === seen.at && Number.isFinite(recorded) && seen.recorded !== undefined && recorded < seen.recorded)
110
- return true;
111
- this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : seen.recorded });
112
- return false;
113
- }
114
- /** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
115
- lastAt = new Map();
116
- /** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
117
- apply(e) {
118
- this.revision++;
119
- if (e.eventType.startsWith('epcis.')) {
120
- this.applyEpcis(e.data, e);
121
- return;
122
- }
123
- switch (e.eventType) {
124
- case OP_EVENT.task: {
125
- const d = e.data;
126
- if (this.stale(`task:${d.taskId}`, e))
127
- return;
128
- /* 커널이 실어 보낸 것을 그대로 담는다 — 진척·남은 시간·의도가 있어야 이 상태를 씨앗으로
129
- * 예측을 이어 굴릴 수 있고, 무자원이 설계인지(체류) 구별할 수 있다. */
130
- this.touchLocation(d.fromNode);
131
- this.touchLocation(d.toNode);
132
- this.tasks.set(d.taskId, {
133
- id: d.taskId, kind: d.kind, status: d.status, fromNode: d.fromNode, toNode: d.toNode,
134
- itemRefs: d.itemRefs, resourceRef: d.resourceRef, orderId: d.orderId,
135
- intent: d.intent, progress: d.progress, remainingMs: d.remainingMs, durationMs: d.durationMs,
136
- ...(d.personnel?.length ? { personnel: d.personnel.slice() } : {}),
137
- ...(d.assets?.length ? { assets: d.assets.slice() } : {})
138
- });
139
- break;
140
- }
141
- case OP_EVENT.equipment: {
142
- const d = e.data;
143
- if (this.stale(`mover:${d.moverId}`, e))
144
- return;
145
- /* 마스터에 없던 자원도 관측으로 자란다(예전부터 그랬다) — 이제 그 사실을 출처로 표시하고,
146
- * 자원이 있다고 말하는 자리도 구조로 승격한다(로케이션만 자라지 않던 비대칭 해소). */
147
- this.touchLocation(d.location);
148
- const known = this.movers.get(d.moverId);
149
- this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, motion: d.motion, origin: known?.origin ?? 'observed' });
150
- break;
151
- }
152
- case OP_EVENT.person: {
153
- const d = e.data;
154
- if (this.stale(`person:${d.personId}`, e))
155
- return;
156
- /* 사람도 관측으로 자란다(자원과 같은 정책) — 마스터에 없던 사람이 이벤트에 나오면 승격한다. */
157
- this.persons.set(d.personId, {
158
- id: d.personId,
159
- personnelClass: d.personnelClass ?? this.persons.get(d.personId)?.personnelClass,
160
- status: d.status,
161
- taskId: d.taskId,
162
- ...(d.offShift ? { offShift: true } : {})
163
- });
164
- break;
165
- }
166
- case OP_EVENT.asset: {
167
- const d = e.data;
168
- if (this.stale(`asset:${d.assetId}`, e))
169
- return;
170
- const cur = this.assets.get(d.assetId);
171
- this.assets.set(d.assetId, {
172
- id: d.assetId,
173
- assetClass: d.assetClass ?? cur?.assetClass,
174
- location: d.location ?? cur?.location,
175
- status: d.status,
176
- taskId: d.taskId,
177
- ...(d.carrying ? { carrying: d.carrying } : {})
178
- });
179
- this.touchLocation(d.location);
180
- break;
181
- }
182
- case OP_EVENT.order: {
183
- const d = e.data;
184
- if (this.stale(`order:${d.orderId}`, e))
185
- return;
186
- this.orders.set(d.orderId, { id: d.orderId, kind: d.kind, status: d.status, progress: d.requested ? d.fulfilled / d.requested : 0, held: d.held });
187
- break;
188
- }
189
- // 알 수 없는 eventType 은 무시(전방 호환).
190
- }
191
- }
192
- applyEpcis(ev, envelope) {
193
- /* 정정 선언이 붙은 이벤트는 **새 사실이 아니다** — 앞선 이벤트를 취소·수정하는 선언이다.
194
- * 무엇을 어떻게 되돌릴지는 도메인 판단이 필요하므로(원본을 찾아 역적용) 지금은 **상태에 반영하지
195
- * 않는다.** 새 사실로 받아 재고를 흔드는 것보다 반영하지 않는 것이 정직하다. 정정 목록은 남겨
196
- * 소비처가 볼 수 있게 한다(조용히 버리지 않는다). */
197
- if (ev.errorDeclaration) {
198
- this.corrections.push({
199
- declaredAt: String(ev.errorDeclaration.declarationTime ?? ''),
200
- reason: ev.errorDeclaration.reason,
201
- correctiveEventIDs: ev.errorDeclaration.correctiveEventIDs ?? [],
202
- eventID: ev.eventID
203
- });
204
- return;
205
- }
206
- if (ev.type === 'AggregationEvent') {
207
- if (ev.action === 'ADD' && ev.childEPCs?.length) {
208
- this.aggregation.set(ev.parentID, [...ev.childEPCs]);
209
- /* 조립 관계를 물품에도 심는다 — 예전에는 내부 맵에만 두고 밖으로 내보내지 않아, 3D 가
210
- * "이 상자가 어느 팔레트에 실렸나" 를 알 수 없었다. */
211
- for (const child of ev.childEPCs) {
212
- const cur = this.items.get(child);
213
- if (cur)
214
- this.items.set(child, { ...cur, parent: ev.parentID });
215
- else
216
- this.items.set(child, { epc: child, location: '', parent: ev.parentID });
217
- }
218
- }
219
- else if (ev.action === 'DELETE') {
220
- for (const child of this.aggregation.get(ev.parentID) ?? []) {
221
- const cur = this.items.get(child);
222
- if (cur)
223
- this.items.set(child, { ...cur, parent: undefined });
224
- }
225
- this.aggregation.delete(ev.parentID);
226
- }
227
- return;
228
- }
229
- if (ev.type === 'TransactionEvent')
230
- return; // 거래 연결 — 오더 상태는 order.status 델타로
231
- if (ev.type === 'TransformationEvent') {
232
- // 변환: 입력 소비(제거) → 출력 생산(readPoint 에 등장)
233
- for (const epc of ev.inputEPCList ?? [])
234
- this.remove(epc);
235
- const loc = ev.readPoint?.id ?? '';
236
- /* 변환의 마스터데이터는 **출력**에 적용된다(§7.3.8) — 입력에 붙이면 소비되는 것에 태생을 심는 셈. */
237
- this.touchLocation(loc);
238
- for (const epc of ev.outputEPCList ?? []) {
239
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }));
240
- }
241
- return;
242
- }
243
- // ObjectEvent
244
- if (ev.action === 'DELETE') {
245
- for (const epc of ev.epcList)
246
- this.remove(epc);
247
- return;
248
- }
249
- const loc = ev.readPoint?.id;
250
- /* 수량 목록은 **클래스 + 수량 + 단위**를 함께 실어 온다 — 예전에는 클래스만 꺼내고 수량·단위를
251
- * 버렸다. 클래스가 LGTIN 이면 품번과 로트가 그 안에 있으므로 파서로 뜯는다(문자열을 자르지 않는다). */
252
- const q = ev.quantityList?.[0];
253
- this.touchLocation(loc);
254
- for (const epc of ev.epcList) {
255
- /* 물품별 순서 판정 — 늦게 온 옛 관측이 최신 위치를 덮지 않게. */
256
- if (envelope && this.stale(`item:${epc}`, envelope))
257
- continue;
258
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q));
259
- }
260
- /* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
261
- * 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
262
- if (!ev.epcList?.length) {
263
- for (const qe of ev.quantityList ?? []) {
264
- if (qe?.epcClass)
265
- this.items.set(qe.epcClass, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe));
266
- }
267
- }
268
- }
269
- /**
270
- * 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
271
- * 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
272
- */
273
- /**
274
- * 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
275
- *
276
- * 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
277
- * `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
278
- */
279
- expiryOf(ilmd) {
280
- const raw = ilmd?.[ILMD_ATTR.expiry];
281
- if (typeof raw === 'number' && Number.isFinite(raw))
282
- return raw;
283
- if (typeof raw === 'string') {
284
- const t = Date.parse(raw);
285
- if (Number.isFinite(t))
286
- return t;
287
- }
288
- return undefined;
289
- }
290
- mergeItem(epc, patch, q) {
291
- const cur = this.items.get(epc);
292
- /* 클래스는 수량 목록에서 오거나, 물품 자신이 클래스 식별자일 수 있다(비직렬 입고). */
293
- const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : undefined;
294
- const parsedSelf = parseEpc(epc);
295
- /* `gtin` 은 **클래스 URI 원문**이다(오더 매칭이 이 값을 쓴다 — 뜻을 바꾸면 조용히 안 맞는다).
296
- * 파서로 뜯은 품번 키·로트는 **별도 필드**로 얹는다. */
297
- const classUri = q?.epcClass ?? (parsedSelf.instance ? undefined : epc);
298
- return {
299
- epc,
300
- gtin: classUri ?? cur?.gtin,
301
- gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
302
- location: patch.location ?? cur?.location ?? '',
303
- disposition: patch.disposition ?? cur?.disposition,
304
- parent: cur?.parent,
305
- qty: q?.quantity ?? cur?.qty,
306
- uom: q?.uom ?? cur?.uom,
307
- /* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
308
- ilmd: patch.ilmd ?? cur?.ilmd,
309
- expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
310
- /* 로트는 LGTIN(식별자)에서 오지만, 직렬 개체는 마스터데이터에 실려 온다. */
311
- lot: parsedClass?.lot ?? parsedSelf.lot ?? (typeof patch.ilmd?.[ILMD_ATTR.lot] === 'string' ? patch.ilmd[ILMD_ATTR.lot] : undefined) ?? cur?.lot
312
- };
313
- }
314
- /** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
315
- remove(epc) {
316
- this.items.delete(epc);
317
- const children = this.aggregation.get(epc);
318
- if (children) {
319
- this.aggregation.delete(epc);
320
- for (const c of children)
321
- this.remove(c);
322
- }
323
- }
324
- /** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
325
- snapshot() {
326
- const occ = new Map();
327
- for (const it of this.items.values())
328
- occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
329
- return {
330
- revision: this.revision,
331
- ...(this.corrections.length ? { corrections: this.corrections.map(c => ({ ...c })) } : {}),
332
- nodes: [...this.master.values()].map(n => ({
333
- id: n.id, type: n.type, occupancy: occ.get(n.id) ?? 0,
334
- /* 용량 미상은 **키를 만들지 않는다** — 0 으로 실으면 "자리 없음" 이라는 없는 사실이 생긴다. */
335
- ...(n.capacity === undefined ? {} : { capacity: n.capacity }),
336
- ...(n.parentId ? { parentId: n.parentId } : {}),
337
- origin: n.origin
338
- })),
339
- /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
340
- items: [...this.items.values()].map(i => ({ ...i })),
341
- persons: [...this.persons.values()].map(p => ({ ...p })),
342
- assets: [...this.assets.values()].map(a => ({ ...a })),
343
- tasks: [...this.tasks.values()].map(t => ({ ...t })),
344
- movers: [...this.movers.values()].map(m => ({ ...m })),
345
- orders: [...this.orders.values()].map(o => ({ ...o }))
346
- };
347
- }
348
- }
8
+ export { ObservedReducer, ObservedReducer as StateProjector } from "./observed-reducer.js";
@@ -105,7 +105,7 @@ export class YmsKernel extends FlowEngine {
105
105
  return; // 적재 화물 부족 → 대기
106
106
  this.trailerCargo.set(trailer.epc, avail.slice(0, CARGO_PER_TRAILER).map(i => i.epc));
107
107
  }
108
- trailer.disposition = DISP.reserved;
108
+ this.reserve([trailer.epc], bizStep); // 처분 변화를 이벤트로 — 미러가 "잡혔다" 를 알 수 있게
109
109
  this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep, bizTransactionList: [{ type: BTT_DELIVERY, bizTransaction: o.bizTransaction }], epcList: [trailer.epc], readPoint: door.id }));
110
110
  // drop: 야드→도크 pull. live: 게이트→도크 직행(spot-live). 둘 다 toNode=도크도어.
111
111
  const dockKind = mode === 'drop' ? 'pull' : 'spot-live';
@@ -143,7 +143,9 @@ export class YmsKernel extends FlowEngine {
143
143
  const staging = this.nodeByType('staging');
144
144
  const cargo = this.trailerCargo.get(trailer.epc) ?? [];
145
145
  // 도크 도착 — 상·하차 관측. 도어는 depart 완료까지 점유(예약 유지).
146
- this.emit(objectEvent({ eventTime: this.now(), action: 'OBSERVE', bizStep: outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, disposition: DISP.in_progress, epcList: [trailer.epc], readPoint: to.id, bizLocation: to.id }));
146
+ /* 이벤트만 내고 상태를 바꾸면 이벤트와 상태가 다른 말을 한다(할당 잡힌 'reserved'
147
+ * 그대로 남았다). 처분 변경은 한 곳에서 — 상태와 이벤트를 함께. */
148
+ this.observeDisposition([trailer.epc], DISP.in_progress, outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, to.id);
147
149
  if (outbound) {
148
150
  // 상차: staging 화물 → 트레일러 조립(merge). 화물은 staging 이탈(consume → 트레일러 안 opaque, 적재 출차).
149
151
  if (cargo.length && staging) {