@operato/twin-kernel 0.11.24 → 0.11.26

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, pruneFoldedHours, 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,35 @@ 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
+ /* 창 밖 시간 칸은 접을 때 버린다 — 칸 수가 T/1h 를 넘지 않게(ADR-0092 결정 2 보탬). */
725
+ const now = nowMs ?? this.nowMs();
726
+ this.foldedOrders = pruneFoldedHours(foldOrdersInto(this.foldedOrders, ids.map(id => this.orders.get(id)).filter(Boolean)), now, this.orderFoldPolicy);
727
+ for (const id of ids)
728
+ this.orders.delete(id);
729
+ this.revision++;
730
+ return ids.length;
731
+ }
698
732
  observedCheckpoint() {
699
733
  /*
700
734
  * 관측 구동인데 리듀서가 아직 없으면 만든다 (2026-08-27).
@@ -825,6 +859,11 @@ export class FlowEngine {
825
859
  */
826
860
  if (def.allocationPolicy)
827
861
  this.policy = allocationPolicyOf(def.allocationPolicy);
862
+ /* 끝난 오더를 접는 경계 — 0 · 무한은 거절한다. 접지 않는 선언은 끝난 오더가 끝없이 쌓이던 때로 돌아간다(ADR-0092). */
863
+ const fold = orderFoldPolicyOf(def.orderFold);
864
+ if ('error' in fold)
865
+ throw new Error(fold.error);
866
+ this.orderFoldPolicy = fold.policy;
828
867
  /*
829
868
  * **생산 선언은 어느 커널이든 싣는다.** 예전에는 MES 커널만 생성자에서 이것을 실었고(그 시절
830
869
  * 이름은 `mesSpec` 이었다), 그래서 창고 트윈은 "자재를 소비해 자재를 산출하는 공정" 을 선언할
@@ -1281,6 +1320,19 @@ export class FlowEngine {
1281
1320
  *
1282
1321
  * 지어내지 않는다 — 없는 물품을 만들어 채우면 그 뒤 계보와 재고가 전부 거짓 위에 선다.
1283
1322
  */
1323
+ /* 접힌 쪽과 종결 시각을 이어받는다(ADR-0092). 종결 시각은 델타에 없으므로 스냅샷의 오더에서 찾는다. */
1324
+ this.foldedOrders = snap.foldedOrders ? structuredClone(snap.foldedOrders) : emptyFoldedOrders();
1325
+ const terminalAtOf = new Map((snap.orders ?? []).map(o => [o.id, o.terminalAtMs]));
1326
+ /*
1327
+ * **관측이면 원천에 없는 오더를 지운다.** 이 목록은 리듀서에서 옮겨 받는 것이라, 리듀서가 접은 오더를 여기 남기면
1328
+ * 다시는 사라지지 않는다. 씨앗(웜스타트 · fork)은 새로 세우는 커널이라 지울 것이 없다.
1329
+ */
1330
+ if (purpose === 'observe') {
1331
+ const present = new Set(observedOrders.map(o => o.orderId));
1332
+ for (const id of [...this.orders.keys()])
1333
+ if (!present.has(id))
1334
+ this.orders.delete(id);
1335
+ }
1284
1336
  for (const o of observedOrders) {
1285
1337
  const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
1286
1338
  /* **라인이 없는 오더를 조용히 빼지 않는다.** 예전에는 남은 양을 라인에서만 셌으므로, 품목 라인
@@ -1341,7 +1393,9 @@ export class FlowEngine {
1341
1393
  /* 약속해 둔 자리·시각창을 이어받는다 — 없으면 도메인이 「정해 뒀다」고 믿는 것을 잃는다.
1342
1394
  사실이 말해 주지 않으면(옛 저널) 비는 것이 사실이다 — 지어내지 않는다. */
1343
1395
  ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
1344
- ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
1396
+ ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {}),
1397
+ /* 언제 끝났나 — 관측만 이어받는다(씨앗은 끝난 오더를 심지 않는다). */
1398
+ ...(observing && terminalAtOf.get(o.orderId) !== undefined ? { terminalAtMs: terminalAtOf.get(o.orderId) } : {})
1345
1399
  });
1346
1400
  }
