@operato/twin-kernel 0.7.37 → 0.7.38

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.
@@ -1397,6 +1397,19 @@ export interface StateSnapshot {
1397
1397
  tasks: TaskState[];
1398
1398
  orders: OrderState[];
1399
1399
  attentions?: Attention[];
1400
+ /**
1401
+ * **담을 줄 몰라 반영하지 못한 사실** — 종류별 수(관측 구동에서만 나온다).
1402
+ *
1403
+ * 관측 리듀서는 오래전부터 이것을 세어 왔다. 그런데 **스냅샷이 그 값을 떨어뜨렸다** — 그래서
1404
+ * 이미 그것을 읽도록 쓰여 있던 정합성 검사(`twin-unhandled-vocabulary`)는 미러에서 영원히
1405
+ * 조용했다(2026-08-19 실측). 세기만 하고 아무도 못 보는 값은 없는 것과 같다.
1406
+ */
1407
+ unhandled?: {
1408
+ eventType: string;
1409
+ count: number;
1410
+ firstAtMs?: number;
1411
+ lastAtMs?: number;
1412
+ }[];
1400
1413
  /**
1401
1414
  * 확인(ack)해 둔 주목 신호 id — **상태에서 파생되지 않는 유일한 축.**
1402
1415
  *
@@ -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,
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.38",
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": {