@operato/twin-kernel 0.11.24 → 0.11.25

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.
@@ -4,6 +4,7 @@ import type { VocabularyElement } from '@operato/ops-contract';
4
4
  import type { ReducerCheckpoint } from './observed-reducer.ts';
5
5
  import type { EpcisEvent, BizTransactionElement } from '@operato/ops-contract';
6
6
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
7
+ import { type FoldedOrders, type OrderFoldPolicy } from '@operato/ops-contract';
7
8
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
8
9
  import type { OperationDef, IsoDuration, OpMaterialSpecification } from '@operato/ops-contract';
9
10
  import { type CapacityAnalysis } from '@operato/ops-contract';
@@ -290,6 +291,8 @@ export interface FlowOrder {
290
291
  picked: string[];
291
292
  gtin?: string;
292
293
  shipmentEpc?: string | null;
294
+ /** 언제 끝났나(ms) — 끝났다고 사건을 낸 시각. 끝난 오더를 접는 경계가 이 값으로 잰다(ADR-0092). */
295
+ terminalAtMs?: number;
293
296
  lines?: OrderLine[];
294
297
  held?: boolean;
295
298
  dockDoor?: string;
@@ -568,6 +571,10 @@ export declare abstract class FlowEngine implements TwinKernel {
568
571
  assets: Map<string, FlowAsset>;
569
572
  tasks: Map<string, FlowTask>;
570
573
  orders: Map<string, FlowOrder>;
574
+ /** 접힌 오더(ADR-0092) — 세는 수와 경계만 든다. 미러는 리듀서가 들고 이것은 옮겨 받는다(§`hydrateObserved`). */
575
+ protected foldedOrders: FoldedOrders;
576
+ /** 끝난 오더를 언제 접나 — 모델의 `orderFold` 선언. 없으면 기본(24시간 · 1,000건). */
577
+ protected orderFoldPolicy: OrderFoldPolicy;
571
578
  revision: number;
572
579
  clockMs: number;
573
580
  /**
@@ -633,6 +640,16 @@ export declare abstract class FlowEngine implements TwinKernel {
633
640
  *
634
641
  * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
635
642
  */
643
+ /**
644
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
645
+ *
646
+ * 부르는 것은 호스트다 — 체크포인트(미러는 `observedCheckpoint`, 시뮬은 상태 스냅샷)를 쓰기 직전에 부른다. 그래야
647
+ * 접힌 쪽이 씨앗과 같은 때에 적힌다. 커널이 스스로 접지 않는 것은, 접힌 목록을 읽는 쪽(예측 · 화면)이 그 뜻에
648
+ * 맞춰진 뒤에 호스트가 부르게 하려는 것이다.
649
+ *
650
+ * 미러는 원천인 리듀서에서 접고 옮겨 받는다 — 이 목록에서만 지우면 다음 옮김이 되살린다.
651
+ */
652
+ foldFinishedOrders(nowMs?: number): number;
636
653
  observedCheckpoint(): ReducerCheckpoint | undefined;
637
654
  /**
638
655
  * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
@@ -837,6 +854,8 @@ export declare abstract class FlowEngine implements TwinKernel {
837
854
  assets?: AssetState[];
838
855
  tasks?: TaskState[];
839
856
  orders?: OrderState[];
857
+ /** 접힌 오더(ADR-0092) — 세는 수와 경계. 이어받지 않으면 재기동 · fork 뒤에 끝난 오더의 수를 잃는다. */
858
+ foldedOrders?: FoldedOrders;
840
859
  /** 확인해 둔 주목 신호 id — 계산으로 되살릴 수 없어 스냅샷에서 이어받는다. */
841
860
  acked?: string[];
842
861
  /** 주목 신호가 처음 성립한 시각 — 이어받지 않으면 지속된 조건이 「방금」으로 되살아난다. */
@@ -17,6 +17,7 @@ import { resolveSubject } from '@operato/ops-contract';
17
17
  import { WMS_ORDER_KIND } from '@operato/ops-contract';
18
18
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from '@operato/ops-contract';
19
19
  import { allocationPolicyOf } from "./allocation-policy.js";
20
+ import { ORDER_FOLD_DEFAULT, emptyFoldedOrders, foldOrdersInto, foldedOrderCount, orderFoldPolicyOf, planOrderFold } from '@operato/ops-contract';
20
21
  import { OP_PARAM } from '@operato/ops-contract';
21
22
  import { parseIsoDuration } from '@operato/ops-contract';
22
23
  import { analyzeCapacity } from '@operato/ops-contract';
@@ -630,6 +631,10 @@ export class FlowEngine {
630
631
  assets = new Map();
631
632
  tasks = new Map();
632
633
  orders = new Map();
634
+ /** 접힌 오더(ADR-0092) — 세는 수와 경계만 든다. 미러는 리듀서가 들고 이것은 옮겨 받는다(§`hydrateObserved`). */
635
+ foldedOrders = emptyFoldedOrders();
636
+ /** 끝난 오더를 언제 접나 — 모델의 `orderFold` 선언. 없으면 기본(24시간 · 1,000건). */
637
+ orderFoldPolicy = ORDER_FOLD_DEFAULT;
633
638
  revision = 0;
634
639
  clockMs = 0;
635
640
  /**
@@ -695,6 +700,33 @@ export class FlowEngine {
695
700
  *
696
701
  * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
697
702
  */
703
+ /**
704
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
705
+ *
706
+ * 부르는 것은 호스트다 — 체크포인트(미러는 `observedCheckpoint`, 시뮬은 상태 스냅샷)를 쓰기 직전에 부른다. 그래야
707
+ * 접힌 쪽이 씨앗과 같은 때에 적힌다. 커널이 스스로 접지 않는 것은, 접힌 목록을 읽는 쪽(예측 · 화면)이 그 뜻에
708
+ * 맞춰진 뒤에 호스트가 부르게 하려는 것이다.
709
+ *
710
+ * 미러는 원천인 리듀서에서 접고 옮겨 받는다 — 이 목록에서만 지우면 다음 옮김이 되살린다.
711
+ */
712
+ foldFinishedOrders(nowMs) {
713
+ if (this.observeMode && this.observer) {
714
+ const n = this.observer.foldFinishedOrders(nowMs);
715
+ if (n) {
716
+ this.observedDirty = true;
717
+ this.settleObserved();
718
+ }
719
+ return n;
720
+ }
721
+ const ids = planOrderFold([...this.orders.values()], nowMs ?? this.nowMs(), this.orderFoldPolicy);
722
+ if (!ids.length)
723
+ return 0;
724
+ this.foldedOrders = foldOrdersInto(this.foldedOrders, ids.map(id => this.orders.get(id)).filter(Boolean));
725
+ for (const id of ids)
726
+ this.orders.delete(id);
727
+ this.revision++;
728
+ return ids.length;
729
+ }
698
730
  observedCheckpoint() {
699
731
  /*
700
732
  * 관측 구동인데 리듀서가 아직 없으면 만든다 (2026-08-27).
@@ -825,6 +857,11 @@ export class FlowEngine {
825
857
  */
826
858
  if (def.allocationPolicy)
827
859
  this.policy = allocationPolicyOf(def.allocationPolicy);
860
+ /* 끝난 오더를 접는 경계 — 0 · 무한은 거절한다. 접지 않는 선언은 끝난 오더가 끝없이 쌓이던 때로 돌아간다(ADR-0092). */
861
+ const fold = orderFoldPolicyOf(def.orderFold);
862
+ if ('error' in fold)
863
+ throw new Error(fold.error);
864
+ this.orderFoldPolicy = fold.policy;
828
865
  /*
829
866
  * **생산 선언은 어느 커널이든 싣는다.** 예전에는 MES 커널만 생성자에서 이것을 실었고(그 시절
830
867
  * 이름은 `mesSpec` 이었다), 그래서 창고 트윈은 "자재를 소비해 자재를 산출하는 공정" 을 선언할
@@ -1281,6 +1318,19 @@ export class FlowEngine {
1281
1318
  *
1282
1319
  * 지어내지 않는다 — 없는 물품을 만들어 채우면 그 뒤 계보와 재고가 전부 거짓 위에 선다.
1283
1320
  */
1321
+ /* 접힌 쪽과 종결 시각을 이어받는다(ADR-0092). 종결 시각은 델타에 없으므로 스냅샷의 오더에서 찾는다. */
1322
+ this.foldedOrders = snap.foldedOrders ? structuredClone(snap.foldedOrders) : emptyFoldedOrders();
1323
+ const terminalAtOf = new Map((snap.orders ?? []).map(o => [o.id, o.terminalAtMs]));
1324
+ /*
1325
+ * **관측이면 원천에 없는 오더를 지운다.** 이 목록은 리듀서에서 옮겨 받는 것이라, 리듀서가 접은 오더를 여기 남기면
1326
+ * 다시는 사라지지 않는다. 씨앗(웜스타트 · fork)은 새로 세우는 커널이라 지울 것이 없다.
1327
+ */
1328
+ if (purpose === 'observe') {
1329
+ const present = new Set(observedOrders.map(o => o.orderId));
1330
+ for (const id of [...this.orders.keys()])
1331
+ if (!present.has(id))
1332
+ this.orders.delete(id);
1333
+ }
1284
1334
  for (const o of observedOrders) {
1285
1335
  const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
1286
1336
  /* **라인이 없는 오더를 조용히 빼지 않는다.** 예전에는 남은 양을 라인에서만 셌으므로, 품목 라인
@@ -1341,7 +1391,9 @@ export class FlowEngine {
1341
1391
  /* 약속해 둔 자리·시각창을 이어받는다 — 없으면 도메인이 「정해 뒀다」고 믿는 것을 잃는다.
1342
1392
  사실이 말해 주지 않으면(옛 저널) 비는 것이 사실이다 — 지어내지 않는다. */
1343
1393
  ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
1344
- ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
1394
+ ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {}),
1395
+ /* 언제 끝났나 — 관측만 이어받는다(씨앗은 끝난 오더를 심지 않는다). */
1396
+ ...(observing && terminalAtOf.get(o.orderId) !== undefined ? { terminalAtMs: terminalAtOf.get(o.orderId) } : {})
1345
1397
  });
1346
1398
  }
1347
1399
  /* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
@@ -1831,8 +1883,11 @@ export class FlowEngine {
1831
1883
  ...(t.startTime ? { startTime: t.startTime } : {}),
1832
1884
  ...(t.endTime ? { endTime: t.endTime } : {})
1833
1885
  })),
1886
+ /* 접힌 오더 — 접은 것이 없으면 싣지 않는다(ADR-0092). */
1887
+ ...(foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {}),
1834
1888
  orders: [...this.orders.values()].map(o => ({
1835
1889
  id: o.id, kind: o.kind, status: o.status,
1890
+ ...(o.terminalAtMs !== undefined ? { terminalAtMs: o.terminalAtMs } : {}),
1836
1891
  progress: o.requested ? o.fulfilled / o.requested : 0,
1837
1892
  requested: o.requested, fulfilled: o.fulfilled,
1838
1893
  /*
@@ -3007,6 +3062,15 @@ export class FlowEngine {
3007
3062
  }
3008
3063
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
3009
3064
  emitOrder(o) {
3065
+ /*
3066
+ * **언제 끝났나를 여기서 적는다** — 오더의 상태를 바꾸는 자리는 여럿이지만 사건을 내는 문은 이것 하나다.
3067
+ * 끝났던 오더는 처음 끝난 시각을 지키고, 다시 열리면 지운다. 미러는 같은 값을 그 사건의 `eventTime` 에서
3068
+ * 되세운다(델타에 싣지 않는다 — 두 구동이 같은 시각을 든다).
3069
+ */
3070
+ if (isOrderTerminal(o))
3071
+ o.terminalAtMs ??= this.nowMs();
3072
+ else
3073
+ delete o.terminalAtMs;
3010
3074
  this.emitOp(OP_EVENT.order, {
3011
3075
  orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held,
3012
3076
  ...(o.priority !== undefined ? { priority: o.priority } : {}),
@@ -1,4 +1,5 @@
1
1
  import type { AssetState, TestResult, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift, DispositionFact } from '@operato/ops-contract';
2
+ import { type FoldedOrders } from '@operato/ops-contract';
2
3
  import type { MaterialLotUse } from '@operato/ops-contract';
3
4
  import { type VocabularyElement } from '@operato/ops-contract';
4
5
  interface ProjItem {
@@ -103,6 +104,8 @@ export interface ProjectedState {
103
104
  tasks: TaskState[];
104
105
  equipment: EquipmentState[];
105
106
  orders: OrderState[];
107
+ /** 접힌 오더(ADR-0092) — 접은 것이 없으면 없다. */
108
+ foldedOrders?: FoldedOrders;
106
109
  /**
107
110
  * 사람이 확인(ack)해 둔 주목 신호 id — **파생될 수 없는 유일한 축**이라 저널에서 되살린다.
108
111
  *
@@ -159,6 +162,8 @@ export interface ReducerCheckpoint {
159
162
  persons: PersonState[];
160
163
  assets: AssetState[];
161
164
  orders: OrderState[];
165
+ /** 접힌 오더(ADR-0092) — 옛 저장본에는 없다(없으면 접은 것이 없는 것이다). */
166
+ foldedOrders?: FoldedOrders;
162
167
  acked: string[];
163
168
  corrections: {
164
169
  declaredAt: string;
@@ -267,6 +272,8 @@ export declare class ObservedReducer {
267
272
  private persons;
268
273
  private assets;
269
274
  private orders;
275
+ private foldedOrders;
276
+ private orderFoldPolicy;
270
277
  /** 확인해 둔 주목 신호 id — `attention.acked` 이벤트로만 들어온다(계산으로 만들지 않는다). */
271
278
  private acked;
272
279
  revision: number;
@@ -518,6 +525,14 @@ export declare class ObservedReducer {
518
525
  * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
519
526
  */
520
527
  restore(cp: ReducerCheckpoint): void;
528
+ /**
529
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
530
+ *
531
+ * 부르는 것은 호스트다: 체크포인트를 쓰기 직전에 부른다. 그래야 접힌 쪽이 씨앗과 같은 때에 적혀 재기동 동치
532
+ * (`재개점 + 꼬리 == 0부터 접기`)가 선다. 「지금」은 트윈의 지금(마지막으로 들은 시각)이다 — 벽시계가 아니다.
533
+ * 아직 아무것도 못 들었으면 접지 않는다.
534
+ */
535
+ foldFinishedOrders(nowMs?: number | undefined): number;
521
536
  snapshot(): ProjectedState;
522
537
  }
523
538
  export {};
@@ -22,6 +22,7 @@
22
22
  import { computeOee, isWorkShiftExecutionBasis } from '@operato/ops-contract';
23
23
  import { OP_EVENT, capabilityOf, itemKeyOf, judgeAgainstSpec, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from '@operato/ops-contract';
24
24
  import { ILMD_ATTR, parseEpc } from '@operato/ops-contract';
25
+ import { emptyFoldedOrders, foldOrdersInto, foldedOrderCount, isOrderTerminal, orderFoldPolicyOf, planOrderFold, standsNewOrderRow } from '@operato/ops-contract';
25
26
  import { expiryFromAttributes, lotFromAttributes } from '@operato/ops-contract';
26
27
  /* 클래스 마스터 표는 상태를 들므로 커널에 있다(§`class-master-table`). */
27
28
  import { ClassMasterTable } from "./class-master-table.js";
@@ -86,6 +87,12 @@ export class ObservedReducer {
86
87
  persons = new Map();
87
88
  assets = new Map();
88
89
  orders = new Map();
90
+ /*
91
+ * **접힌 오더** — 끝나서 뜨거운 목록에서 걷힌 것의 세는 수와 경계(ADR-0092). 접는 때는 호스트가 정한다
92
+ * (체크포인트를 쓰기 직전에 `foldFinishedOrders` 를 부른다). 경계는 모델의 `orderFold` 선언이다.
93
+ */
94
+ foldedOrders = emptyFoldedOrders();
95
+ orderFoldPolicy;
89
96
  /** 확인해 둔 주목 신호 id — `attention.acked` 이벤트로만 들어온다(계산으로 만들지 않는다). */
90
97
  acked = new Set();
91
98
  revision = 0;
@@ -119,6 +126,11 @@ export class ObservedReducer {
119
126
  /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
120
127
  utcOffsetMinutes;
121
128
  constructor(model) {
129
+ /* 접지 않는 선언(0 · 무한)은 거절한다 — 기본값으로 조용히 바꾸면 선언한 사람은 자기 값이 쓰이는 줄 안다. */
130
+ const fold = orderFoldPolicyOf(model.orderFold);
131
+ if ('error' in fold)
132
+ throw new Error(fold.error);
133
+ this.orderFoldPolicy = fold.policy;
122
134
  this.utcOffsetMinutes = model.utcOffsetMinutes;
123
135
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
124
136
  /* 선언된 판정 기준 — 원천이 판정하지 않은 결과를 커널이 판정할 때 쓴다(§`judgeAgainstSpec`). */
@@ -711,11 +723,26 @@ export class ObservedReducer {
711
723
  const d = e.data;
712
724
  if (this.stale(`order:${d.orderId}`, e))
713
725
  return;
726
+ const prev = this.orders.get(d.orderId);
727
+ const at = Date.parse(String(e.eventTime ?? ''));
728
+ /*
729
+ * **접힌 오더의 옛 종결이 다시 오면 줄을 세우지 않는다**(ADR-0092 결정 2). 폴링 원본은 같은 종결을 다시
730
+ * 보낸다 — 줄을 세우면 다음 경계에서 또 접혀 세는 수가 두 번 는다. 레코드 자신의 시각으로 가른다(도착
731
+ * 시각이 아니다). 순서 판정에 적힌 이 오더의 시각도 지운다 — 남기면 그 맵이 오더 수만큼 다시 자란다.
732
+ */
733
+ if (!prev && !standsNewOrderRow(d, Number.isFinite(at) ? at : undefined, this.foldedOrders)) {
734
+ this.lastAt.delete(`order:${d.orderId}`);
735
+ return;
736
+ }
737
+ /* 언제 끝났나 — 끝났던 오더는 처음 끝난 시각을 지킨다. 끝나지 않았으면(재개 포함) 없다. */
738
+ const terminal = isOrderTerminal({ status: d.status, requested: d.requested, fulfilled: d.fulfilled });
739
+ const terminalAtMs = terminal ? (prev?.terminalAtMs ?? (Number.isFinite(at) ? at : undefined)) : undefined;
714
740
  /* **진척으로 압축하지 않는다.** 예전에는 progress 만 남겨서, 미러에서 예측을 세우려면 저널을
715
741
  * 따로 읽어 오더 원값을 되찾아야 했다. 진척은 요청·이행에서 나오는 파생이다 — 파생을 남기고
716
742
  * 원본을 버리면 남은 데맨드를 재계획할 수 없다. */
717
743
  this.orders.set(d.orderId, {
718
744
  id: d.orderId, kind: d.kind, status: d.status,
745
+ ...(terminalAtMs !== undefined ? { terminalAtMs } : {}),
719
746
  progress: d.requested ? d.fulfilled / d.requested : 0,
720
747
  requested: d.requested, fulfilled: d.fulfilled,
721
748
  ...(d.lines?.length ? { lines: d.lines.map(l => ({ ...l })) } : {}),
@@ -1332,6 +1359,8 @@ export class ObservedReducer {
1332
1359
  persons: [...this.persons.values()].map(x => ({ ...x })),
1333
1360
  assets: [...this.assets.values()].map(x => ({ ...x })),
1334
1361
  orders: [...this.orders.values()].map(o => ({ ...o })),
1362
+ /* 접힌 쪽도 재개점에 든다 — 없으면 재기동 뒤에 끝난 오더의 수를 잃는다(ADR-0092). 접은 것이 없으면 싣지 않는다. */
1363
+ ...(foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {}),
1335
1364
  acked: [...this.acked],
1336
1365
  corrections: this.corrections.map(c => ({ ...c })),
1337
1366
  unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
@@ -1375,10 +1404,33 @@ export class ObservedReducer {
1375
1404
  this.persons = new Map((cp?.persons ?? []).map(x => [x.id, { ...x }]));
1376
1405
  this.assets = new Map((cp?.assets ?? []).map(x => [x.id, { ...x }]));
1377
1406
  this.orders = new Map((cp?.orders ?? []).map(o => [o.id, { ...o }]));
1407
+ this.foldedOrders = cp?.foldedOrders ? structuredClone(cp.foldedOrders) : emptyFoldedOrders();
1378
1408
  this.acked = new Set(cp?.acked ?? []);
1379
1409
  this.corrections = (cp?.corrections ?? []).map(c => ({ ...c }));
1380
1410
  this.unhandled = new Map((cp?.unhandled ?? []).map(({ eventType, ...v }) => [eventType, { ...v }]));
1381
1411
  }
1412
+ /**
1413
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
1414
+ *
1415
+ * 부르는 것은 호스트다: 체크포인트를 쓰기 직전에 부른다. 그래야 접힌 쪽이 씨앗과 같은 때에 적혀 재기동 동치
1416
+ * (`재개점 + 꼬리 == 0부터 접기`)가 선다. 「지금」은 트윈의 지금(마지막으로 들은 시각)이다 — 벽시계가 아니다.
1417
+ * 아직 아무것도 못 들었으면 접지 않는다.
1418
+ */
1419
+ foldFinishedOrders(nowMs = this.observedAtMs) {
1420
+ if (nowMs === undefined || !Number.isFinite(nowMs))
1421
+ return 0;
1422
+ const ids = planOrderFold([...this.orders.values()], nowMs, this.orderFoldPolicy);
1423
+ if (!ids.length)
1424
+ return 0;
1425
+ const rows = ids.map(id => this.orders.get(id)).filter(Boolean);
1426
+ this.foldedOrders = foldOrdersInto(this.foldedOrders, rows);
1427
+ for (const id of ids) {
1428
+ this.orders.delete(id);
1429
+ this.lastAt.delete(`order:${id}`);
1430
+ }
1431
+ this.revision++;
1432
+ return ids.length;
1433
+ }
1382
1434
  snapshot() {
1383
1435
  const occ = new Map();
1384
1436
  for (const it of this.items.values())
@@ -1425,6 +1477,7 @@ export class ObservedReducer {
1425
1477
  tasks: [...this.tasks.values()].map(t => structuredClone(t)),
1426
1478
  equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.oeePart(m.id), ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
1427
1479
  orders: [...this.orders.values()].map(o => ({ ...o })),
1480
+ ...(foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {}),
1428
1481
  acked: [...this.acked]
1429
1482
  };
1430
1483
  }
@@ -1909,6 +1909,68 @@ function gs1KeyDigitViolation(uri) {
1909
1909
  return `${m[1]} \uC790\uB9AC \uC218 \uC624\uB958: ${uri} \u2014 \uD68C\uC0AC \uD504\uB9AC\uD53D\uC2A4(${prefix.length}) + \uCC38\uC870(${ref.length}) = ${got} \uC774\uC9C0\uB9CC ${want} \uC5EC\uC57C \uD55C\uB2E4(GS1 TDS). \uD504\uB9AC\uD53D\uC2A4\uB97C \uBC14\uAFB8\uBA74 \uCC38\uC870 \uC790\uB9AC \uC218\uB97C \uD568\uAED8 \uB9DE\uCDB0\uC57C \uD55C\uB2E4.`;
1910
1910
  }
1911
1911
 
1912
+ // ../ops-contract/dist/order-fold.js
1913
+ var ORDER_FOLD_DEFAULT = Object.freeze({ afterMs: 24 * 60 * 60 * 1e3, maxTerminal: 1e3 });
1914
+ function orderFoldPolicyOf(declared) {
1915
+ const policy = { ...ORDER_FOLD_DEFAULT };
1916
+ for (const key of ["afterMs", "maxTerminal"]) {
1917
+ const v = declared?.[key];
1918
+ if (v === void 0 || v === null)
1919
+ continue;
1920
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
1921
+ return { error: `order fold ${key} must be a positive finite number, got ${String(v)} \u2014 a fold that never folds is refused (ADR-0092)` };
1922
+ }
1923
+ policy[key] = key === "maxTerminal" ? Math.floor(v) : v;
1924
+ }
1925
+ if (policy.maxTerminal < 1)
1926
+ return { error: "order fold maxTerminal must be at least 1 (ADR-0092)" };
1927
+ return { policy };
1928
+ }
1929
+ function emptyFoldedOrders() {
1930
+ return { counts: {}, boundaryMs: null };
1931
+ }
1932
+ function foldedOrderCount(folded) {
1933
+ let n = 0;
1934
+ for (const byStatus of Object.values(folded?.counts ?? {}))
1935
+ for (const c of Object.values(byStatus))
1936
+ n += c;
1937
+ return n;
1938
+ }
1939
+ function planOrderFold(orders, nowMs, policy = ORDER_FOLD_DEFAULT) {
1940
+ const candidates = orders.filter((o) => isOrderTerminal(o) && !o.held && typeof o.terminalAtMs === "number" && Number.isFinite(o.terminalAtMs)).sort((a, b) => a.terminalAtMs - b.terminalAtMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1941
+ const cutoff = nowMs - policy.afterMs;
1942
+ const byTime = candidates.filter((o) => o.terminalAtMs <= cutoff);
1943
+ const stay = candidates.length - byTime.length;
1944
+ const byCount = stay > policy.maxTerminal ? candidates.slice(byTime.length, byTime.length + (stay - policy.maxTerminal)) : [];
1945
+ return [...byTime, ...byCount].map((o) => o.id);
1946
+ }
1947
+ function foldOrdersInto(folded, orders) {
1948
+ const out = {
1949
+ counts: Object.fromEntries(Object.entries(folded?.counts ?? {}).map(([k, v]) => [k, { ...v }])),
1950
+ boundaryMs: folded?.boundaryMs ?? null
1951
+ };
1952
+ for (const o of orders) {
1953
+ const kind = String(o.kind ?? "");
1954
+ const status = String(o.status ?? "");
1955
+ const byStatus = out.counts[kind] ??= {};
1956
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
1957
+ if (typeof o.terminalAtMs === "number" && Number.isFinite(o.terminalAtMs)) {
1958
+ out.boundaryMs = out.boundaryMs === null ? o.terminalAtMs : Math.max(out.boundaryMs, o.terminalAtMs);
1959
+ }
1960
+ }
1961
+ return out;
1962
+ }
1963
+ function standsNewOrderRow(record, eventTimeMs, folded) {
1964
+ if (!isOrderTerminal(record))
1965
+ return true;
1966
+ const boundary = folded?.boundaryMs;
1967
+ if (boundary === null || boundary === void 0)
1968
+ return true;
1969
+ if (typeof eventTimeMs !== "number" || !Number.isFinite(eventTimeMs))
1970
+ return true;
1971
+ return eventTimeMs > boundary;
1972
+ }
1973
+
1912
1974
  // ../ops-contract/dist/iso-duration.js
1913
1975
  var RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
1914
1976
  function parseIsoDuration(text) {
@@ -2833,6 +2895,12 @@ var ObservedReducer = class {
2833
2895
  persons = /* @__PURE__ */ new Map();
2834
2896
  assets = /* @__PURE__ */ new Map();
2835
2897
  orders = /* @__PURE__ */ new Map();
2898
+ /*
2899
+ * **접힌 오더** — 끝나서 뜨거운 목록에서 걷힌 것의 세는 수와 경계(ADR-0092). 접는 때는 호스트가 정한다
2900
+ * (체크포인트를 쓰기 직전에 `foldFinishedOrders` 를 부른다). 경계는 모델의 `orderFold` 선언이다.
2901
+ */
2902
+ foldedOrders = emptyFoldedOrders();
2903
+ orderFoldPolicy;
2836
2904
  /** 확인해 둔 주목 신호 id — `attention.acked` 이벤트로만 들어온다(계산으로 만들지 않는다). */
2837
2905
  acked = /* @__PURE__ */ new Set();
2838
2906
  revision = 0;
@@ -2866,6 +2934,9 @@ var ObservedReducer = class {
2866
2934
  /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
2867
2935
  utcOffsetMinutes;
2868
2936
  constructor(model) {
2937
+ const fold = orderFoldPolicyOf(model.orderFold);
2938
+ if ("error" in fold) throw new Error(fold.error);
2939
+ this.orderFoldPolicy = fold.policy;
2869
2940
  this.utcOffsetMinutes = model.utcOffsetMinutes;
2870
2941
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
2871
2942
  for (const sp of model.testSpecifications ?? []) this.testSpecs.set(sp.id, sp);
@@ -3260,10 +3331,19 @@ var ObservedReducer = class {
3260
3331
  case OP_EVENT.order: {
3261
3332
  const d = e.data;
3262
3333
  if (this.stale(`order:${d.orderId}`, e)) return;
3334
+ const prev = this.orders.get(d.orderId);
3335
+ const at = Date.parse(String(e.eventTime ?? ""));
3336
+ if (!prev && !standsNewOrderRow(d, Number.isFinite(at) ? at : void 0, this.foldedOrders)) {
3337
+ this.lastAt.delete(`order:${d.orderId}`);
3338
+ return;
3339
+ }
3340
+ const terminal = isOrderTerminal({ status: d.status, requested: d.requested, fulfilled: d.fulfilled });
3341
+ const terminalAtMs = terminal ? prev?.terminalAtMs ?? (Number.isFinite(at) ? at : void 0) : void 0;
3263
3342
  this.orders.set(d.orderId, {
3264
3343
  id: d.orderId,
3265
3344
  kind: d.kind,
3266
3345
  status: d.status,
3346
+ ...terminalAtMs !== void 0 ? { terminalAtMs } : {},
3267
3347
  progress: d.requested ? d.fulfilled / d.requested : 0,
3268
3348
  requested: d.requested,
3269
3349
  fulfilled: d.fulfilled,
@@ -3707,6 +3787,8 @@ var ObservedReducer = class {
3707
3787
  persons: [...this.persons.values()].map((x) => ({ ...x })),
3708
3788
  assets: [...this.assets.values()].map((x) => ({ ...x })),
3709
3789
  orders: [...this.orders.values()].map((o) => ({ ...o })),
3790
+ /* 접힌 쪽도 재개점에 든다 — 없으면 재기동 뒤에 끝난 오더의 수를 잃는다(ADR-0092). 접은 것이 없으면 싣지 않는다. */
3791
+ ...foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {},
3710
3792
  acked: [...this.acked],
3711
3793
  corrections: this.corrections.map((c) => ({ ...c })),
3712
3794
  unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
@@ -3753,10 +3835,31 @@ var ObservedReducer = class {
3753
3835
  this.persons = new Map((cp?.persons ?? []).map((x) => [x.id, { ...x }]));
3754
3836
  this.assets = new Map((cp?.assets ?? []).map((x) => [x.id, { ...x }]));
3755
3837
  this.orders = new Map((cp?.orders ?? []).map((o) => [o.id, { ...o }]));
3838
+ this.foldedOrders = cp?.foldedOrders ? structuredClone(cp.foldedOrders) : emptyFoldedOrders();
3756
3839
  this.acked = new Set(cp?.acked ?? []);
3757
3840
  this.corrections = (cp?.corrections ?? []).map((c) => ({ ...c }));
3758
3841
  this.unhandled = new Map((cp?.unhandled ?? []).map(({ eventType, ...v }) => [eventType, { ...v }]));
3759
3842
  }
3843
+ /**
3844
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
3845
+ *
3846
+ * 부르는 것은 호스트다: 체크포인트를 쓰기 직전에 부른다. 그래야 접힌 쪽이 씨앗과 같은 때에 적혀 재기동 동치
3847
+ * (`재개점 + 꼬리 == 0부터 접기`)가 선다. 「지금」은 트윈의 지금(마지막으로 들은 시각)이다 — 벽시계가 아니다.
3848
+ * 아직 아무것도 못 들었으면 접지 않는다.
3849
+ */
3850
+ foldFinishedOrders(nowMs = this.observedAtMs) {
3851
+ if (nowMs === void 0 || !Number.isFinite(nowMs)) return 0;
3852
+ const ids = planOrderFold([...this.orders.values()], nowMs, this.orderFoldPolicy);
3853
+ if (!ids.length) return 0;
3854
+ const rows = ids.map((id) => this.orders.get(id)).filter(Boolean);
3855
+ this.foldedOrders = foldOrdersInto(this.foldedOrders, rows);
3856
+ for (const id of ids) {
3857
+ this.orders.delete(id);
3858
+ this.lastAt.delete(`order:${id}`);
3859
+ }
3860
+ this.revision++;
3861
+ return ids.length;
3862
+ }
3760
3863
  snapshot() {
3761
3864
  const occ = /* @__PURE__ */ new Map();
3762
3865
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
@@ -3802,6 +3905,7 @@ var ObservedReducer = class {
3802
3905
  tasks: [...this.tasks.values()].map((t) => structuredClone(t)),
3803
3906
  equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.oeePart(m.id), ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
3804
3907
  orders: [...this.orders.values()].map((o) => ({ ...o })),
3908
+ ...foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {},
3805
3909
  acked: [...this.acked]
3806
3910
  };
3807
3911
  }
@@ -4331,6 +4435,10 @@ var FlowEngine = class {
4331
4435
  assets = /* @__PURE__ */ new Map();
4332
4436
  tasks = /* @__PURE__ */ new Map();
4333
4437
  orders = /* @__PURE__ */ new Map();
4438
+ /** 접힌 오더(ADR-0092) — 세는 수와 경계만 든다. 미러는 리듀서가 들고 이것은 옮겨 받는다(§`hydrateObserved`). */
4439
+ foldedOrders = emptyFoldedOrders();
4440
+ /** 끝난 오더를 언제 접나 — 모델의 `orderFold` 선언. 없으면 기본(24시간 · 1,000건). */
4441
+ orderFoldPolicy = ORDER_FOLD_DEFAULT;
4334
4442
  revision = 0;
4335
4443
  clockMs = 0;
4336
4444
  /**
@@ -4396,6 +4504,31 @@ var FlowEngine = class {
4396
4504
  *
4397
4505
  * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
4398
4506
  */
4507
+ /**
4508
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
4509
+ *
4510
+ * 부르는 것은 호스트다 — 체크포인트(미러는 `observedCheckpoint`, 시뮬은 상태 스냅샷)를 쓰기 직전에 부른다. 그래야
4511
+ * 접힌 쪽이 씨앗과 같은 때에 적힌다. 커널이 스스로 접지 않는 것은, 접힌 목록을 읽는 쪽(예측 · 화면)이 그 뜻에
4512
+ * 맞춰진 뒤에 호스트가 부르게 하려는 것이다.
4513
+ *
4514
+ * 미러는 원천인 리듀서에서 접고 옮겨 받는다 — 이 목록에서만 지우면 다음 옮김이 되살린다.
4515
+ */
4516
+ foldFinishedOrders(nowMs) {
4517
+ if (this.observeMode && this.observer) {
4518
+ const n = this.observer.foldFinishedOrders(nowMs);
4519
+ if (n) {
4520
+ this.observedDirty = true;
4521
+ this.settleObserved();
4522
+ }
4523
+ return n;
4524
+ }
4525
+ const ids = planOrderFold([...this.orders.values()], nowMs ?? this.nowMs(), this.orderFoldPolicy);
4526
+ if (!ids.length) return 0;
4527
+ this.foldedOrders = foldOrdersInto(this.foldedOrders, ids.map((id) => this.orders.get(id)).filter(Boolean));
4528
+ for (const id of ids) this.orders.delete(id);
4529
+ this.revision++;
4530
+ return ids.length;
4531
+ }
4399
4532
  observedCheckpoint() {
4400
4533
  if (!this.observer && this.observeMode) {
4401
4534
  this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
@@ -4504,6 +4637,9 @@ var FlowEngine = class {
4504
4637
  loadTwinModel(def) {
4505
4638
  this.boardDef = def;
4506
4639
  if (def.allocationPolicy) this.policy = allocationPolicyOf(def.allocationPolicy);
4640
+ const fold = orderFoldPolicyOf(def.orderFold);
4641
+ if ("error" in fold) throw new Error(fold.error);
4642
+ this.orderFoldPolicy = fold.policy;
4507
4643
  if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
4508
4644
  for (const n of readBoardLocations(def)) this.locations.set(n.id, this.buildLocation(n));
4509
4645
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
@@ -4881,6 +5017,12 @@ var FlowEngine = class {
4881
5017
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
4882
5018
  ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
4883
5019
  }));
5020
+ this.foldedOrders = snap.foldedOrders ? structuredClone(snap.foldedOrders) : emptyFoldedOrders();
5021
+ const terminalAtOf = new Map((snap.orders ?? []).map((o) => [o.id, o.terminalAtMs]));
5022
+ if (purpose === "observe") {
5023
+ const present = new Set(observedOrders.map((o) => o.orderId));
5024
+ for (const id of [...this.orders.keys()]) if (!present.has(id)) this.orders.delete(id);
5025
+ }
4884
5026
  for (const o of observedOrders) {
4885
5027
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
4886
5028
  const remaining = lines.length ? lines.reduce((s, l) => s + l.requested, 0) : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
@@ -4919,7 +5061,9 @@ var FlowEngine = class {
4919
5061
  /* 약속해 둔 자리·시각창을 이어받는다 — 없으면 도메인이 「정해 뒀다」고 믿는 것을 잃는다.
4920
5062
  사실이 말해 주지 않으면(옛 저널) 비는 것이 사실이다 — 지어내지 않는다. */
4921
5063
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
4922
- ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
5064
+ ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {},
5065
+ /* 언제 끝났나 — 관측만 이어받는다(씨앗은 끝난 오더를 심지 않는다). */
5066
+ ...observing && terminalAtOf.get(o.orderId) !== void 0 ? { terminalAtMs: terminalAtOf.get(o.orderId) } : {}
4923
5067
  });
4924
5068
  }
4925
5069
  const seededOrderIds = new Set(this.orders.keys());
@@ -5319,10 +5463,13 @@ var FlowEngine = class {
5319
5463
  ...t.startTime ? { startTime: t.startTime } : {},
5320
5464
  ...t.endTime ? { endTime: t.endTime } : {}
5321
5465
  })),
5466
+ /* 접힌 오더 — 접은 것이 없으면 싣지 않는다(ADR-0092). */
5467
+ ...foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {},
5322
5468
  orders: [...this.orders.values()].map((o) => ({
5323
5469
  id: o.id,
5324
5470
  kind: o.kind,
5325
5471
  status: o.status,
5472
+ ...o.terminalAtMs !== void 0 ? { terminalAtMs: o.terminalAtMs } : {},
5326
5473
  progress: o.requested ? o.fulfilled / o.requested : 0,
5327
5474
  requested: o.requested,
5328
5475
  fulfilled: o.fulfilled,
@@ -6358,6 +6505,8 @@ var FlowEngine = class {
6358
6505
  }
6359
6506
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
6360
6507
  emitOrder(o) {
6508
+ if (isOrderTerminal(o)) o.terminalAtMs ??= this.nowMs();
6509
+ else delete o.terminalAtMs;
6361
6510
  this.emitOp(OP_EVENT.order, {
6362
6511
  orderId: o.id,
6363
6512
  kind: o.kind,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.11.24",
3
+ "version": "0.11.25",
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": {
@@ -28,6 +28,6 @@
28
28
  "test": "node --test test/*.test.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@operato/ops-contract": "^0.9.35"
31
+ "@operato/ops-contract": "^0.9.36"
32
32
  }
33
33
  }