1347
1401
  /* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
@@ -1831,8 +1885,11 @@ export class FlowEngine {
1831
1885
  ...(t.startTime ? { startTime: t.startTime } : {}),
1832
1886
  ...(t.endTime ? { endTime: t.endTime } : {})
1833
1887
  })),
1888
+ /* 접힌 오더 — 접은 것이 없으면 싣지 않는다(ADR-0092). */
1889
+ ...(foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {}),
1834
1890
  orders: [...this.orders.values()].map(o => ({
1835
1891
  id: o.id, kind: o.kind, status: o.status,
1892
+ ...(o.terminalAtMs !== undefined ? { terminalAtMs: o.terminalAtMs } : {}),
1836
1893
  progress: o.requested ? o.fulfilled / o.requested : 0,
1837
1894
  requested: o.requested, fulfilled: o.fulfilled,
1838
1895
  /*
@@ -3007,6 +3064,15 @@ export class FlowEngine {
3007
3064
  }
3008
3065
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
3009
3066
  emitOrder(o) {
3067
+ /*
3068
+ * **언제 끝났나를 여기서 적는다** — 오더의 상태를 바꾸는 자리는 여럿이지만 사건을 내는 문은 이것 하나다.
3069
+ * 끝났던 오더는 처음 끝난 시각을 지키고, 다시 열리면 지운다. 미러는 같은 값을 그 사건의 `eventTime` 에서
3070
+ * 되세운다(델타에 싣지 않는다 — 두 구동이 같은 시각을 든다).
3071
+ */
3072
+ if (isOrderTerminal(o))
3073
+ o.terminalAtMs ??= this.nowMs();
3074
+ else
3075
+ delete o.terminalAtMs;
3010
3076
  this.emitOp(OP_EVENT.order, {
3011
3077
  orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held,
3012
3078
  ...(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, pruneFoldedHours, 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,34 @@ 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
+ /* 창 밖 시간 칸은 접을 때 버린다 — 칸 수가 T/1h 를 넘지 않게(ADR-0092 결정 2 보탬). */
1427
+ this.foldedOrders = pruneFoldedHours(foldOrdersInto(this.foldedOrders, rows), nowMs, this.orderFoldPolicy);
1428
+ for (const id of ids) {
1429
+ this.orders.delete(id);
1430
+ this.lastAt.delete(`order:${id}`);
1431
+ }
1432
+ this.revision++;
1433
+ return ids.length;
1434
+ }
1382
1435
  snapshot() {
1383
1436
  const occ = new Map();
1384
1437
  for (const it of this.items.values())
@@ -1425,6 +1478,7 @@ export class ObservedReducer {
1425
1478
  tasks: [...this.tasks.values()].map(t => structuredClone(t)),
1426
1479
  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
1480
  orders: [...this.orders.values()].map(o => ({ ...o })),
1481
+ ...(foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {}),
1428
1482
  acked: [...this.acked]
1429
1483
  };
1430
1484
  }
@@ -1909,6 +1909,93 @@ 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_HOUR_MS = 60 * 60 * 1e3;
1914
+ var ORDER_FOLD_DEFAULT = Object.freeze({ afterMs: 24 * 60 * 60 * 1e3, maxTerminal: 1e3 });
1915
+ function orderFoldPolicyOf(declared) {
1916
+ const policy = { ...ORDER_FOLD_DEFAULT };
1917
+ for (const key of ["afterMs", "maxTerminal"]) {
1918
+ const v = declared?.[key];
1919
+ if (v === void 0 || v === null)
1920
+ continue;
1921
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
1922
+ return { error: `order fold ${key} must be a positive finite number, got ${String(v)} \u2014 a fold that never folds is refused (ADR-0092)` };
1923
+ }
1924
+ policy[key] = key === "maxTerminal" ? Math.floor(v) : v;
1925
+ }
1926
+ if (policy.afterMs % ORDER_FOLD_HOUR_MS !== 0) {
1927
+ return { error: `order fold afterMs must be a whole number of hours, got ${policy.afterMs} ms \u2014 the window is counted in hour slots (ADR-0092)` };
1928
+ }
1929
+ if (policy.maxTerminal < 1)
1930
+ return { error: "order fold maxTerminal must be at least 1 (ADR-0092)" };
1931
+ return { policy };
1932
+ }
1933
+ function emptyFoldedOrders() {
1934
+ return { counts: {}, boundaryMs: null };
1935
+ }
1936
+ function foldedOrderCount(folded) {
1937
+ let n = 0;
1938
+ for (const byStatus of Object.values(folded?.counts ?? {}))
1939
+ for (const c of Object.values(byStatus))
1940
+ n += c;
1941
+ return n;
1942
+ }
1943
+ function planOrderFold(orders, nowMs, policy = ORDER_FOLD_DEFAULT) {
1944
+ 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));
1945
+ const cutoff = nowMs - policy.afterMs;
1946
+ const byTime = candidates.filter((o) => o.terminalAtMs <= cutoff);
1947
+ const stay = candidates.length - byTime.length;
1948
+ const byCount = stay > policy.maxTerminal ? candidates.slice(byTime.length, byTime.length + (stay - policy.maxTerminal)) : [];
1949
+ return [...byTime, ...byCount].map((o) => o.id);
1950
+ }
1951
+ function foldOrdersInto(folded, orders) {
1952
+ const out = {
1953
+ counts: Object.fromEntries(Object.entries(folded?.counts ?? {}).map(([k, v]) => [k, { ...v }])),
1954
+ boundaryMs: folded?.boundaryMs ?? null
1955
+ };
1956
+ const hours = Object.fromEntries(Object.entries(folded?.hours ?? {}).map(([h, byKind]) => [h, Object.fromEntries(Object.entries(byKind).map(([k, v]) => [k, { ...v }]))]));
1957
+ for (const o of orders) {
1958
+ const kind = String(o.kind ?? "");
1959
+ const status = String(o.status ?? "");
1960
+ const byStatus = out.counts[kind] ??= {};
1961
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
1962
+ if (typeof o.terminalAtMs === "number" && Number.isFinite(o.terminalAtMs)) {
1963
+ out.boundaryMs = out.boundaryMs === null ? o.terminalAtMs : Math.max(out.boundaryMs, o.terminalAtMs);
1964
+ const slot = (hours[String(hourStartOf(o.terminalAtMs))] ??= {})[kind] ??= {};
1965
+ slot[status] = (slot[status] ?? 0) + 1;
1966
+ }
1967
+ }
1968
+ if (Object.keys(hours).length)
1969
+ out.hours = hours;
1970
+ return out;
1971
+ }
1972
+ function standsNewOrderRow(record, eventTimeMs, folded) {
1973
+ if (!isOrderTerminal(record))
1974
+ return true;
1975
+ const boundary = folded?.boundaryMs;
1976
+ if (boundary === null || boundary === void 0)
1977
+ return true;
1978
+ if (typeof eventTimeMs !== "number" || !Number.isFinite(eventTimeMs))
1979
+ return true;
1980
+ return eventTimeMs > boundary;
1981
+ }
1982
+ function hourStartOf(ms2) {
1983
+ return Math.floor(ms2 / ORDER_FOLD_HOUR_MS) * ORDER_FOLD_HOUR_MS;
1984
+ }
1985
+ function finishedWindowStartMs(nowMs, policy = ORDER_FOLD_DEFAULT) {
1986
+ return hourStartOf(nowMs) - policy.afterMs;
1987
+ }
1988
+ function pruneFoldedHours(folded, nowMs, policy = ORDER_FOLD_DEFAULT) {
1989
+ if (!folded.hours)
1990
+ return folded;
1991
+ const start = finishedWindowStartMs(nowMs, policy);
1992
+ const kept = Object.fromEntries(Object.entries(folded.hours).filter(([h]) => Number(h) >= start));
1993
+ const out = { counts: folded.counts, boundaryMs: folded.boundaryMs };
1994
+ if (Object.keys(kept).length)
1995
+ out.hours = kept;
1996
+ return out;
1997
+ }
1998
+
1912
1999
  // ../ops-contract/dist/iso-duration.js
1913
2000
  var RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
1914
2001
  function parseIsoDuration(text) {
@@ -2833,6 +2920,12 @@ var ObservedReducer = class {
2833
2920
  persons = /* @__PURE__ */ new Map();
2834
2921
  assets = /* @__PURE__ */ new Map();
2835
2922
  orders = /* @__PURE__ */ new Map();
2923
+ /*
2924
+ * **접힌 오더** — 끝나서 뜨거운 목록에서 걷힌 것의 세는 수와 경계(ADR-0092). 접는 때는 호스트가 정한다
2925
+ * (체크포인트를 쓰기 직전에 `foldFinishedOrders` 를 부른다). 경계는 모델의 `orderFold` 선언이다.
2926
+ */
2927
+ foldedOrders = emptyFoldedOrders();
2928
+ orderFoldPolicy;
2836
2929
  /** 확인해 둔 주목 신호 id — `attention.acked` 이벤트로만 들어온다(계산으로 만들지 않는다). */
2837
2930
  acked = /* @__PURE__ */ new Set();
2838
2931
  revision = 0;
@@ -2866,6 +2959,9 @@ var ObservedReducer = class {
2866
2959
  /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
2867
2960
  utcOffsetMinutes;
2868
2961
  constructor(model) {
2962
+ const fold = orderFoldPolicyOf(model.orderFold);
2963
+ if ("error" in fold) throw new Error(fold.error);
2964
+ this.orderFoldPolicy = fold.policy;
2869
2965
  this.utcOffsetMinutes = model.utcOffsetMinutes;
2870
2966
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
2871
2967
  for (const sp of model.testSpecifications ?? []) this.testSpecs.set(sp.id, sp);
@@ -3260,10 +3356,19 @@ var ObservedReducer = class {
3260
3356
  case OP_EVENT.order: {
3261
3357
  const d = e.data;
3262
3358
  if (this.stale(`order:${d.orderId}`, e)) return;
3359
+ const prev = this.orders.get(d.orderId);
3360
+ const at = Date.parse(String(e.eventTime ?? ""));
3361
+ if (!prev && !standsNewOrderRow(d, Number.isFinite(at) ? at : void 0, this.foldedOrders)) {
3362
+ this.lastAt.delete(`order:${d.orderId}`);
3363
+ return;
3364
+ }
3365
+ const terminal = isOrderTerminal({ status: d.status, requested: d.requested, fulfilled: d.fulfilled });
3366
+ const terminalAtMs = terminal ? prev?.terminalAtMs ?? (Number.isFinite(at) ? at : void 0) : void 0;
3263
3367
  this.orders.set(d.orderId, {
3264
3368
  id: d.orderId,
3265
3369
  kind: d.kind,
3266
3370
  status: d.status,
3371
+ ...terminalAtMs !== void 0 ? { terminalAtMs } : {},
3267
3372
  progress: d.requested ? d.fulfilled / d.requested : 0,
3268
3373
  requested: d.requested,
3269
3374
  fulfilled: d.fulfilled,
@@ -3707,6 +3812,8 @@ var ObservedReducer = class {
3707
3812
  persons: [...this.persons.values()].map((x) => ({ ...x })),
3708
3813
  assets: [...this.assets.values()].map((x) => ({ ...x })),
3709
3814
  orders: [...this.orders.values()].map((o) => ({ ...o })),
3815
+ /* 접힌 쪽도 재개점에 든다 — 없으면 재기동 뒤에 끝난 오더의 수를 잃는다(ADR-0092). 접은 것이 없으면 싣지 않는다. */
3816
+ ...foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {},
3710
3817
  acked: [...this.acked],
3711
3818
  corrections: this.corrections.map((c) => ({ ...c })),
3712
3819
  unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
@@ -3753,10 +3860,31 @@ var ObservedReducer = class {
3753
3860
  this.persons = new Map((cp?.persons ?? []).map((x) => [x.id, { ...x }]));
3754
3861
  this.assets = new Map((cp?.assets ?? []).map((x) => [x.id, { ...x }]));
3755
3862
  this.orders = new Map((cp?.orders ?? []).map((o) => [o.id, { ...o }]));
3863
+ this.foldedOrders = cp?.foldedOrders ? structuredClone(cp.foldedOrders) : emptyFoldedOrders();
3756
3864
  this.acked = new Set(cp?.acked ?? []);
3757
3865
  this.corrections = (cp?.corrections ?? []).map((c) => ({ ...c }));
3758
3866
  this.unhandled = new Map((cp?.unhandled ?? []).map(({ eventType, ...v }) => [eventType, { ...v }]));
3759
3867
  }
3868
+ /**
3869
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
3870
+ *
3871
+ * 부르는 것은 호스트다: 체크포인트를 쓰기 직전에 부른다. 그래야 접힌 쪽이 씨앗과 같은 때에 적혀 재기동 동치
3872
+ * (`재개점 + 꼬리 == 0부터 접기`)가 선다. 「지금」은 트윈의 지금(마지막으로 들은 시각)이다 — 벽시계가 아니다.
3873
+ * 아직 아무것도 못 들었으면 접지 않는다.
3874
+ */
3875
+ foldFinishedOrders(nowMs = this.observedAtMs) {
3876
+ if (nowMs === void 0 || !Number.isFinite(nowMs)) return 0;
3877
+ const ids = planOrderFold([...this.orders.values()], nowMs, this.orderFoldPolicy);
3878
+ if (!ids.length) return 0;
3879
+ const rows = ids.map((id) => this.orders.get(id)).filter(Boolean);
3880
+ this.foldedOrders = pruneFoldedHours(foldOrdersInto(this.foldedOrders, rows), nowMs, this.orderFoldPolicy);
3881
+ for (const id of ids) {
3882
+ this.orders.delete(id);
3883
+ this.lastAt.delete(`order:${id}`);
3884
+ }
3885
+ this.revision++;
3886
+ return ids.length;
3887
+ }
3760
3888
  snapshot() {
3761
3889
  const occ = /* @__PURE__ */ new Map();
3762
3890
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
@@ -3802,6 +3930,7 @@ var ObservedReducer = class {
3802
3930
  tasks: [...this.tasks.values()].map((t) => structuredClone(t)),
3803
3931
  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
3932
  orders: [...this.orders.values()].map((o) => ({ ...o })),
3933
+ ...foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {},
3805
3934
  acked: [...this.acked]
3806
3935
  };
3807
3936
  }
@@ -4331,6 +4460,10 @@ var FlowEngine = class {
4331
4460
  assets = /* @__PURE__ */ new Map();
4332
4461
  tasks = /* @__PURE__ */ new Map();
4333
4462
  orders = /* @__PURE__ */ new Map();
4463
+ /** 접힌 오더(ADR-0092) — 세는 수와 경계만 든다. 미러는 리듀서가 들고 이것은 옮겨 받는다(§`hydrateObserved`). */
4464
+ foldedOrders = emptyFoldedOrders();
4465
+ /** 끝난 오더를 언제 접나 — 모델의 `orderFold` 선언. 없으면 기본(24시간 · 1,000건). */
4466
+ orderFoldPolicy = ORDER_FOLD_DEFAULT;
4334
4467
  revision = 0;
4335
4468
  clockMs = 0;
4336
4469
  /**
@@ -4396,6 +4529,32 @@ var FlowEngine = class {
4396
4529
  *
4397
4530
  * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
4398
4531
  */
4532
+ /**
4533
+ * **끝난 오더를 접는다**(ADR-0092) — 접은 수를 돌려준다.
4534
+ *
4535
+ * 부르는 것은 호스트다 — 체크포인트(미러는 `observedCheckpoint`, 시뮬은 상태 스냅샷)를 쓰기 직전에 부른다. 그래야
4536
+ * 접힌 쪽이 씨앗과 같은 때에 적힌다. 커널이 스스로 접지 않는 것은, 접힌 목록을 읽는 쪽(예측 · 화면)이 그 뜻에
4537
+ * 맞춰진 뒤에 호스트가 부르게 하려는 것이다.
4538
+ *
4539
+ * 미러는 원천인 리듀서에서 접고 옮겨 받는다 — 이 목록에서만 지우면 다음 옮김이 되살린다.
4540
+ */
4541
+ foldFinishedOrders(nowMs) {
4542
+ if (this.observeMode && this.observer) {
4543
+ const n = this.observer.foldFinishedOrders(nowMs);
4544
+ if (n) {
4545
+ this.observedDirty = true;
4546
+ this.settleObserved();
4547
+ }
4548
+ return n;
4549
+ }
4550
+ const ids = planOrderFold([...this.orders.values()], nowMs ?? this.nowMs(), this.orderFoldPolicy);
4551
+ if (!ids.length) return 0;
4552
+ const now = nowMs ?? this.nowMs();
4553
+ this.foldedOrders = pruneFoldedHours(foldOrdersInto(this.foldedOrders, ids.map((id) => this.orders.get(id)).filter(Boolean)), now, this.orderFoldPolicy);
4554
+ for (const id of ids) this.orders.delete(id);
4555
+ this.revision++;
4556
+ return ids.length;
4557
+ }
4399
4558
  observedCheckpoint() {
4400
4559
  if (!this.observer && this.observeMode) {
4401
4560
  this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
@@ -4504,6 +4663,9 @@ var FlowEngine = class {
4504
4663
  loadTwinModel(def) {
4505
4664
  this.boardDef = def;
4506
4665
  if (def.allocationPolicy) this.policy = allocationPolicyOf(def.allocationPolicy);
4666
+ const fold = orderFoldPolicyOf(def.orderFold);
4667
+ if ("error" in fold) throw new Error(fold.error);
4668
+ this.orderFoldPolicy = fold.policy;
4507
4669
  if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
4508
4670
  for (const n of readBoardLocations(def)) this.locations.set(n.id, this.buildLocation(n));
4509
4671
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
@@ -4881,6 +5043,12 @@ var FlowEngine = class {
4881
5043
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
4882
5044
  ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
4883
5045
  }));
5046
+ this.foldedOrders = snap.foldedOrders ? structuredClone(snap.foldedOrders) : emptyFoldedOrders();
5047
+ const terminalAtOf = new Map((snap.orders ?? []).map((o) => [o.id, o.terminalAtMs]));
5048
+ if (purpose === "observe") {
5049
+ const present = new Set(observedOrders.map((o) => o.orderId));
5050
+ for (const id of [...this.orders.keys()]) if (!present.has(id)) this.orders.delete(id);
5051
+ }
4884
5052
  for (const o of observedOrders) {
4885
5053
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
4886
5054
  const remaining = lines.length ? lines.reduce((s, l) => s + l.requested, 0) : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
@@ -4919,7 +5087,9 @@ var FlowEngine = class {
4919
5087
  /* 약속해 둔 자리·시각창을 이어받는다 — 없으면 도메인이 「정해 뒀다」고 믿는 것을 잃는다.
4920
5088
  사실이 말해 주지 않으면(옛 저널) 비는 것이 사실이다 — 지어내지 않는다. */
4921
5089
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
4922
- ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
5090
+ ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {},
5091
+ /* 언제 끝났나 — 관측만 이어받는다(씨앗은 끝난 오더를 심지 않는다). */
5092
+ ...observing && terminalAtOf.get(o.orderId) !== void 0 ? { terminalAtMs: terminalAtOf.get(o.orderId) } : {}
4923
5093
  });
4924
5094
  }
4925
5095
  const seededOrderIds = new Set(this.orders.keys());
@@ -5319,10 +5489,13 @@ var FlowEngine = class {
5319
5489
  ...t.startTime ? { startTime: t.startTime } : {},
5320
5490
  ...t.endTime ? { endTime: t.endTime } : {}
5321
5491
  })),
5492
+ /* 접힌 오더 — 접은 것이 없으면 싣지 않는다(ADR-0092). */
5493
+ ...foldedOrderCount(this.foldedOrders) ? { foldedOrders: structuredClone(this.foldedOrders) } : {},
5322
5494
  orders: [...this.orders.values()].map((o) => ({
5323
5495
  id: o.id,
5324
5496
  kind: o.kind,
5325
5497
  status: o.status,
5498
+ ...o.terminalAtMs !== void 0 ? { terminalAtMs: o.terminalAtMs } : {},
5326
5499
  progress: o.requested ? o.fulfilled / o.requested : 0,
5327
5500
  requested: o.requested,
5328
5501
  fulfilled: o.fulfilled,
@@ -6358,6 +6531,8 @@ var FlowEngine = class {
6358
6531
  }
6359
6532
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
6360
6533
  emitOrder(o) {
6534
+ if (isOrderTerminal(o)) o.terminalAtMs ??= this.nowMs();
6535
+ else delete o.terminalAtMs;
6361
6536
  this.emitOp(OP_EVENT.order, {
6362
6537
  orderId: o.id,
6363
6538
  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.26",
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.37"
32
32
  }
33
33
  }