@operato/twin-kernel 0.7.37 → 0.7.39

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.
@@ -1245,6 +1245,20 @@ export interface Attention {
1245
1245
  /** 계량 지점의 마지막 관측 — 값이 없으면 **모르는 것이다**(0 이 아니다). */
1246
1246
  export interface MeterPointState {
1247
1247
  id: string;
1248
+ /**
1249
+ * 이 계량 지점이 **모델에 선언된 것인가, 계측이 처음 데려온 것인가** (자리와 같은 어휘).
1250
+ *
1251
+ * ── 왜 표시해야 하나 ───────────────────────────────────────────────────────
1252
+ * 계측은 모르는 `meterId` 를 만나면 **조용히 새 지점을 만든다.** 그 kW 는 그 현장의 수요·피크에
1253
+ * 그대로 더해지므로(그것이 옳다 — 빼면 「계약 안쪽」이라는 더 위험한 거짓이 된다), 표시가 없으면
1254
+ * 커넥터 매핑에서 id 를 한 글자 틀린 것과 정상을 구별할 수 없다. 그때 진짜 계량기는 조용히 멈추고
1255
+ * 유령이 누적되며, **숫자는 그럴듯하고 틀린다.** 요금 판정까지 가는 축이라 자리 오타보다 무겁다.
1256
+ *
1257
+ * `'master'` = 모델(설비 선언)에 있는 계량기. `'observed'` = 계측만 데려온 계량기.
1258
+ * 옛 스냅샷에는 이 칸이 없다(`undefined`) — 「선언되었다」로 읽지 말 것. 선언 여부의 정본은 모델이고,
1259
+ * 커널은 표본이 올 때마다 이 값을 다시 맞춘다.
1260
+ */
1261
+ origin?: 'master' | 'observed';
1248
1262
  kW?: number;
1249
1263
  /** 누적 전력량(원천이 준 적산값) — 우리가 적분한 값이 아니다. */
1250
1264
  kWh?: number;
@@ -1265,6 +1279,18 @@ export interface MeterPointState {
1265
1279
  export interface DemandWindowState {
1266
1280
  startMs: number;
1267
1281
  endMs: number;
1282
+ /**
1283
+ * `maxKW` 가 찍힌 **그 순간**의, **모델이 선언한** 뿌리 계량기들만의 합.
1284
+ *
1285
+ * `maxKW` 는 모르는 계량기까지 포함한 합이다(그것을 빼면 부하가 조용히 작아지고, 작아진 부하는
1286
+ * 「계약 안쪽」이라는 더 위험한 거짓을 만든다). 그러나 그 값 하나만 내면 반대쪽 거짓이 생긴다 —
1287
+ * 오타로 생긴 유령 계량기가 계약 초과를 만들어도 화면은 그것을 진짜 초과로 말한다.
1288
+ *
1289
+ * 그래서 **두 벌을 낸다.** 화면은 「선언된 것으로는 82%, 정체 모를 계량기까지 넣으면 104%」라고
1290
+ * 말할 수 있다 — 빼서 정확한 척하지 않고, 더해서 뭉개지도 않는다. 두 값이 같으면 모든 뿌리가
1291
+ * 선언된 것이고, 그것도 사실이다. 잰 적이 없으면 `undefined` 다(0 이 아니다).
1292
+ */
1293
+ maxDeclaredKW?: number;
1268
1294
  /**
1269
1295
  * 이 구간의 **최대가 찍힌 순간**의 지점별 kW — 요금이 걸린 수는 kW 다.
1270
1296
  *
@@ -1397,6 +1423,19 @@ export interface StateSnapshot {
1397
1423
  tasks: TaskState[];
1398
1424
  orders: OrderState[];
1399
1425
  attentions?: Attention[];
1426
+ /**
1427
+ * **담을 줄 몰라 반영하지 못한 사실** — 종류별 수(관측 구동에서만 나온다).
1428
+ *
1429
+ * 관측 리듀서는 오래전부터 이것을 세어 왔다. 그런데 **스냅샷이 그 값을 떨어뜨렸다** — 그래서
1430
+ * 이미 그것을 읽도록 쓰여 있던 정합성 검사(`twin-unhandled-vocabulary`)는 미러에서 영원히
1431
+ * 조용했다(2026-08-19 실측). 세기만 하고 아무도 못 보는 값은 없는 것과 같다.
1432
+ */
1433
+ unhandled?: {
1434
+ eventType: string;
1435
+ count: number;
1436
+ firstAtMs?: number;
1437
+ lastAtMs?: number;
1438
+ }[];
1400
1439
  /**
1401
1440
  * 확인(ack)해 둔 주목 신호 id — **상태에서 파생되지 않는 유일한 축.**
1402
1441
  *
@@ -286,6 +286,20 @@ export class EmsKernel extends FlowEngine {
286
286
  this.openWindow(atMs);
287
287
  const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : undefined;
288
288
  const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
289
+ /*
290
+ * ── 이 계량기가 모델에 있는가를 **표시한다** (2026-08-20) ────────────────────
291
+ *
292
+ * 모르는 `meterId` 는 여기서 조용히 새 지점이 된다. 그 kW 는 수요·피크에 그대로 더해지는데(그것이
293
+ * 옳다 — 아래 뿌리 합 주석), 표시가 없으면 커넥터 매핑의 한 글자 오타와 정상을 구별할 수 없다.
294
+ * 그때 진짜 계량기는 조용히 멈추고 유령이 누적되며, 숫자는 그럴듯하고 틀린다.
295
+ *
296
+ * 바로 옆 `applyEquipmentEnergy` 는 같은 상황을 이미 세어 두고 있었다(`unknownEquipmentReports`).
297
+ * 계량기만 그 규율에서 빠져 있었다 — 요금까지 가는 축이라 오히려 더 무거운 자리다.
298
+ *
299
+ * **표본마다 다시 맞춘다**: 선언 여부의 정본은 모델이고, 모델은 채택(adopt)으로 늘어난다. 한 번
300
+ * 찍고 두면 나중에 선언된 계량기가 영원히 `observed` 로 남는다. 자리(`origin: 'observed'`)와 같은 어휘다.
301
+ */
302
+ point.origin = this.equipment.has(id) ? 'master' : 'observed';
289
303
  /* 늦게 온 옛 표본이 최신 관측을 덮지 않게 — 상위 커널의 `stale` 판정과 같은 규율. */
290
304
  if (point.atMs === undefined || atMs >= point.atMs) {
291
305
  point.atMs = atMs;
@@ -324,9 +338,33 @@ export class EmsKernel extends FlowEngine {
324
338
  * 조용히 작아지고, 작아진 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다.
325
339
  */
326
340
  const roots = this.rootMeterIds();
327
- const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
341
+ /*
342
+ * ── 합을 **두 벌** 낸다 (2026-08-20) ──────────────────────────────────────
343
+ *
344
+ * 전부의 합(`total`)이 요금 판정의 근거다 — 모르는 계량기를 빼면 부하가 조용히 작아지고, 작아진
345
+ * 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다.
346
+ *
347
+ * 그런데 그 값 하나만 내면 반대쪽 거짓이 생긴다: 오타로 생긴 유령 계량기가 만든 초과를 화면이
348
+ * 진짜 초과로 말한다. 그래서 **선언된 뿌리만의 합**도 같은 순간에 잰다. 화면은 두 수를 나란히
349
+ * 놓고 「선언된 것으로는 82%, 정체 모를 계량기까지 넣으면 104%」라고 말할 수 있다.
350
+ *
351
+ * 선언 여부는 **모델에 직접 묻는다**(`this.equipment`). 지점에 찍힌 `origin` 을 쓰지 않는 이유는
352
+ * 옛 스냅샷에서 이어받은 지점에는 그 칸이 없고, 없는 것을 「선언되었다」로 읽으면 이 값이 조용히
353
+ * 전부의 합과 같아지기 때문이다.
354
+ */
355
+ let total = 0;
356
+ let declared = 0;
357
+ for (const p of this.points.values()) {
358
+ if (!roots.has(p.id))
359
+ continue;
360
+ const v = p.kW ?? 0;
361
+ total += v;
362
+ if (this.equipment.has(p.id))
363
+ declared += v;
364
+ }
328
365
  if (w.maxKW === undefined || total > w.maxKW) {
329
366
  w.maxKW = total;
367
+ w.maxDeclaredKW = declared;
330
368
  /*
331
369
  * ── 피크가 갱신된 **그 순간**의 지점별 kW 를 붙잡는다 (2026-08-18) ─────────
332
370
  *
@@ -1086,6 +1086,11 @@ export class FlowEngine {
1086
1086
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
1087
1087
  * 다른 커널을 주입하면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
1088
1088
  * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
1089
+ /* 관측이 담지 못한 사실을 그대로 내보낸다 — 리듀서가 세고 있어도 여기서 떨어뜨리면 아무도 못 본다. */
1090
+ ...(() => {
1091
+ const u = this.observer?.snapshot?.().unhandled;
1092
+ return u?.length ? { unhandled: u } : {};
1093
+ })(),
1089
1094
  tasks: [...this.tasks.values()].map(t => ({
1090
1095
  id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
1091
1096
  fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
@@ -106,6 +106,23 @@ export interface ReducerCheckpoint {
106
106
  child: string;
107
107
  parent: string;
108
108
  }[];
109
+ /** 수량으로 담긴 내용 — 채워 넣은 것과, 팔레트를 아직 못 본 채 보류한 것. */
110
+ qtyAggregation?: {
111
+ parent: string;
112
+ quantities: {
113
+ epcClass?: string;
114
+ quantity?: number;
115
+ uom?: string;
116
+ }[];
117
+ }[];
118
+ pendingQuantities?: {
119
+ parent: string;
120
+ quantities: {
121
+ epcClass?: string;
122
+ quantity?: number;
123
+ uom?: string;
124
+ }[];
125
+ }[];
109
126
  tasks: TaskState[];
110
127
  equipment: EquipmentState[];
111
128
  persons: PersonState[];
@@ -132,6 +149,15 @@ export declare class ObservedReducer {
132
149
  private aggregation;
133
150
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
134
151
  private pendingParent;
152
+ /**
153
+ * 팔레트가 **담고 있는 것**(클래스 + 수량) — 바코드 없이 수량만 실린 담김.
154
+ *
155
+ * · `qtyAggregation` — 우리가 그 팔레트 물품에 채워 넣은 내용(풀릴 때 되돌릴 근거).
156
+ * · `pendingQuantities` — 담김이 먼저 오고 그 팔레트를 아직 관측하지 못한 경우. 물품을 지어내지
157
+ * 않고 보류해 두었다가, 관측되는 순간 붙인다(`pendingParent` 와 같은 규율).
158
+ */
159
+ private qtyAggregation;
160
+ private pendingQuantities;
135
161
  private tasks;
136
162
  private equipment;
137
163
  private persons;
@@ -217,8 +243,21 @@ export declare class ObservedReducer {
217
243
  private observedAtMs?;
218
244
  /** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
219
245
  apply(e: CanonicalEnvelope): void;
220
- /** 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다. */
246
+ /**
247
+ * 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다.
248
+ *
249
+ * `key` 를 주면 그 이름으로 센다: 종류 전체를 못 다룬 것과 **그 종류의 어떤 모양만** 못 다룬 것은
250
+ * 다른 사실이다(예: 담김은 반영하는데 품번이 섞인 팔레트만 못 담는다).
251
+ */
221
252
  private noteUnhandled;
253
+ /**
254
+ * 팔레트 물품에 **담고 있는 것**을 채운다 — 이미 아는 값을 덮지 않는다.
255
+ *
256
+ * 팔레트가 자기 품번·수량을 이미 들고 있으면(관측이 그렇게 말했으면) 담김 선언이 그것을 밀어내지
257
+ * 않는다. 비어 있던 자리만 채운다 — 「모른다」를 채우는 것이 이 작업의 목적이고, 아는 것을 바꾸는
258
+ * 것은 아니다.
259
+ */
260
+ private withContainedQuantity;
222
261
  private applyEpcis;
223
262
  /**
224
263
  * 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
@@ -45,6 +45,15 @@ export class ObservedReducer {
45
45
  aggregation = new Map();
46
46
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
47
47
  pendingParent = new Map(); // 자식 EPC → 부모(물류단위)
48
+ /**
49
+ * 팔레트가 **담고 있는 것**(클래스 + 수량) — 바코드 없이 수량만 실린 담김.
50
+ *
51
+ * · `qtyAggregation` — 우리가 그 팔레트 물품에 채워 넣은 내용(풀릴 때 되돌릴 근거).
52
+ * · `pendingQuantities` — 담김이 먼저 오고 그 팔레트를 아직 관측하지 못한 경우. 물품을 지어내지
53
+ * 않고 보류해 두었다가, 관측되는 순간 붙인다(`pendingParent` 와 같은 규율).
54
+ */
55
+ qtyAggregation = new Map();
56
+ pendingQuantities = new Map();
48
57
  tasks = new Map();
49
58
  equipment = new Map();
50
59
  persons = new Map();
@@ -371,10 +380,16 @@ export class ObservedReducer {
371
380
  this.noteUnhandled(e);
372
381
  }
373
382
  }
374
- /** 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다. */
375
- noteUnhandled(e) {
383
+ /**
384
+ * 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다.
385
+ *
386
+ * `key` 를 주면 그 이름으로 센다: 종류 전체를 못 다룬 것과 **그 종류의 어떤 모양만** 못 다룬 것은
387
+ * 다른 사실이다(예: 담김은 반영하는데 품번이 섞인 팔레트만 못 담는다).
388
+ */
389
+ noteUnhandled(e, key) {
376
390
  const at = Date.parse(String(e.eventTime ?? ''));
377
- const cur = this.unhandled.get(e.eventType) ?? { count: 0 };
391
+ const name = key ?? e.eventType;
392
+ const cur = this.unhandled.get(name) ?? { count: 0 };
378
393
  cur.count++;
379
394
  if (Number.isFinite(at)) {
380
395
  if (cur.firstAtMs === undefined || at < cur.firstAtMs)
@@ -382,7 +397,29 @@ export class ObservedReducer {
382
397
  if (cur.lastAtMs === undefined || at > cur.lastAtMs)
383
398
  cur.lastAtMs = at;
384
399
  }
385
- this.unhandled.set(e.eventType, cur);
400
+ this.unhandled.set(name, cur);
401
+ }
402
+ /**
403
+ * 팔레트 물품에 **담고 있는 것**을 채운다 — 이미 아는 값을 덮지 않는다.
404
+ *
405
+ * 팔레트가 자기 품번·수량을 이미 들고 있으면(관측이 그렇게 말했으면) 담김 선언이 그것을 밀어내지
406
+ * 않는다. 비어 있던 자리만 채운다 — 「모른다」를 채우는 것이 이 작업의 목적이고, 아는 것을 바꾸는
407
+ * 것은 아니다.
408
+ */
409
+ withContainedQuantity(cur, q, all) {
410
+ const parsed = q.epcClass ? parseEpc(q.epcClass) : undefined;
411
+ const declared = all.filter(x => typeof x.quantity === 'number');
412
+ return {
413
+ ...cur,
414
+ gtin: cur.gtin ?? q.epcClass,
415
+ gtinKey: cur.gtinKey ?? parsed?.gtinKey,
416
+ /* 관측이 준 수량이 있으면 그대로 둔다. 없으면(팔레트 하나로 세어 `1` 이던 자리) 내용의 수량이 답이다. */
417
+ qty: cur.qty !== undefined && cur.qty !== 1 ? cur.qty : q.quantity,
418
+ uom: cur.uom ?? q.uom,
419
+ /* 단위가 여럿일 때만 목록을 든다(하나면 `qty`/`uom` 이 무손실로 들고 있다 — 위 `upsert` 와 같은 규율). */
420
+ quantities: cur.quantities ?? (declared.length >= 2 ? declared.map(x => ({ value: x.quantity, ...(x.uom ? { uom: x.uom } : {}) })) : undefined),
421
+ lot: cur.lot ?? parsed?.lot
422
+ };
386
423
  }
387
424
  applyEpcis(ev, envelope) {
388
425
  /* 정정 선언이 붙은 이벤트는 **새 사실이 아니다** — 앞선 이벤트를 취소·수정하는 선언이다.
@@ -399,6 +436,46 @@ export class ObservedReducer {
399
436
  return;
400
437
  }
401
438
  if (ev.type === 'AggregationEvent') {
439
+ /*
440
+ * ── 바코드 없이 **수량만** 실린 담김 (2026-08-19) ────────────────────────
441
+ * 실제 창고는 박스마다 바코드를 붙이지 않는다. 팔레트 하나에 「이 품번 40개」로 입고하는 것이
442
+ * 훨씬 흔하고, 표준도 그 자리를 둔다(`childQuantityList`: 클래스 + 수량).
443
+ *
444
+ * 그런데 여기는 **낱개 자식만** 반영했다. 그래서 미러는 그 팔레트를 `{epc, location, qty: 1}` 로
445
+ * 들었다 — 실측: 품번도 없고 40개도 없다. 화면에는 「팔레트 1개」만 보이고 그 안의 40개는 재고
446
+ * 집계에서 통째로 빠진다. 실 WMS 를 붙이면 바로 부딪히는 자리다.
447
+ *
448
+ * **어떻게 담나**: 시뮬 커널이 같은 입고를 다루는 방식과 맞춘다 — 그쪽은 팔레트 물품 자신에
449
+ * 품번과 수량을 싣는다(`items.set(epc, { epc, gtin, qty … })`). 그래서 여기서도 팔레트 물품에
450
+ * 채운다. 새 필드를 만들지 않으므로 **기존 재고 집계가 그대로 센다**(필드만 만들고 아무도 읽지
451
+ * 않는 상태를 이 프로젝트는 결함으로 본다). 두 구동이 같은 모양을 들게 되는 것이 덤이다.
452
+ *
453
+ * **못 하는 경우는 밝힌다**: 한 팔레트에 품번이 둘 이상이면 이 모양으로 표현할 수 없다(하나를
454
+ * 골라 채우면 나머지를 지우는 것이다). 그때는 채우지 않고 `unhandled` 로 센다 — 조용히 버리지
455
+ * 않는다. 그 어휘를 담으려면 상태에 「담고 있는 것들」이 따로 필요하고, 그것은 별 결정이다.
456
+ */
457
+ if (ev.action === 'ADD' && ev.childQuantityList?.length) {
458
+ const classes = new Set(ev.childQuantityList.map(q => q.epcClass));
459
+ if (classes.size > 1) {
460
+ /* 여러 품번이 한 팔레트에 — 지금 상태로는 표현할 수 없다. 세어서 드러낸다. */
461
+ if (envelope)
462
+ this.noteUnhandled(envelope, 'aggregation-mixed-classes');
463
+ }
464
+ else {
465
+ const q = ev.childQuantityList[0];
466
+ const cur = this.items.get(ev.parentID);
467
+ /*
468
+ * **관측하지 못한 팔레트를 지어내지 않는다** — 담김은 *어디 있는지*를 말하지 않는다.
469
+ * 그 팔레트가 관측되면(ObjectEvent) 그때 위치와 함께 서고, 담김은 보류해 두었다가 붙인다.
470
+ */
471
+ if (!cur)
472
+ this.pendingQuantities.set(ev.parentID, [...ev.childQuantityList]);
473
+ else {
474
+ this.items.set(cur.subLotId ?? cur.epc, this.withContainedQuantity(cur, q, ev.childQuantityList));
475
+ this.qtyAggregation.set(ev.parentID, [...ev.childQuantityList]);
476
+ }
477
+ }
478
+ }
402
479
  if (ev.action === 'ADD' && ev.childEPCs?.length) {
403
480
  this.aggregation.set(ev.parentID, [...ev.childEPCs]);
404
481
  /* 조립 관계를 물품에도 주입한다 — 예전에는 내부 맵에만 두고 밖으로 내보내지 않아, 3D 가
@@ -424,6 +501,16 @@ export class ObservedReducer {
424
501
  this.pendingParent.delete(child);
425
502
  }
426
503
  this.aggregation.delete(ev.parentID);
504
+ /* 수량으로 담겼던 것을 풀면 그 팔레트는 **비게 된다** — 우리가 채운 것만 되돌린다(원래
505
+ 팔레트 자신의 수량이었다면 건드리지 않는다). */
506
+ if (this.qtyAggregation.delete(ev.parentID)) {
507
+ const cur = this.items.get(ev.parentID);
508
+ if (cur) {
509
+ const { gtin, gtinKey, qty, uom, quantities, lot, ...rest } = cur;
510
+ this.items.set(cur.subLotId ?? cur.epc, { ...rest, qty: 1 });
511
+ }
512
+ }
513
+ this.pendingQuantities.delete(ev.parentID);
427
514
  }
428
515
  return;
429
516
  }
@@ -512,7 +599,9 @@ export class ObservedReducer {
512
599
  const parsedSelf = parseEpc(epc);
513
600
  /* `gtin` 은 **클래스 URI 원문**이다(오더 매칭이 이 값을 쓴다 — 뜻을 바꾸면 조용히 안 맞는다).
514
601
  * 파서로 뜯은 품번 키·로트는 **별도 필드**로 얹는다. */
515
- const classUri = q?.epcClass ?? (parsedSelf.instance ? undefined : epc);
602
+ /* 담김이 먼저 팔레트는 그 내용의 클래스가 곧 이 물품의 품번이다(시뮬이 같은 입고를 그렇게 든다). */
603
+ const pendingQty = this.pendingQuantities.get(epc);
604
+ const classUri = q?.epcClass ?? pendingQty?.[0]?.epcClass ?? (parsedSelf.instance ? undefined : epc);
516
605
  return {
517
606
  epc,
518
607
  ...(subLotId ? { subLotId } : {}),
@@ -521,8 +610,12 @@ export class ObservedReducer {
521
610
  location: patch.location ?? cur?.location ?? '',
522
611
  disposition: patch.disposition ?? cur?.disposition,
523
612
  parent: cur?.parent ?? this.pendingParent.get(epc),
524
- qty: q?.quantity ?? cur?.qty,
525
- uom: q?.uom ?? cur?.uom,
613
+ /*
614
+ * 관측이 수량을 말하지 않았는데 **담김이 먼저 와 있었다면** 그 내용이 답이다(바코드 없이 수량만
615
+ * 실린 팔레트). 보류해 둔 것을 여기서 붙인다 — 위 `parent` 와 같은 규율이다.
616
+ */
617
+ qty: q?.quantity ?? this.pendingQuantities.get(epc)?.[0]?.quantity ?? cur?.qty,
618
+ uom: q?.uom ?? this.pendingQuantities.get(epc)?.[0]?.uom ?? cur?.uom,
526
619
  /*
527
620
  * 선언된 수량 전부 — 값이 없는 항목은 담지 않는다(모름을 0 으로 만들지 않는다).
528
621
  *
@@ -656,6 +749,9 @@ export class ObservedReducer {
656
749
  items: [...this.items.values()].map(i => ({ ...i })),
657
750
  aggregation: [...this.aggregation.entries()].map(([parent, children]) => ({ parent, children: [...children] })),
658
751
  pendingParent: [...this.pendingParent.entries()].map(([child, parent]) => ({ child, parent })),
752
+ /* 수량으로 담긴 것도 이어받는다 — 되풀어 주지 않는 축이다(원천은 담김을 다시 말해 주지 않는다). */
753
+ qtyAggregation: [...this.qtyAggregation.entries()].map(([parent, quantities]) => ({ parent, quantities })),
754
+ pendingQuantities: [...this.pendingQuantities.entries()].map(([parent, quantities]) => ({ parent, quantities })),
659
755
  tasks: [...this.tasks.values()].map(t => ({ ...t })),
660
756
  equipment: [...this.equipment.values()].map(m => ({ ...m })),
661
757
  persons: [...this.persons.values()].map(x => ({ ...x })),
@@ -677,6 +773,8 @@ export class ObservedReducer {
677
773
  this.items = new Map((cp?.items ?? []).map(i => [i.epc, { ...i }]));
678
774
  this.aggregation = new Map((cp?.aggregation ?? []).map(a => [a.parent, [...a.children]]));
679
775
  this.pendingParent = new Map((cp?.pendingParent ?? []).map(x => [x.child, x.parent]));
776
+ this.qtyAggregation = new Map((cp?.qtyAggregation ?? []).map(x => [x.parent, x.quantities]));
777
+ this.pendingQuantities = new Map((cp?.pendingQuantities ?? []).map(x => [x.parent, x.quantities]));
680
778
  this.tasks = new Map((cp?.tasks ?? []).map(t => [t.id, { ...t }]));
681
779
  this.equipment = new Map((cp?.equipment ?? []).map(m => [m.id, { ...m }]));
682
780
  this.persons = new Map((cp?.persons ?? []).map(x => [x.id, { ...x }]));
@@ -1091,6 +1091,15 @@ var ObservedReducer = class {
1091
1091
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
1092
1092
  pendingParent = /* @__PURE__ */ new Map();
1093
1093
  // 자식 EPC → 부모(물류단위)
1094
+ /**
1095
+ * 팔레트가 **담고 있는 것**(클래스 + 수량) — 바코드 없이 수량만 실린 담김.
1096
+ *
1097
+ * · `qtyAggregation` — 우리가 그 팔레트 물품에 채워 넣은 내용(풀릴 때 되돌릴 근거).
1098
+ * · `pendingQuantities` — 담김이 먼저 오고 그 팔레트를 아직 관측하지 못한 경우. 물품을 지어내지
1099
+ * 않고 보류해 두었다가, 관측되는 순간 붙인다(`pendingParent` 와 같은 규율).
1100
+ */
1101
+ qtyAggregation = /* @__PURE__ */ new Map();
1102
+ pendingQuantities = /* @__PURE__ */ new Map();
1094
1103
  tasks = /* @__PURE__ */ new Map();
1095
1104
  equipment = /* @__PURE__ */ new Map();
1096
1105
  persons = /* @__PURE__ */ new Map();
@@ -1381,16 +1390,44 @@ var ObservedReducer = class {
1381
1390
  this.noteUnhandled(e);
1382
1391
  }
1383
1392
  }
1384
- /** 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다. */
1385
- noteUnhandled(e) {
1393
+ /**
1394
+ * 반영하지 못한 사건을 종류별로 센다 — 처음·마지막 시각을 함께 남겨 「언제부터」에 답한다.
1395
+ *
1396
+ * `key` 를 주면 그 이름으로 센다: 종류 전체를 못 다룬 것과 **그 종류의 어떤 모양만** 못 다룬 것은
1397
+ * 다른 사실이다(예: 담김은 반영하는데 품번이 섞인 팔레트만 못 담는다).
1398
+ */
1399
+ noteUnhandled(e, key) {
1386
1400
  const at = Date.parse(String(e.eventTime ?? ""));
1387
- const cur = this.unhandled.get(e.eventType) ?? { count: 0 };
1401
+ const name = key ?? e.eventType;
1402
+ const cur = this.unhandled.get(name) ?? { count: 0 };
1388
1403
  cur.count++;
1389
1404
  if (Number.isFinite(at)) {
1390
1405
  if (cur.firstAtMs === void 0 || at < cur.firstAtMs) cur.firstAtMs = at;
1391
1406
  if (cur.lastAtMs === void 0 || at > cur.lastAtMs) cur.lastAtMs = at;
1392
1407
  }
1393
- this.unhandled.set(e.eventType, cur);
1408
+ this.unhandled.set(name, cur);
1409
+ }
1410
+ /**
1411
+ * 팔레트 물품에 **담고 있는 것**을 채운다 — 이미 아는 값을 덮지 않는다.
1412
+ *
1413
+ * 팔레트가 자기 품번·수량을 이미 들고 있으면(관측이 그렇게 말했으면) 담김 선언이 그것을 밀어내지
1414
+ * 않는다. 비어 있던 자리만 채운다 — 「모른다」를 채우는 것이 이 작업의 목적이고, 아는 것을 바꾸는
1415
+ * 것은 아니다.
1416
+ */
1417
+ withContainedQuantity(cur, q, all) {
1418
+ const parsed = q.epcClass ? parseEpc(q.epcClass) : void 0;
1419
+ const declared = all.filter((x) => typeof x.quantity === "number");
1420
+ return {
1421
+ ...cur,
1422
+ gtin: cur.gtin ?? q.epcClass,
1423
+ gtinKey: cur.gtinKey ?? parsed?.gtinKey,
1424
+ /* 관측이 준 수량이 있으면 그대로 둔다. 없으면(팔레트 하나로 세어 `1` 이던 자리) 내용의 수량이 답이다. */
1425
+ qty: cur.qty !== void 0 && cur.qty !== 1 ? cur.qty : q.quantity,
1426
+ uom: cur.uom ?? q.uom,
1427
+ /* 단위가 여럿일 때만 목록을 든다(하나면 `qty`/`uom` 이 무손실로 들고 있다 — 위 `upsert` 와 같은 규율). */
1428
+ quantities: cur.quantities ?? (declared.length >= 2 ? declared.map((x) => ({ value: x.quantity, ...x.uom ? { uom: x.uom } : {} })) : void 0),
1429
+ lot: cur.lot ?? parsed?.lot
1430
+ };
1394
1431
  }
1395
1432
  applyEpcis(ev, envelope) {
1396
1433
  if (ev.errorDeclaration) {
@@ -1403,6 +1440,20 @@ var ObservedReducer = class {
1403
1440
  return;
1404
1441
  }
1405
1442
  if (ev.type === "AggregationEvent") {
1443
+ if (ev.action === "ADD" && ev.childQuantityList?.length) {
1444
+ const classes = new Set(ev.childQuantityList.map((q2) => q2.epcClass));
1445
+ if (classes.size > 1) {
1446
+ if (envelope) this.noteUnhandled(envelope, "aggregation-mixed-classes");
1447
+ } else {
1448
+ const q2 = ev.childQuantityList[0];
1449
+ const cur = this.items.get(ev.parentID);
1450
+ if (!cur) this.pendingQuantities.set(ev.parentID, [...ev.childQuantityList]);
1451
+ else {
1452
+ this.items.set(cur.subLotId ?? cur.epc, this.withContainedQuantity(cur, q2, ev.childQuantityList));
1453
+ this.qtyAggregation.set(ev.parentID, [...ev.childQuantityList]);
1454
+ }
1455
+ }
1456
+ }
1406
1457
  if (ev.action === "ADD" && ev.childEPCs?.length) {
1407
1458
  this.aggregation.set(ev.parentID, [...ev.childEPCs]);
1408
1459
  for (const child of ev.childEPCs) {
@@ -1420,6 +1471,14 @@ var ObservedReducer = class {
1420
1471
  this.pendingParent.delete(child);
1421
1472
  }
1422
1473
  this.aggregation.delete(ev.parentID);
1474
+ if (this.qtyAggregation.delete(ev.parentID)) {
1475
+ const cur = this.items.get(ev.parentID);
1476
+ if (cur) {
1477
+ const { gtin, gtinKey, qty, uom, quantities, lot, ...rest } = cur;
1478
+ this.items.set(cur.subLotId ?? cur.epc, { ...rest, qty: 1 });
1479
+ }
1480
+ }
1481
+ this.pendingQuantities.delete(ev.parentID);
1423
1482
  }
1424
1483
  return;
1425
1484
  }
@@ -1477,7 +1536,8 @@ var ObservedReducer = class {
1477
1536
  const cur = this.items.get(subLotId ?? epc);
1478
1537
  const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : void 0;
1479
1538
  const parsedSelf = parseEpc(epc);
1480
- const classUri = q?.epcClass ?? (parsedSelf.instance ? void 0 : epc);
1539
+ const pendingQty = this.pendingQuantities.get(epc);
1540
+ const classUri = q?.epcClass ?? pendingQty?.[0]?.epcClass ?? (parsedSelf.instance ? void 0 : epc);
1481
1541
  return {
1482
1542
  epc,
1483
1543
  ...subLotId ? { subLotId } : {},
@@ -1486,8 +1546,12 @@ var ObservedReducer = class {
1486
1546
  location: patch.location ?? cur?.location ?? "",
1487
1547
  disposition: patch.disposition ?? cur?.disposition,
1488
1548
  parent: cur?.parent ?? this.pendingParent.get(epc),
1489
- qty: q?.quantity ?? cur?.qty,
1490
- uom: q?.uom ?? cur?.uom,
1549
+ /*
1550
+ * 관측이 수량을 말하지 않았는데 **담김이 먼저 와 있었다면** 그 내용이 답이다(바코드 없이 수량만
1551
+ * 실린 팔레트). 보류해 둔 것을 여기서 붙인다 — 위 `parent` 와 같은 규율이다.
1552
+ */
1553
+ qty: q?.quantity ?? this.pendingQuantities.get(epc)?.[0]?.quantity ?? cur?.qty,
1554
+ uom: q?.uom ?? this.pendingQuantities.get(epc)?.[0]?.uom ?? cur?.uom,
1491
1555
  /*
1492
1556
  * 선언된 수량 전부 — 값이 없는 항목은 담지 않는다(모름을 0 으로 만들지 않는다).
1493
1557
  *
@@ -1615,6 +1679,9 @@ var ObservedReducer = class {
1615
1679
  items: [...this.items.values()].map((i) => ({ ...i })),
1616
1680
  aggregation: [...this.aggregation.entries()].map(([parent, children]) => ({ parent, children: [...children] })),
1617
1681
  pendingParent: [...this.pendingParent.entries()].map(([child, parent]) => ({ child, parent })),
1682
+ /* 수량으로 담긴 것도 이어받는다 — 되풀어 주지 않는 축이다(원천은 담김을 다시 말해 주지 않는다). */
1683
+ qtyAggregation: [...this.qtyAggregation.entries()].map(([parent, quantities]) => ({ parent, quantities })),
1684
+ pendingQuantities: [...this.pendingQuantities.entries()].map(([parent, quantities]) => ({ parent, quantities })),
1618
1685
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
1619
1686
  equipment: [...this.equipment.values()].map((m) => ({ ...m })),
1620
1687
  persons: [...this.persons.values()].map((x) => ({ ...x })),
@@ -1636,6 +1703,8 @@ var ObservedReducer = class {
1636
1703
  this.items = new Map((cp?.items ?? []).map((i) => [i.epc, { ...i }]));
1637
1704
  this.aggregation = new Map((cp?.aggregation ?? []).map((a) => [a.parent, [...a.children]]));
1638
1705
  this.pendingParent = new Map((cp?.pendingParent ?? []).map((x) => [x.child, x.parent]));
1706
+ this.qtyAggregation = new Map((cp?.qtyAggregation ?? []).map((x) => [x.parent, x.quantities]));
1707
+ this.pendingQuantities = new Map((cp?.pendingQuantities ?? []).map((x) => [x.parent, x.quantities]));
1639
1708
  this.tasks = new Map((cp?.tasks ?? []).map((t) => [t.id, { ...t }]));
1640
1709
  this.equipment = new Map((cp?.equipment ?? []).map((m) => [m.id, { ...m }]));
1641
1710
  this.persons = new Map((cp?.persons ?? []).map((x) => [x.id, { ...x }]));
@@ -3916,6 +3985,11 @@ var FlowEngine = class {
3916
3985
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
3917
3986
  * 다른 커널을 주입하면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
3918
3987
  * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
3988
+ /* 관측이 담지 못한 사실을 그대로 내보낸다 — 리듀서가 세고 있어도 여기서 떨어뜨리면 아무도 못 본다. */
3989
+ ...(() => {
3990
+ const u = this.observer?.snapshot?.().unhandled;
3991
+ return u?.length ? { unhandled: u } : {};
3992
+ })(),
3919
3993
  tasks: [...this.tasks.values()].map((t) => ({
3920
3994
  id: t.id,
3921
3995
  kind: t.kind,
@@ -6606,6 +6680,7 @@ var EmsKernel = class extends FlowEngine {
6606
6680
  this.openWindow(atMs);
6607
6681
  const kW = Number.isFinite(Number(d?.kW)) ? Number(d.kW) : void 0;
6608
6682
  const point = this.points.get(id) ?? { id, samplesInWindow: 0 };
6683
+ point.origin = this.equipment.has(id) ? "master" : "observed";
6609
6684
  if (point.atMs === void 0 || atMs >= point.atMs) {
6610
6685
  point.atMs = atMs;
6611
6686
  if (kW !== void 0) point.kW = kW;
@@ -6618,9 +6693,17 @@ var EmsKernel = class extends FlowEngine {
6618
6693
  const w = this.open;
6619
6694
  if (kW !== void 0) {
6620
6695
  const roots = this.rootMeterIds();
6621
- const total = [...this.points.values()].reduce((sum, p) => sum + (roots.has(p.id) ? p.kW ?? 0 : 0), 0);
6696
+ let total = 0;
6697
+ let declared = 0;
6698
+ for (const p of this.points.values()) {
6699
+ if (!roots.has(p.id)) continue;
6700
+ const v = p.kW ?? 0;
6701
+ total += v;
6702
+ if (this.equipment.has(p.id)) declared += v;
6703
+ }
6622
6704
  if (w.maxKW === void 0 || total > w.maxKW) {
6623
6705
  w.maxKW = total;
6706
+ w.maxDeclaredKW = declared;
6624
6707
  w.pointsAtPeak = [...this.points.values()].filter((p) => Number.isFinite(Number(p.kW))).map((p) => ({ id: p.id, kW: Number(p.kW) }));
6625
6708
  }
6626
6709
  w.samples++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.37",
3
+ "version": "0.7.39",
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": {