@operato/twin-kernel 0.11.18 → 0.11.20

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.
package/README.md CHANGED
@@ -221,6 +221,6 @@ Host binding (transport, persistence, connectors) · board binding (component
221
221
 
222
222
  - [`fold-and-resume.md`](../../design/fold-and-resume.md) — folding and resume points (the wrong turns, the principles, what is implemented)
223
223
  - [`runtime-state-model.md`](../../design/runtime-state-model.md) — live = authority / history = derived, the time axis, identity
224
- - [`04-decisions.md`](../../design/04-decisions.md) — ADRs
224
+ - `operato-application/adr/` — ADRs
225
225
  - `plans/simulation-spec.md` (specs, estimators, forecast standing) · `plans/four-resources-and-conformance.md` (four resources, invariants) · `plans/kernel-unification-live-observe.md` (driver unification)
226
226
  - `profiles/ems.md` — the energy profile's standard anchors
@@ -1,3 +1,4 @@
1
+ import type { AllocatedQuantity, AllocationPolicyName } from '@operato/ops-contract';
1
2
  /** 배치(placement) 대상 후보 슬롯의 관측 뷰 (내부 상태 누출 없이 결정에 필요한 만큼만). */
2
3
  export interface SlotView {
3
4
  id: string;
@@ -11,6 +12,19 @@ export interface StockView {
11
12
  location: string;
12
13
  qty: number;
13
14
  expiry?: number;
15
+ /**
16
+ * **이 재고가 들어온 시각**(sim-ms) — 출처는 receiving 관측의 `eventTime` 이다(수집 시각이 아니다).
17
+ *
18
+ * ── 없는 값이 있다 ──────────────────────────────────────────────────────────
19
+ * 씨앗으로 선 재고, 입고 기록 없이 서 있던 재고는 이 값을 갖지 않는다. **없음을 시각으로 메우지
20
+ * 않는다** — 0 으로 두면 가장 오래된 것이 되어 먼저 나가고, 지금 시각으로 두면 영영 안 나간다.
21
+ * 둘 다 없는 근거로 순서를 정하는 것이다.
22
+ *
23
+ * 지금 이 값을 읽는 정책은 없다(닫힌 목록 셋 중 시각을 보는 것은 없다 — `firstFit` 은 식별자 순이다).
24
+ * 「먼저 들어온 것 먼저」를 실제로 하는 정책이 설 때, 시각 없는 재고를 어디에 세울지는 그 정책이
25
+ * 한 곳에서 선언한다.
26
+ */
27
+ receivedAtMs?: number;
14
28
  }
15
29
  export interface PlacementContext {
16
30
  item: {
@@ -39,8 +53,18 @@ export interface StockRequest {
39
53
  export interface AllocationPolicy {
40
54
  /** 배치 목적지 슬롯 id. 수용 불가면 null(도크 대기 → 다음 tick 재시도). */
41
55
  selectPlacement(ctx: PlacementContext): string | null;
42
- /** 이번 라운드에 할당할 재고 epc 목록(부분 가능). 빈 배열 = 이번엔 할당 보류. */
43
- selectStock(ctx: StockRequest): readonly string[];
56
+ /**
57
+ * 이번 라운드에 잡을 **줄들**(부분 가능). 빈 배열 = 이번엔 할당 보류.
58
+ *
59
+ * ── 왜 줄인가 (2026-09-19, ADR-0076 보탬) ─────────────────────────────────
60
+ * 예전에는 EPC 목록이었고 **줄 수**를 셌다(`total++`). 한 EPC = 한 개라는 전제였고, 그래서
61
+ * 「100 중 30」을 표현할 수 없었다 — 수량 로트를 다루는 창고와 MES 의 자재 확보(밀가루 3.5kg)가
62
+ * 그 전제 밖이다. 이제 각 줄이 자기 수를 들고, 마지막 줄은 요청에 맞춰 일부만 잡을 수 있다.
63
+ *
64
+ * 열쇠의 낱말은 `epc` 그대로다 — 수량 로트의 줄도 그 식별자로 불린다(한 개념에 낱말 둘을 만들지
65
+ * 않는다).
66
+ */
67
+ selectStock(ctx: StockRequest): readonly AllocatedQuantity[];
44
68
  }
45
69
  /**
46
70
  * 기본 정책 — first-fit slot + all-or-nothing 오더 할당.
@@ -57,3 +81,18 @@ export declare const partialFitPolicy: AllocationPolicy;
57
81
  * all-or-nothing(전량 확보 전 대기)이되 선택 순서는 만료 오름차순(동률은 epc). 코어 수정 없이 정책 교체만으로 확장(원칙 2).
58
82
  */
59
83
  export declare const fefoPolicy: AllocationPolicy;
84
+ /**
85
+ * 이름 → 정책 — **닫힌 목록**(계약의 `ALLOCATION_POLICY`).
86
+ *
87
+ * 선언은 「이 현장은 어느 규칙으로 고르나」이고(트윈 모델의 `allocationPolicy`), 그것을 창고 앱과
88
+ * 커널이 **같이** 읽는다. 등록부가 없으면 선언이 이름만 남고 커널은 계속 기본 정책으로 돈다 —
89
+ * 그러면 트윈이 그 창고의 미래를 다른 규칙으로 실행한다(ADR-0076 결정 1).
90
+ */
91
+ export declare const ALLOCATION_POLICIES: Record<AllocationPolicyName, AllocationPolicy>;
92
+ /**
93
+ * 선언한 이름의 정책을 찾는다. **모르는 이름은 거절한다** — 기본값으로 조용히 떨어지면 선언한
94
+ * 규칙과 도는 규칙이 갈리고, 그 사실은 아무 데도 나타나지 않는다.
95
+ *
96
+ * 선언이 없으면(`undefined`) 기본 정책이다 — 선언하지 않은 현장은 지금까지와 같이 돈다.
97
+ */
98
+ export declare function allocationPolicyOf(name: string | undefined | null): AllocationPolicy;
@@ -5,6 +5,7 @@
5
5
  * 결정은 정책에 위임한다. 고객은 FIFO/FEFO/nearest/zone·부분할당 등을 정책 교체로만
6
6
  * 확장 — 코어 수정 없이. (설계 gap #5 "할당 정책 플러그")
7
7
  */
8
+ import { ALLOCATION_POLICY } from '@operato/ops-contract';
8
9
  /*
9
10
  * ── 식별자 비교에 `localeCompare` 를 쓰지 않는다 (2026-08-21 프로파일) ───────
10
11
  *
@@ -37,24 +38,60 @@ function freeBinsFirstFit(slots) {
37
38
  * 작은 정렬 버퍼를 들고 한 번 순회한다. k 가 작을 때(오더가 요구하는 수는 보통 한 자리다) 전량 정렬보다
38
39
  * 훨씬 싸고, 결과는 정렬한 뒤 앞의 k 개와 **같다**.
39
40
  */
40
- function smallestByEpc(available, k) {
41
- if (k <= 0)
41
+ /*
42
+ * 줄 하나가 든 수 — **틱의 뜨거운 길**이라 흔한 경우를 먼저 본다(`Number()` 변환을 태우지 않는다).
43
+ * 값이 수가 아니거나 0 이하면 0 이다: 그 줄에서 잡을 수 있는 것이 없다는 뜻이고, 「모른다」를 1 로
44
+ * 메우면 없는 재고를 잡는다.
45
+ */
46
+ function qtyOf(s) {
47
+ const q = s?.qty;
48
+ return typeof q === 'number' && q > 0 && q < Infinity ? q : 0;
49
+ }
50
+ function smallestByEpc(available, need) {
51
+ if (need <= 0)
42
52
  return { picked: [], total: 0 };
43
53
  const out = [];
54
+ /* 버퍼가 들고 있는 수 — 요청을 덮는 **가장 짧은 앞머리**만 남긴다. */
55
+ let held = 0;
44
56
  let total = 0;
45
57
  for (const s of available) {
46
- total++;
47
- if (out.length === k && byId(s.epc, out[out.length - 1].epc) >= 0)
58
+ const q = qtyOf(s);
59
+ total += q;
60
+ if (q === 0)
61
+ continue;
62
+ /* 이미 덮었고 이 줄이 마지막 것보다 뒤면 볼 것 없다 — 순회 한 번을 지키는 자리다. */
63
+ if (held >= need && byId(s.epc, out[out.length - 1].epc) >= 0)
48
64
  continue;
49
65
  let i = out.length;
50
66
  while (i > 0 && byId(out[i - 1].epc, s.epc) > 0)
51
67
  i--;
52
68
  out.splice(i, 0, s);
53
- if (out.length > k)
54
- out.pop();
69
+ held += q;
70
+ /* 뒤에서부터 덜어 낸다 — 빼도 여전히 덮이면 그 줄은 필요 없다. */
71
+ while (out.length > 0 && held - qtyOf(out[out.length - 1]) >= need)
72
+ held -= qtyOf(out.pop());
55
73
  }
56
74
  return { picked: out, total };
57
75
  }
76
+ /**
77
+ * 고른 줄들에서 요청한 **수만큼** 떼어 낸다 — 마지막 줄은 일부만 잡힐 수 있다.
78
+ *
79
+ * 「100 중 30」이 여기서 생긴다. 직렬 물품(`qty: 1`)은 예전과 같이 줄마다 하나씩이다.
80
+ */
81
+ function takeFrom(views, need) {
82
+ const out = [];
83
+ let left = need;
84
+ for (const s of views) {
85
+ if (left <= 0)
86
+ break;
87
+ const take = Math.min(left, qtyOf(s));
88
+ if (take <= 0)
89
+ continue;
90
+ out.push({ epc: s.epc, qty: take });
91
+ left -= take;
92
+ }
93
+ return out;
94
+ }
58
95
  /**
59
96
  * 기본 정책 — first-fit slot + all-or-nothing 오더 할당.
60
97
  * (재고가 요청 전량을 못 채우면 이번 라운드 보류 → 전량 확보 시 단일 출하.)
@@ -62,11 +99,11 @@ function smallestByEpc(available, k) {
62
99
  export const firstFitPolicy = {
63
100
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
64
101
  selectStock: ({ qty, available }) => {
65
- /* 고르는 것과 세는 것을 **한 번의 순회**로 한다 — 전량 확보 판정에 개수가 필요하다. */
102
+ /* 고르는 것과 세는 것을 **한 번의 순회**로 한다 — 전량 확보 판정에 총수가 필요하다. */
66
103
  const { picked, total } = smallestByEpc(available, qty);
67
104
  if (total < qty)
68
- return []; // 전량 확보 전엔 대기
69
- return picked.map(s => s.epc);
105
+ return []; // 전량 확보 전엔 대기 — 세는 것은 줄 수가 아니라 수다
106
+ return takeFrom(picked, qty);
70
107
  }
71
108
  };
72
109
  /**
@@ -75,7 +112,7 @@ export const firstFitPolicy = {
75
112
  */
76
113
  export const partialFitPolicy = {
77
114
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
78
- selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked.map(s => s.epc)
115
+ selectStock: ({ qty, available }) => takeFrom(smallestByEpc(available, qty).picked, qty)
79
116
  };
80
117
  /**
81
118
  * FEFO 정책 — First-Expired-First-Out. 만료 임박 로트를 먼저 출고(신선/제약 도메인).
@@ -86,9 +123,38 @@ export const fefoPolicy = {
86
123
  selectStock: ({ qty, available }) => {
87
124
  /* 만료 순이 필요하므로 전체를 펼친다 — 이 정책이 실제로 필요해서 치르는 비용이다. */
88
125
  const all = [...available];
89
- if (all.length < qty)
126
+ const total = all.reduce((sum, s) => sum + qtyOf(s), 0);
127
+ if (total < qty)
90
128
  return []; // 전량 확보 전엔 대기
91
129
  const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
92
- return byExpiry.slice(0, qty).map(s => s.epc);
130
+ return takeFrom(byExpiry, qty);
93
131
  }
94
132
  };
133
+ /**
134
+ * 이름 → 정책 — **닫힌 목록**(계약의 `ALLOCATION_POLICY`).
135
+ *
136
+ * 선언은 「이 현장은 어느 규칙으로 고르나」이고(트윈 모델의 `allocationPolicy`), 그것을 창고 앱과
137
+ * 커널이 **같이** 읽는다. 등록부가 없으면 선언이 이름만 남고 커널은 계속 기본 정책으로 돈다 —
138
+ * 그러면 트윈이 그 창고의 미래를 다른 규칙으로 실행한다(ADR-0076 결정 1).
139
+ */
140
+ export const ALLOCATION_POLICIES = {
141
+ [ALLOCATION_POLICY.firstFit]: firstFitPolicy,
142
+ [ALLOCATION_POLICY.partialFit]: partialFitPolicy,
143
+ [ALLOCATION_POLICY.fefo]: fefoPolicy
144
+ };
145
+ /**
146
+ * 선언한 이름의 정책을 찾는다. **모르는 이름은 거절한다** — 기본값으로 조용히 떨어지면 선언한
147
+ * 규칙과 도는 규칙이 갈리고, 그 사실은 아무 데도 나타나지 않는다.
148
+ *
149
+ * 선언이 없으면(`undefined`) 기본 정책이다 — 선언하지 않은 현장은 지금까지와 같이 돈다.
150
+ */
151
+ export function allocationPolicyOf(name) {
152
+ const key = String(name ?? '').trim();
153
+ if (!key)
154
+ return firstFitPolicy;
155
+ const found = ALLOCATION_POLICIES[key];
156
+ if (!found) {
157
+ throw new Error(`unknown allocation policy "${key}" — declare one of: ${Object.keys(ALLOCATION_POLICIES).join(' · ')}`);
158
+ }
159
+ return found;
160
+ }
@@ -1,4 +1,4 @@
1
- import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact } from '@operato/ops-contract';
1
+ import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact, AllocatedQuantity, MaterialLotUse } from '@operato/ops-contract';
2
2
  import { type RatedUsage, type UsedUsage } from '@operato/ops-contract';
3
3
  import type { VocabularyElement } from '@operato/ops-contract';
4
4
  import type { ReducerCheckpoint } from './observed-reducer.ts';
@@ -42,6 +42,16 @@ export interface FlowItem {
42
42
  /** 이 물류단위를 싣고 있는 반복사용 자산(GRAI) — `parent` 와 다른 축. */
43
43
  carriedBy?: string;
44
44
  expiry?: number;
45
+ /**
46
+ * **이 재고가 들어온 시각**(sim-ms) — receiving 관측의 `eventTime` 이다(수집 시각이 아니다).
47
+ *
48
+ * 「먼저 들어온 것 먼저」를 실제로 하려면 이 축이 있어야 한다 — 지금 시각 축은 만료(`expiry`)
49
+ * 뿐이고, 그래서 `firstFit` 은 FIFO 가 아니라 식별자 순이다(ADR-0076 정정 ②).
50
+ *
51
+ * **없는 값이 있다**: 씨앗으로 선 재고, 입고 기록 없이 서 있던 재고. 없음을 시각으로 메우지
52
+ * 않는다 — 0 도 지금도 없는 근거다(§`StockView.receivedAtMs`).
53
+ */
54
+ receivedAtMs?: number;
45
55
  /** 개체·로트 마스터데이터 원문 — 생겨날 때 정해지고 뒤 이벤트가 지우지 않는다. */
46
56
  ilmd?: Record<string, unknown>;
47
57
  /** 선언된 모든 수량 — 표준 `MaterialLot.Quantity`(복수). 관측에서 온 물품이 여러 단위를 들 수 있다. */
@@ -53,6 +63,13 @@ export interface FlowItem {
53
63
  */
54
64
  nonconformance?: DispositionFact;
55
65
  status?: string;
66
+ /**
67
+ * 그 상태의 **효과** — 이 로트를 쓸 수 있나(계약 `MATERIAL_LOT_USE`). 할당이 읽는 유일한 칸이다.
68
+ *
69
+ * 낱말(`status`)은 현장의 것이라 커널이 읽지 않는다 — 읽으면 현장이 다른 낱말을 쓰는 날 보류가
70
+ * 조용히 할당에 든다. **없으면 「모름」이다**(`allowed` 가 아니다).
71
+ */
72
+ use?: MaterialLotUse;
56
73
  }
57
74
  /** 물리 자산 — 반복사용(팔레트·랙·용기). 설비도 물품도 아니다(GRAI vs SSCC 구분은 계약 주석 참조). */
58
75
  export interface FlowAsset extends EffectivePeriod {
@@ -239,7 +256,20 @@ export interface FlowTask {
239
256
  export interface OrderLine {
240
257
  gtin: string;
241
258
  requested: number;
259
+ /** 이 줄이 이행한 수 — 입고 오더는 「몇 개 받았나」다. */
260
+ fulfilled?: number;
261
+ /**
262
+ * 이 줄이 **잡아 둔 수** — 「100 중 30 을 잡았다」의 30(계약 `ObservedOrderLine.allocated`).
263
+ *
264
+ * 전량 확보는 물류단위의 처분 관측으로도 나가지만 부분 확보는 그렇게 적을 수 없다(나머지까지
265
+ * 잡힌 것으로 읽힌다 — ADR-0076 결정 2 의 정정). 그 수가 사는 자리가 여기다.
266
+ */
267
+ allocated?: number;
242
268
  }
269
+ /** 잡아 둔 줄들의 **합계** — 줄 수가 아니라 수다(직렬 물품은 둘이 같다). */
270
+ export declare function allocatedQty(rows: readonly AllocatedQuantity[] | undefined): number;
271
+ /** 잡아 둔 줄들의 식별자 — EPCIS 이벤트가 싣는 것은 열쇠뿐이다. */
272
+ export declare function allocatedEpcs(rows: readonly AllocatedQuantity[] | undefined): string[];
243
273
  export interface FlowOrder {
244
274
  id: string;
245
275
  kind: string;
@@ -247,7 +277,13 @@ export interface FlowOrder {
247
277
  requested: number;
248
278
  fulfilled: number;
249
279
  bizTransaction: string;
250
- allocated: string[];
280
+ /**
281
+ * 이 오더가 잡아 둔 줄들 — **무엇을 얼마나**(계약 `AllocatedQuantity`).
282
+ *
283
+ * 2026-09-19 에 EPC 목록에서 넓혔다: 한 EPC = 한 개라는 전제가 수량 로트(케이스 100 중 30 ·
284
+ * 밀가루 3.5kg)를 적지 못했다. 직렬 물품은 `qty: 1` 이고 거동이 같다.
285
+ */
286
+ allocated: AllocatedQuantity[];
251
287
  picked: string[];
252
288
  gtin?: string;
253
289
  shipmentEpc?: string | null;
@@ -633,6 +669,8 @@ export declare abstract class FlowEngine implements TwinKernel {
633
669
  * 부족한 씨앗 위에서 낸 답을 완전한 답으로 읽는다.
634
670
  */
635
671
  private seedDanglingRefs;
672
+ /** 로트 상태를 모르는 채로 할당 후보에 든 수 — 「모름」을 안전으로 읽지 않기 위한 셈(ADR-0080 결정 2). */
673
+ private allocatedWithUnknownLotUse;
636
674
  private transformInputsAbsent;
637
675
  /**
638
676
  * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
@@ -771,6 +809,19 @@ export declare abstract class FlowEngine implements TwinKernel {
771
809
  * 그래서 심을 때 맞춘다. **없는 물품을 만들어 채우지 않는다** — 채우면 계보와 재고가 거짓 위에 선다.
772
810
  * 대신 몇 건이 맞지 않았는지 남겨 씨앗이 불완전했다는 사실을 답에 실을 수 있게 한다.
773
811
  */
812
+ /**
813
+ * **이 로트를 할당 후보에 넣어도 되나** — 판정은 이 한 곳이다(ADR-0080 결정 2).
814
+ *
815
+ * 읽는 것은 효과(`use`)뿐이고 낱말(`status`)은 읽지 않는다. 낱말을 읽으면 현장이 `quarantine` ·
816
+ * `검사대기` 를 쓰는 날 **오류 없이** 보류된 로트가 할당에 든다.
817
+ *
818
+ * **말해 주지 않은 로트는 「모름」이다.** 지금 거동 그대로 후보에 넣되 그 수를 센다 — 멈추면 로트
819
+ * 상태를 아직 안 보내는 현장의 트윈이 통째로 서고, 「모름」을 안전으로 읽으면 보류가 새는 것을
820
+ * 아무도 모른다. 답에 실린 그 수가 「이 원본은 아직 상태를 안 보낸다」를 말한다.
821
+ */
822
+ protected usableLot(item: {
823
+ use?: MaterialLotUse;
824
+ }): boolean;
774
825
  private resolvedAllocated;
775
826
  hydrateObserved(snap: {
776
827
  locations: LocationState[];
@@ -13,7 +13,9 @@ import { OP_EVENT, CMD, USE_UOM, locationStatusOf, readBoardEquipment, equipment
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  /* 주체를 정하는 규칙은 유입 문과 한 벌이다 — 두 곳에 적으면 한쪽만 고쳐진다. */
15
15
  import { resolveSubject } from '@operato/ops-contract';
16
+ import { WMS_ORDER_KIND } from '@operato/ops-contract';
16
17
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from '@operato/ops-contract';
18
+ import { allocationPolicyOf } from "./allocation-policy.js";
17
19
  import { OP_PARAM } from '@operato/ops-contract';
18
20
  import { parseIsoDuration } from '@operato/ops-contract';
19
21
  import { analyzeCapacity } from '@operato/ops-contract';
@@ -33,6 +35,14 @@ function effectiveOnly(r) {
33
35
  ...(r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {})
34
36
  };
35
37
  }
38
+ /** 잡아 둔 줄들의 **합계** — 줄 수가 아니라 수다(직렬 물품은 둘이 같다). */
39
+ export function allocatedQty(rows) {
40
+ return (rows ?? []).reduce((sum, a) => sum + (Number(a?.qty) || 0), 0);
41
+ }
42
+ /** 잡아 둔 줄들의 식별자 — EPCIS 이벤트가 싣는 것은 열쇠뿐이다. */
43
+ export function allocatedEpcs(rows) {
44
+ return (rows ?? []).map(a => a.epc);
45
+ }
36
46
  function mulberry32(seed) {
37
47
  let a = seed >>> 0;
38
48
  const fn = (() => {
@@ -743,6 +753,8 @@ export class FlowEngine {
743
753
  * 부족한 씨앗 위에서 낸 답을 완전한 답으로 읽는다.
744
754
  */
745
755
  seedDanglingRefs = 0;
756
+ /** 로트 상태를 모르는 채로 할당 후보에 든 수 — 「모름」을 안전으로 읽지 않기 위한 셈(ADR-0080 결정 2). */
757
+ allocatedWithUnknownLotUse = 0;
746
758
  transformInputsAbsent = 0;
747
759
  /**
748
760
  * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
@@ -784,6 +796,18 @@ export class FlowEngine {
784
796
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
785
797
  loadTwinModel(def) {
786
798
  this.boardDef = def;
799
+ /*
800
+ * **선언한 규칙으로 고른다** (2026-09-19, ADR-0076 결정 1).
801
+ *
802
+ * 고르는 일은 기록의 주인(창고 앱)이 하고 예측 · what-if 는 커널이 한다. 둘이 같은 선언을 읽지
803
+ * 않으면 트윈이 그 현장의 미래를 **다른 규칙으로** 실행한다. 선언이 없으면 생성자가 준 정책
804
+ * 그대로다 — 지금까지 세운 트윈은 아무것도 바뀌지 않는다.
805
+ *
806
+ * 모르는 이름은 `allocationPolicyOf` 가 거절한다. 기본값으로 조용히 떨어지면 선언한 규칙과 도는
807
+ * 규칙이 갈리고 그 사실이 아무 데도 나타나지 않는다.
808
+ */
809
+ if (def.allocationPolicy)
810
+ this.policy = allocationPolicyOf(def.allocationPolicy);
787
811
  /*
788
812
  * **생산 선언은 어느 커널이든 싣는다.** 예전에는 MES 커널만 생성자에서 이것을 실었고(그 시절
789
813
  * 이름은 `mesSpec` 이었다), 그래서 창고 트윈은 "자재를 소비해 자재를 산출하는 공정" 을 선언할
@@ -980,11 +1004,28 @@ export class FlowEngine {
980
1004
  * 그래서 심을 때 맞춘다. **없는 물품을 만들어 채우지 않는다** — 채우면 계보와 재고가 거짓 위에 선다.
981
1005
  * 대신 몇 건이 맞지 않았는지 남겨 씨앗이 불완전했다는 사실을 답에 실을 수 있게 한다.
982
1006
  */
1007
+ /**
1008
+ * **이 로트를 할당 후보에 넣어도 되나** — 판정은 이 한 곳이다(ADR-0080 결정 2).
1009
+ *
1010
+ * 읽는 것은 효과(`use`)뿐이고 낱말(`status`)은 읽지 않는다. 낱말을 읽으면 현장이 `quarantine` ·
1011
+ * `검사대기` 를 쓰는 날 **오류 없이** 보류된 로트가 할당에 든다.
1012
+ *
1013
+ * **말해 주지 않은 로트는 「모름」이다.** 지금 거동 그대로 후보에 넣되 그 수를 센다 — 멈추면 로트
1014
+ * 상태를 아직 안 보내는 현장의 트윈이 통째로 서고, 「모름」을 안전으로 읽으면 보류가 새는 것을
1015
+ * 아무도 모른다. 답에 실린 그 수가 「이 원본은 아직 상태를 안 보낸다」를 말한다.
1016
+ */
1017
+ usableLot(item) {
1018
+ if (item.use === 'blocked')
1019
+ return false;
1020
+ if (item.use === undefined)
1021
+ this.allocatedWithUnknownLotUse++;
1022
+ return true;
1023
+ }
983
1024
  resolvedAllocated(allocated) {
984
1025
  const all = allocated ?? [];
985
1026
  if (!all.length)
986
1027
  return { kept: [], dropped: 0 };
987
- const kept = all.filter(epc => this.items.has(epc));
1028
+ const kept = all.filter(a => this.items.has(a.epc));
988
1029
  const dropped = all.length - kept.length;
989
1030
  this.seedDanglingRefs += dropped;
990
1031
  return { kept, dropped };
@@ -1041,7 +1082,9 @@ export class FlowEngine {
1041
1082
  * 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
1042
1083
  */
1043
1084
  ...(it.nonconformance ? { nonconformance: it.nonconformance } : {}),
1044
- ...(it.status ? { status: it.status } : {})
1085
+ ...(it.status ? { status: it.status } : {}),
1086
+ /* 그 상태의 효과 — 이 칸이 빠지면 미러가 보류를 알고도 할당이 모른다(ADR-0080 결정 2). */
1087
+ ...(it.use ? { use: it.use } : {})
1045
1088
  });
1046
1089
  }
1047
1090
  for (const m of snap.equipment) {
@@ -1645,11 +1688,12 @@ export class FlowEngine {
1645
1688
  nowTime: this.now(), // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
1646
1689
  identityGrounding: this.identityGroundingView(),
1647
1690
  /* 원본과 어긋난 사실 — 0 이면 싣지 않는다(어긋난 적 없는 트윈에 빈 칸을 만들지 않는다). */
1648
- ...(this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps
1691
+ ...(this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps || this.allocatedWithUnknownLotUse
1649
1692
  ? { conformance: {
1650
1693
  ...(this.transformInputsAbsent ? { transformInputsAbsent: this.transformInputsAbsent } : {}),
1651
1694
  ...(this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {}),
1652
- ...(this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {})
1695
+ ...(this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {}),
1696
+ ...(this.allocatedWithUnknownLotUse ? { allocatedWithUnknownLotUse: this.allocatedWithUnknownLotUse } : {})
1653
1697
  } }
1654
1698
  : {}),
1655
1699
  /* 출처 표시 — 트윈 모델(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
@@ -3879,7 +3923,9 @@ export class FlowEngine {
3879
3923
  ...(i.ilmd ? { ilmd: i.ilmd } : {}),
3880
3924
  /* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
3881
3925
  ...(i.nonconformance ? { nonconformance: i.nonconformance } : {}),
3882
- ...(i.status ? { status: i.status } : {})
3926
+ ...(i.status ? { status: i.status } : {}),
3927
+ /* 그 상태의 효과 — 화면과 다음 씨앗이 읽는다(§`ItemState.use`). */
3928
+ ...(i.use ? { use: i.use } : {})
3883
3929
  };
3884
3930
  }
3885
3931
  /**
@@ -3934,7 +3980,12 @@ export class FlowEngine {
3934
3980
  processOrders() {
3935
3981
  /* **우선순위 순으로 할당한다** — 표준 `OperationsRequest.Priority`. 같은 우선순위는 입력 순서를
3936
3982
  지켜 결정성을 잃지 않는다(정렬이 안정적이어야 같은 seed 가 같은 결과를 낸다). */
3937
- const pending = [...this.orders.values()].filter(o => o.status === 'created' && !o.held);
3983
+ /*
3984
+ * **입고 오더는 고르기의 대상이 아니다** (2026-09-19). 그 오더가 기다리는 것은 재고가 아니라
3985
+ * 물건의 도착이고, 커널이 그것을 고를 수는 없다. 걸러 내지 않으면 `allocate` 가 매 틱마다 라인을
3986
+ * 들고 재고를 뒤지고, 못 찾아 `shortage` 를 적는다 — 「입고 대기」가 「자재 부족」으로 보인다.
3987
+ */
3988
+ const pending = [...this.orders.values()].filter(o => o.status === 'created' && !o.held && o.kind !== WMS_ORDER_KIND.inbound);
3938
3989
  if (pending.some(o => o.priority !== undefined))
3939
3990
  pending.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
3940
3991
  for (const o of pending)
package/dist/kernel.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { CMD } from '@operato/ops-contract';
9
9
  import { firstFitPolicy } from "./allocation-policy.js";
10
- import { FlowEngine } from "./flow-engine.js";
10
+ import { FlowEngine, allocatedQty } from "./flow-engine.js";
11
11
  import { planMakeToOrder } from "./make-to-order.js";
12
12
  import { BIZSTEP, BTT, WMS_ORDER_KIND } from '@operato/ops-contract';
13
13
  import { DISP, ILMD_ATTR, aggregationEvent, objectEvent, transactionEvent } from '@operato/ops-contract';
@@ -64,7 +64,11 @@ export class WmsKernel extends FlowEngine {
64
64
  /* 방출한 마스터데이터를 **상태에도 들고 있는다** — 자기가 선언한 것을 자기가 모르면, 미러는 알고
65
65
  * 시뮬은 모르는 비대칭이 생긴다(파리티 테스트가 잡은 바로 그 종류). */
66
66
  const ilmd = { [ILMD_ATTR.expiry]: expiry };
67
- this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
67
+ /*
68
+ * 들어온 시각을 재고가 들고 있는다 — 「먼저 들어온 것 먼저」를 할 수 있는 유일한 근거다.
69
+ * 값은 **receiving 이 일어난 시각**이고(수집 시각이 아니다), 여기서는 그 둘이 같다.
70
+ */
71
+ this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd, receivedAtMs: this.nowMs() });
68
72
  dock.occupancy++;
69
73
  this.emit(transactionEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
70
74
  this.emit(aggregationEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
@@ -152,17 +156,28 @@ export class WmsKernel extends FlowEngine {
152
156
  const staging = this.builtInLocation('staging', 'picked pallets wait here before shipping');
153
157
  const chosenAll = [];
154
158
  for (const line of o.lines) {
155
- const already = o.allocated.filter(e => this.items.get(e)?.gtin === line.gtin).length;
159
+ const already = allocatedQty(o.allocated.filter(a => this.items.get(a.epc)?.gtin === line.gtin));
156
160
  const need = line.requested - already;
157
161
  if (need <= 0)
158
162
  continue;
163
+ /*
164
+ * ── 이 커널이 잡는 단위는 **팔레트 하나**다 (2026-09-19) ────────────────
165
+ *
166
+ * 요청(`line.requested`)도 팔레트 수이고, 뒤의 걸음(pick · pack · 라인 차감)이 전부 팔레트를
167
+ * 하나씩 움직인다. 그래서 후보 줄도 **1** 을 낸다 — 팔레트 안의 케이스 수(`i.qty`)를 내면
168
+ * 요청과 단위가 갈려 두 배로 잡는다.
169
+ *
170
+ * 수량으로 잡는 쪽은 MES 의 자재 확보다(밀가루 3.5kg — `mes-kernel.ts`). 시임은 두 단위를
171
+ * 모두 표현할 수 있고, **단위를 정하는 것은 부르는 쪽**이다.
172
+ */
159
173
  const available = [...this.items.values()]
160
174
  .filter(i => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'storage')
161
- .map(i => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
175
+ .filter(i => this.usableLot(i))
176
+ .map(i => ({ epc: i.epc, location: i.location, qty: 1, expiry: i.expiry, ...(i.receivedAtMs !== undefined ? { receivedAtMs: i.receivedAtMs } : {}) }));
162
177
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
163
- for (const epc of chosen) {
164
- o.allocated.push(epc);
165
- chosenAll.push(epc);
178
+ for (const row of chosen) {
179
+ o.allocated.push(row);
180
+ chosenAll.push(row.epc);
166
181
  }
167
182
  }
168
183
  /*
@@ -369,7 +384,7 @@ export class WmsKernel extends FlowEngine {
369
384
  order.status = 'packed';
370
385
  this.emit(objectEvent({ eventTime, action: 'OBSERVE', bizStep: BIZSTEP.staging_outbound, disposition: DISP.reserved, epcList: [shipment], readPoint: staging.id, bizLocation: staging.id, bizTransactionList: soTxn }));
371
386
  this.emit(objectEvent({ eventTime, action: 'DELETE', bizStep: BIZSTEP.shipping, disposition: DISP.in_transit, epcList: [shipment], readPoint: shipDock.id, bizTransactionList: soTxn }));
372
- order.fulfilled += order.allocated.length;
387
+ order.fulfilled += allocatedQty(order.allocated);
373
388
  // 라인 잔량 차감(멀티SKU 백오더) — 출하 팔레트의 gtin 으로 라인 매핑(라인 SKU distinct).
374
389
  if (order.lines)
375
390
  for (const epc of order.picked) {
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { identityGroundingOf, procedureViolations } from '@operato/ops-contract';
12
12
  import { firstFitPolicy } from "./allocation-policy.js";
13
- import { FlowEngine } from "./flow-engine.js";
13
+ import { FlowEngine, allocatedEpcs, allocatedQty } from "./flow-engine.js";
14
14
  import { DISP, objectEvent, transactionEvent, gdtiUri, sgtinClass, bizTransactionUri } from '@operato/ops-contract';
15
15
  import { MES_BIZSTEP, BTT_PRODORDER, sgtinUri } from '@operato/ops-contract';
16
16
  import { OP_PARAM } from '@operato/ops-contract';
@@ -426,8 +426,9 @@ export class MesKernel extends FlowEngine {
426
426
  const order = t.orderId ? this.orders.get(t.orderId) : undefined;
427
427
  if (!order)
428
428
  return false;
429
+ /* 자리가 만든 것도 오더가 들고 있는 것에 들어간다 — 직렬 개체라 줄마다 하나다. */
429
430
  for (const epc of epcs)
430
- order.allocated.push(epc);
431
+ order.allocated.push({ epc, qty: 1 });
431
432
  return true;
432
433
  }
433
434
  // ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
@@ -818,12 +819,12 @@ export class MesKernel extends FlowEngine {
818
819
  }
819
820
  else {
820
821
  for (const i of this.items.ofGtin(g)) {
821
- if (i.disposition === DISP.sellable)
822
+ if (i.disposition === DISP.sellable && this.usableLot(i))
822
823
  available.push({ epc: i.epc, location: i.location, qty: 1 });
823
824
  }
824
825
  }
825
826
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
826
- if (chosen.length < line.qty) {
827
+ if (allocatedQty(chosen) < line.qty) {
827
828
  /*
828
829
  * **왜 대기하는지 남긴다** (2026-08-21).
829
830
  *
@@ -834,7 +835,7 @@ export class MesKernel extends FlowEngine {
834
835
  * 첫 줄에서 멈추지 않고 **모자란 줄을 다 센다.** 하나만 알려 주면 그것을 채운 뒤 다음 줄에서
835
836
  * 또 막히고, 사람은 같은 진단을 여섯 번 반복한다.
836
837
  */
837
- short.push({ material: line.material, need: line.qty, have: chosen.length });
838
+ short.push({ material: line.material, need: line.qty, have: allocatedQty(chosen) });
838
839
  continue;
839
840
  }
840
841
  picks.push(...chosen);
@@ -848,11 +849,11 @@ export class MesKernel extends FlowEngine {
848
849
  /* 채워졌으면 표시를 지운다 — 남겨 두면 화면이 이미 해결된 것을 계속 말한다. */
849
850
  if (o.shortage)
850
851
  delete o.shortage;
851
- for (const epc of picks)
852
- o.allocated.push(epc);
853
- this.reserve(picks, MES_BIZSTEP.producing);
854
- this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
855
- this.emitStationDef(o, ops[0], o.allocated[0]);
852
+ for (const row of picks)
853
+ o.allocated.push(row);
854
+ this.reserve(allocatedEpcs(picks), MES_BIZSTEP.producing);
855
+ this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: allocatedEpcs(o.allocated) }));
856
+ this.emitStationDef(o, ops[0], o.allocated[0]?.epc);
856
857
  o.status = 'op-' + ops[0].key;
857
858
  this.emitOrder(o);
858
859
  }
@@ -894,13 +895,13 @@ export class MesKernel extends FlowEngine {
894
895
  const producedKey = this.producedMaterialKeyOf(t.kind);
895
896
  const next = ops[i + 1];
896
897
  if (producedKey) {
897
- const inputs = order.allocated.slice();
898
+ const inputs = allocatedEpcs(order.allocated);
898
899
  const made = this.serialOf(producedKey, ++this.wipSeq);
899
900
  this.transform(inputs, [{ epc: made, gtin: this.classOf(producedKey), qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
900
- order.allocated = [made];
901
+ order.allocated = [{ epc: made, qty: 1 }];
901
902
  }
902
903
  /* 다음 자리에 무엇을 들고 가는가 — 만든 것이 있으면 그것, 없으면 들고 있던 것. */
903
- const carried = order.allocated[0];
904
+ const carried = order.allocated[0]?.epc;
904
905
  /*
905
906
  * ── 개체가 없어도 **진행은 막지 않는다** (2026-08-24) ───────────────────────
906
907
  * 예전에는 여기서 무조건 던졌다. 그래서 **미러에서 예측이 구조적으로 실패했다**: 미러의 오더는
@@ -962,7 +963,7 @@ export class MesKernel extends FlowEngine {
962
963
  *
963
964
  * 중간 산출물을 선언한 경로에서는 `allocated` 가 이미 `[made]` 하나이므로 거동이 같다.
964
965
  */
965
- const consumed = order.allocated.slice();
966
+ const consumed = allocatedEpcs(order.allocated);
966
967
  if (!consumed.length) {
967
968
  /*
968
969
  * 씨앗이 이 오더의 확보분을 다 심지 못했으면(원본이 말한 물품이 스냅샷에 없었다) 이 오더는
@@ -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 { MaterialLotUse } from '@operato/ops-contract';
2
3
  import { type VocabularyElement } from '@operato/ops-contract';
3
4
  interface ProjItem {
4
5
  epc: string;
@@ -14,6 +15,8 @@ interface ProjItem {
14
15
  nonconformance?: DispositionFact;
15
16
  /** ISA-95 `MaterialLot.Status` — `nonconformance` 와 다른 칸(§계약 `ItemState.status`). 마지막 하나만. */
16
17
  status?: string;
18
+ /** 그 상태의 효과 — 이 로트를 쓸 수 있나(계약 `MATERIAL_LOT_USE`). 없으면 「모름」이다. */
19
+ use?: MaterialLotUse;
17
20
  parent?: string;
18
21
  qty?: number;
19
22
  uom?: string;
@@ -583,8 +583,20 @@ export class ObservedReducer {
583
583
  this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
584
584
  break;
585
585
  }
586
- for (const part of parts)
586
+ /*
587
+ * 낱말과 **효과**를 함께 앉힌다(ADR-0080 결정 2). 낱말은 사람이 읽고, 효과(`use`)는 할당이 읽는다.
588
+ *
589
+ * 레코드가 효과를 말하지 않으면 **비운다.** 앞의 값을 남기면 원본이 상태를 바꿨는데 효과는 옛
590
+ * 결정으로 남는다 — 보류가 풀렸는데 계속 막히거나, 그 반대가 된다. 비어 있는 것은 「모름」이고
591
+ * 할당이 그것을 세어 답에 싣는다.
592
+ */
593
+ for (const part of parts) {
587
594
  part.status = d.status;
595
+ if (d.use)
596
+ part.use = d.use;
597
+ else
598
+ delete part.use;
599
+ }
588
600
  break;
589
601
  }
590
602
  case OP_EVENT.observation: {
@@ -958,6 +970,28 @@ export class ObservedReducer {
958
970
  /* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
959
971
  * 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
960
972
  if (!ev.epcList?.length) {
973
+ /*
974
+ * ── 자리를 말하지 않은 수량 관측은 **앉히지 않는다** (2026-09-19, ADR-0082) ──
975
+ *
976
+ * 열쇠가 `${epcClass}@${자리}` 이므로 자리가 없으면 `@undefined` 라는 줄이 선다. 그 줄은 어느
977
+ * 자리의 재고도 아닌데 합에는 든다 — 실측: 자리 있는 100 + 자리 없는 40 = 140, 그리고 아무 데도
978
+ * 세어지지 않았다.
979
+ *
980
+ * **전수로 세어 보니 일부러 그렇게 보내는 원본이 없다** — 수량 관측을 만드는 여덟 자리(chef v1
981
+ * 셋 · v2 하나 · warehouse 입고 둘 · 출고 둘)가 전부 `readPoint` 를 싣고, sap-ewm · oracle-wms ·
982
+ * ppms · ems 는 수량 관측을 내지 않는다. 그래서 잃는 원본 없이 닫을 수 있다.
983
+ *
984
+ * 「없어졌다」(`DELETE`)는 **건드리지 않는다** — 그쪽에서 자리를 말하지 않은 것은 「그 로트가
985
+ * 통째로 없어졌다」는 진술이고 위에서 그 범위대로 지운다.
986
+ *
987
+ * 순서 판정(`stale`)을 지나지 않는다. 그 함수가 트윈의 「지금」을 미는 유일한 자리인데, 받아들이지
988
+ * 않은 사실로 시계를 움직이면 이 트윈이 「방금 들었다」고 말하면서 아무것도 앉히지 않은 것이 된다.
989
+ */
990
+ if (!loc && (ev.quantityList ?? []).some(qe => qe?.epcClass)) {
991
+ if (envelope)
992
+ this.noteUnhandled(envelope, 'epcis.ObjectEvent:quantity-without-readpoint');
993
+ return;
994
+ }
961
995
  for (const qe of ev.quantityList ?? []) {
962
996
  if (!qe?.epcClass)
963
997
  continue;
@@ -77,7 +77,7 @@ export class YmsKernel extends FlowEngine {
77
77
  const id = `order-${++this.orderSeq}`;
78
78
  const apSeq = ++this.soSeq;
79
79
  const appt = this.requireBizTransactionId(`APPT-${apSeq}`, 'appointment');
80
- const order = { id, kind, status: 'created', requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [epc], picked: [], dockDoor: door.id,
80
+ const order = { id, kind, status: 'created', requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [{ epc, qty: 1 }], picked: [], dockDoor: door.id,
81
81
  /* 내부 스케줄 게이트(시뮬 ms) — 표준 `EarliestStartTime` 과 같은 개념의 우리 단위. */
82
82
  windowStartMs: this.clockMs + WINDOW_DELAY_MS,
83
83
  /* 표준 필드로도 함께 낸다 — 소비처(화면·집계·미러)는 표준 축으로 읽는다.
@@ -105,7 +105,8 @@ export class YmsKernel extends FlowEngine {
105
105
  * 하나라도 안 되면 대기(다음 tick 재시도). = 시간창 예약 스케줄링.
106
106
  */
107
107
  allocate(o) {
108
- const trailer = this.items.get(o.allocated[0]);
108
+ /* 어포인트먼트가 든 것은 트레일러 하나다 — 줄마다 하나이므로 첫 줄의 열쇠를 본다. */
109
+ const trailer = this.items.get(o.allocated[0]?.epc);
109
110
  if (!trailer)
110
111
  return;
111
112
  if (this.clockMs < (o.windowStartMs ?? 0))
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/index.ts
20
20
  var index_exports = {};
21
21
  __export(index_exports, {
22
+ ALLOCATION_POLICIES: () => ALLOCATION_POLICIES,
22
23
  ATTENTION_DEFAULTS: () => ATTENTION_DEFAULTS,
23
24
  ATTENTION_PROPERTY: () => ATTENTION_PROPERTY,
24
25
  ClassMasterTable: () => ClassMasterTable,
@@ -36,6 +37,9 @@ __export(index_exports, {
36
37
  TwinRuntime: () => TwinRuntime,
37
38
  WmsKernel: () => WmsKernel,
38
39
  YmsKernel: () => YmsKernel,
40
+ allocatedEpcs: () => allocatedEpcs,
41
+ allocatedQty: () => allocatedQty,
42
+ allocationPolicyOf: () => allocationPolicyOf,
39
43
  attributeEnergy: () => attributeEnergy,
40
44
  compareStates: () => compareStates,
41
45
  constantDuration: () => constantDuration,
@@ -247,6 +251,7 @@ function locationStatusOf(n) {
247
251
  const r = (n.occupancy ?? 0) / cap;
248
252
  return r >= 1 ? "full" : r >= LOCATION_SATURATION_NEAR ? "near-full" : "available";
249
253
  }
254
+ var MATERIAL_LOT_USE = ["allowed", "blocked"];
250
255
  var DISPOSITION_DECISION = [
251
256
  /** 원 규격으로 되돌린다 — 공정에 다시 들어가고, 산출물은 원 품목이다. */
252
257
  "rework",
@@ -660,15 +665,20 @@ var OP_EVENT = {
660
665
  * 로트에도 붙는 상태(released · quarantine · on-hold …). 출하 승인(batch release)이 이 축이다. 처분에 실으면
661
666
  * 정상 출하가 전부 부적합 이력이 된다 — 진짜 term 을 틀린 뜻으로 쓰는 것이 자리가 없는 것보다 나쁘다.
662
667
  *
663
- * ── 낱말은 열려 있다 ──────────────────────────────────────────────────────
664
- * 오더 상태와 같은 규율 — 커널이 이 낱말로 무엇을 계산하지 않으므로 닫을 근거가 없다. 도메인이 소유한다.
668
+ * ── 낱말은 열려 있고, **효과는 닫혀 있다** (2026-09-19, ADR-0080 결정 2) ────
669
+ * `status` 는 도메인이 소유한다 — 오더 상태와 같은 규율이다. 커널은 그 낱말로 계산하지 않는다.
670
+ *
671
+ * 계산하는 것은 **`use`** 다(§`MATERIAL_LOT_USE`): 닫힌 둘이고, 「이 로트를 쓸 수 있나」만 말한다.
672
+ * 낱말을 읽어 효과를 정하면 현장이 `quarantine` · `검사대기` 를 쓰는 날 조용히 새기 때문이다 —
673
+ * 자원 속성에서 이미 선 원칙 그대로다(속성은 열리고 효과는 닫힌다). 둘은 같은 레코드에 함께 오고,
674
+ * 그 낱말의 주인이 짝을 정한다.
665
675
  *
666
676
  * ── 출발과 다른 사실이다 ─────────────────────────────────────────────────
667
677
  * 승인은 자재를 움직이지 않는다. 자재가 트윈 밖으로 나가는 것은 EPCIS `shipping` 사건이다. 둘을 한 사건으로
668
678
  * 접지 않는다 — 승인됐는데 안 나간 배치가 트윈이 보여야 할 상태다.
669
679
  */
670
680
  materialLot: "material-lot.status",
671
- // 레코드: { lotId(표준 MaterialLotID = ItemState.epc 의 값), status, decidedBy?, reason?, decidedAt? }
681
+ // 레코드: { lotId(표준 MaterialLotID = ItemState.epc 의 값), status, use?, decidedBy?, reason?, decidedAt? }
672
682
  /**
673
683
  * **이 목록이 전부다** — 연결된 시스템이 현재 목록을 한 바퀴 다 보낸 뒤 그것을 알린다.
674
684
  *
@@ -814,6 +824,14 @@ var ENERGY_EVENT = {
814
824
  drSuggested: "energy.dr.suggested"
815
825
  };
816
826
  var MATERIAL_ACTUAL_USE = ["consumed", "produced"];
827
+ var ALLOCATION_POLICY = {
828
+ /** 가장 작은 식별자부터. FIFO 가 아니다 — 입고 시각을 보지 않는다. */
829
+ firstFit: "firstFit",
830
+ /** 모자라도 있는 만큼 잡는다. */
831
+ partialFit: "partialFit",
832
+ /** 먼저 만료되는 것부터(First Expired, First Out). */
833
+ fefo: "fefo"
834
+ };
817
835
  var CMD = {
818
836
  orderHold: "order.hold",
819
837
  orderResume: "order.resume",
@@ -1007,7 +1025,12 @@ var BTT = {
1007
1025
  bol: "urn:epcglobal:cbv:btt:bol"
1008
1026
  };
1009
1027
  var WMS_ORDER_KIND = {
1010
- /** Expected receipt (ASN). The kernel has no inbound order yet — arrival carries a `po` without one. */
1028
+ /**
1029
+ * Expected receipt (ASN) — what was promised to arrive, and how much of it has.
1030
+ *
1031
+ * Flow metrics leave it out: the arrival both raises and fulfils it, so counting it would add a
1032
+ * completed order per arrival and never clear the backlog (`isFlowOrder`).
1033
+ */
1011
1034
  inbound: "inbound",
1012
1035
  /** Outbound order — what the kernel's sales order is. */
1013
1036
  outbound: "outbound",
@@ -1016,6 +1039,7 @@ var WMS_ORDER_KIND = {
1016
1039
  /** Stock transfer between sites. CBV has no document type for it: carry the id without a type. */
1017
1040
  transfer: "transfer"
1018
1041
  };
1042
+ var WMS_RECEIVING_ORDER_KINDS = [WMS_ORDER_KIND.inbound, WMS_ORDER_KIND.return];
1019
1043
  var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship", "vas-station"];
1020
1044
  var WMS_LOCATION_LEVEL = {
1021
1045
  dock: "StorageZone",
@@ -1939,7 +1963,8 @@ var SPECS = {
1939
1963
  priority: "number",
1940
1964
  startTime: "string",
1941
1965
  endTime: "string",
1942
- allocated: "string[]",
1966
+ /* 확보분은 `{ epc, qty }` 줄이다(2026-09-19, ADR-0076 보탬) — 옛 EPC 목록은 받지 않는다. */
1967
+ allocated: "object[]",
1943
1968
  bizTransaction: "string",
1944
1969
  operationsRequestId: "string",
1945
1970
  dockDoor: "string",
@@ -1962,6 +1987,17 @@ var SPECS = {
1962
1987
  * 새 어휘가 아니다 — 계약이 `recipeKey` 를 네 자리에서 이미 쓴다. 오더 관측에만 자리가 없었다.
1963
1988
  */
1964
1989
  recipeKey: "string"
1990
+ },
1991
+ /*
1992
+ * 확보분의 안쪽을 본다 — 열쇠와 수 둘이다(§`AllocatedQuantity`). 안 보면 틀린 이름이 조용히
1993
+ * 지나가고(`epcs`·`quantity` 따위), 미러는 잡힌 수를 0 으로 읽는다.
1994
+ */
1995
+ shapes: {
1996
+ allocated: {
1997
+ required: ["epc", "qty"],
1998
+ fields: { epc: "string", qty: "number" },
1999
+ positive: ["qty"]
2000
+ }
1965
2001
  }
1966
2002
  /* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
1967
2003
  },
@@ -2084,6 +2120,17 @@ var SPECS = {
2084
2120
  * (§`operationalKindOf`). 값은 로트의 식별자(`ItemState.epc` 와 같은 값)다. 상태 낱말은 열려 있어 enum 이
2085
2121
  * 없다(오더 상태와 같은 규율). 로트 전체의 사실이므로 부분(`subLotId`)은 받지 않는다 — 부분마다 다른 상태가
2086
2122
  * 필요해지면 그것은 다른 사실이다.
2123
+ *
2124
+ * ── 낱말은 열리고 **효과는 닫힌다** (2026-09-19, ADR-0080 결정 2) ────────────
2125
+ * `status` 는 현장의 낱말이다(`on-hold` · `quarantine` · `검사대기` — 도메인이 소유한다). 커널이 그
2126
+ * 낱말을 읽어 「보류면 할당에서 뺀다」를 정하면, 현장이 다른 낱말을 쓰는 날 조용히 샌다.
2127
+ *
2128
+ * 그래서 **효과를 따로 싣는다**: `use` 는 닫힌 둘(`allowed` · `blocked`)이고 기계가 읽는다. 낱말의
2129
+ * 주인이 둘을 같이 정한다 — 자원 속성에서 이미 선 원칙 그대로다(속성은 열리고 효과는 닫힌다).
2130
+ *
2131
+ * **`use` 가 없는 레코드는 `allowed` 가 아니라 「모름」이다.** 그래서 필수로 만들지 않는다: 이 문을
2132
+ * 이미 쓰고 있는 원본이 있으면 그 사실이 통째로 거절되는 것이 더 나쁘다. 모르는 것을 할당이 어떻게
2133
+ * 다루는지는 커널이 한 곳에서 정하고 그 수를 센다.
2087
2134
  */
2088
2135
  "material-lot": {
2089
2136
  eventType: OP_EVENT.materialLot,
@@ -2091,7 +2138,8 @@ var SPECS = {
2091
2138
  matchOrder: 85,
2092
2139
  identity: "lotId",
2093
2140
  required: ["lotId", "status"],
2094
- fields: { lotId: "string", status: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" }
2141
+ fields: { lotId: "string", status: "string", use: "string", decidedBy: "string", reason: "string", decidedAt: "string", recordTime: "string" },
2142
+ enums: { use: MATERIAL_LOT_USE }
2095
2143
  },
2096
2144
  test: {
2097
2145
  eventType: OP_EVENT.test,
@@ -2944,7 +2992,11 @@ var ObservedReducer = class {
2944
2992
  this.noteUnhandled(e, `${OP_EVENT.materialLot}:unknown-lot`);
2945
2993
  break;
2946
2994
  }
2947
- for (const part of parts) part.status = d.status;
2995
+ for (const part of parts) {
2996
+ part.status = d.status;
2997
+ if (d.use) part.use = d.use;
2998
+ else delete part.use;
2999
+ }
2948
3000
  break;
2949
3001
  }
2950
3002
  case OP_EVENT.observation: {
@@ -3175,6 +3227,10 @@ var ObservedReducer = class {
3175
3227
  this.items.set(itemKeyOf(seen), Number.isFinite(atSeen) ? { ...seen, seenAtMs: atSeen } : seen);
3176
3228
  }
3177
3229
  if (!ev.epcList?.length) {
3230
+ if (!loc && (ev.quantityList ?? []).some((qe) => qe?.epcClass)) {
3231
+ if (envelope) this.noteUnhandled(envelope, "epcis.ObjectEvent:quantity-without-readpoint");
3232
+ return;
3233
+ }
3178
3234
  for (const qe of ev.quantityList ?? []) {
3179
3235
  if (!qe?.epcClass) continue;
3180
3236
  const mine = all.filter((x) => x.epcClass === qe.epcClass);
@@ -3542,41 +3598,76 @@ function freeBinsFirstFit(slots) {
3542
3598
  }
3543
3599
  return best?.id ?? null;
3544
3600
  }
3545
- function smallestByEpc(available, k) {
3546
- if (k <= 0) return { picked: [], total: 0 };
3601
+ function qtyOf(s) {
3602
+ const q = s?.qty;
3603
+ return typeof q === "number" && q > 0 && q < Infinity ? q : 0;
3604
+ }
3605
+ function smallestByEpc(available, need) {
3606
+ if (need <= 0) return { picked: [], total: 0 };
3547
3607
  const out = [];
3608
+ let held = 0;
3548
3609
  let total = 0;
3549
3610
  for (const s of available) {
3550
- total++;
3551
- if (out.length === k && byId(s.epc, out[out.length - 1].epc) >= 0) continue;
3611
+ const q = qtyOf(s);
3612
+ total += q;
3613
+ if (q === 0) continue;
3614
+ if (held >= need && byId(s.epc, out[out.length - 1].epc) >= 0) continue;
3552
3615
  let i = out.length;
3553
3616
  while (i > 0 && byId(out[i - 1].epc, s.epc) > 0) i--;
3554
3617
  out.splice(i, 0, s);
3555
- if (out.length > k) out.pop();
3618
+ held += q;
3619
+ while (out.length > 0 && held - qtyOf(out[out.length - 1]) >= need) held -= qtyOf(out.pop());
3556
3620
  }
3557
3621
  return { picked: out, total };
3558
3622
  }
3623
+ function takeFrom(views, need) {
3624
+ const out = [];
3625
+ let left = need;
3626
+ for (const s of views) {
3627
+ if (left <= 0) break;
3628
+ const take = Math.min(left, qtyOf(s));
3629
+ if (take <= 0) continue;
3630
+ out.push({ epc: s.epc, qty: take });
3631
+ left -= take;
3632
+ }
3633
+ return out;
3634
+ }
3559
3635
  var firstFitPolicy = {
3560
3636
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
3561
3637
  selectStock: ({ qty, available }) => {
3562
3638
  const { picked, total } = smallestByEpc(available, qty);
3563
3639
  if (total < qty) return [];
3564
- return picked.map((s) => s.epc);
3640
+ return takeFrom(picked, qty);
3565
3641
  }
3566
3642
  };
3567
3643
  var partialFitPolicy = {
3568
3644
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
3569
- selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked.map((s) => s.epc)
3645
+ selectStock: ({ qty, available }) => takeFrom(smallestByEpc(available, qty).picked, qty)
3570
3646
  };
3571
3647
  var fefoPolicy = {
3572
3648
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
3573
3649
  selectStock: ({ qty, available }) => {
3574
3650
  const all = [...available];
3575
- if (all.length < qty) return [];
3651
+ const total = all.reduce((sum, s) => sum + qtyOf(s), 0);
3652
+ if (total < qty) return [];
3576
3653
  const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
3577
- return byExpiry.slice(0, qty).map((s) => s.epc);
3654
+ return takeFrom(byExpiry, qty);
3578
3655
  }
3579
3656
  };
3657
+ var ALLOCATION_POLICIES = {
3658
+ [ALLOCATION_POLICY.firstFit]: firstFitPolicy,
3659
+ [ALLOCATION_POLICY.partialFit]: partialFitPolicy,
3660
+ [ALLOCATION_POLICY.fefo]: fefoPolicy
3661
+ };
3662
+ function allocationPolicyOf(name) {
3663
+ const key = String(name ?? "").trim();
3664
+ if (!key) return firstFitPolicy;
3665
+ const found = ALLOCATION_POLICIES[key];
3666
+ if (!found) {
3667
+ throw new Error(`unknown allocation policy "${key}" \u2014 declare one of: ${Object.keys(ALLOCATION_POLICIES).join(" \xB7 ")}`);
3668
+ }
3669
+ return found;
3670
+ }
3580
3671
 
3581
3672
  // src/flow-engine.ts
3582
3673
  var BASE_EPOCH = Date.parse("2026-01-01T00:00:00Z");
@@ -3586,6 +3677,12 @@ function effectiveOnly(r) {
3586
3677
  ...r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}
3587
3678
  };
3588
3679
  }
3680
+ function allocatedQty(rows) {
3681
+ return (rows ?? []).reduce((sum, a) => sum + (Number(a?.qty) || 0), 0);
3682
+ }
3683
+ function allocatedEpcs(rows) {
3684
+ return (rows ?? []).map((a) => a.epc);
3685
+ }
3589
3686
  function mulberry32(seed) {
3590
3687
  let a = seed >>> 0;
3591
3688
  const fn = () => {
@@ -4128,6 +4225,8 @@ var FlowEngine = class {
4128
4225
  * 부족한 씨앗 위에서 낸 답을 완전한 답으로 읽는다.
4129
4226
  */
4130
4227
  seedDanglingRefs = 0;
4228
+ /** 로트 상태를 모르는 채로 할당 후보에 든 수 — 「모름」을 안전으로 읽지 않기 위한 셈(ADR-0080 결정 2). */
4229
+ allocatedWithUnknownLotUse = 0;
4131
4230
  transformInputsAbsent = 0;
4132
4231
  /**
4133
4232
  * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
@@ -4169,6 +4268,7 @@ var FlowEngine = class {
4169
4268
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
4170
4269
  loadTwinModel(def) {
4171
4270
  this.boardDef = def;
4271
+ if (def.allocationPolicy) this.policy = allocationPolicyOf(def.allocationPolicy);
4172
4272
  if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
4173
4273
  for (const n of readBoardLocations(def)) this.locations.set(n.id, this.buildLocation(n));
4174
4274
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
@@ -4353,10 +4453,25 @@ var FlowEngine = class {
4353
4453
  * 그래서 심을 때 맞춘다. **없는 물품을 만들어 채우지 않는다** — 채우면 계보와 재고가 거짓 위에 선다.
4354
4454
  * 대신 몇 건이 맞지 않았는지 남겨 씨앗이 불완전했다는 사실을 답에 실을 수 있게 한다.
4355
4455
  */
4456
+ /**
4457
+ * **이 로트를 할당 후보에 넣어도 되나** — 판정은 이 한 곳이다(ADR-0080 결정 2).
4458
+ *
4459
+ * 읽는 것은 효과(`use`)뿐이고 낱말(`status`)은 읽지 않는다. 낱말을 읽으면 현장이 `quarantine` ·
4460
+ * `검사대기` 를 쓰는 날 **오류 없이** 보류된 로트가 할당에 든다.
4461
+ *
4462
+ * **말해 주지 않은 로트는 「모름」이다.** 지금 거동 그대로 후보에 넣되 그 수를 센다 — 멈추면 로트
4463
+ * 상태를 아직 안 보내는 현장의 트윈이 통째로 서고, 「모름」을 안전으로 읽으면 보류가 새는 것을
4464
+ * 아무도 모른다. 답에 실린 그 수가 「이 원본은 아직 상태를 안 보낸다」를 말한다.
4465
+ */
4466
+ usableLot(item) {
4467
+ if (item.use === "blocked") return false;
4468
+ if (item.use === void 0) this.allocatedWithUnknownLotUse++;
4469
+ return true;
4470
+ }
4356
4471
  resolvedAllocated(allocated) {
4357
4472
  const all = allocated ?? [];
4358
4473
  if (!all.length) return { kept: [], dropped: 0 };
4359
- const kept = all.filter((epc) => this.items.has(epc));
4474
+ const kept = all.filter((a) => this.items.has(a.epc));
4360
4475
  const dropped = all.length - kept.length;
4361
4476
  this.seedDanglingRefs += dropped;
4362
4477
  return { kept, dropped };
@@ -4390,7 +4505,9 @@ var FlowEngine = class {
4390
4505
  * 넣다가 `nonconformance` 도 여기서 빠져 있던 것을 찾았다 — 자리는 있고 길이 없는 부류). 있을 때만 옮긴다.
4391
4506
  */
4392
4507
  ...it.nonconformance ? { nonconformance: it.nonconformance } : {},
4393
- ...it.status ? { status: it.status } : {}
4508
+ ...it.status ? { status: it.status } : {},
4509
+ /* 그 상태의 효과 — 이 칸이 빠지면 미러가 보류를 알고도 할당이 모른다(ADR-0080 결정 2). */
4510
+ ...it.use ? { use: it.use } : {}
4394
4511
  });
4395
4512
  }
4396
4513
  for (const m of snap.equipment) {
@@ -4864,10 +4981,11 @@ var FlowEngine = class {
4864
4981
  // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
4865
4982
  identityGrounding: this.identityGroundingView(),
4866
4983
  /* 원본과 어긋난 사실 — 0 이면 싣지 않는다(어긋난 적 없는 트윈에 빈 칸을 만들지 않는다). */
4867
- ...this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps ? { conformance: {
4984
+ ...this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps || this.allocatedWithUnknownLotUse ? { conformance: {
4868
4985
  ...this.transformInputsAbsent ? { transformInputsAbsent: this.transformInputsAbsent } : {},
4869
4986
  ...this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {},
4870
- ...this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {}
4987
+ ...this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {},
4988
+ ...this.allocatedWithUnknownLotUse ? { allocatedWithUnknownLotUse: this.allocatedWithUnknownLotUse } : {}
4871
4989
  } } : {},
4872
4990
  /* 출처 표시 — 트윈 모델(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
4873
4991
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
@@ -6800,7 +6918,9 @@ var FlowEngine = class {
6800
6918
  ...i.ilmd ? { ilmd: i.ilmd } : {},
6801
6919
  /* 로트의 두 표준 칸(§FlowItem) — 여기서 빠지면 리듀서·엔진에는 있고 스냅샷에는 없다. */
6802
6920
  ...i.nonconformance ? { nonconformance: i.nonconformance } : {},
6803
- ...i.status ? { status: i.status } : {}
6921
+ ...i.status ? { status: i.status } : {},
6922
+ /* 그 상태의 효과 — 화면과 다음 씨앗이 읽는다(§`ItemState.use`). */
6923
+ ...i.use ? { use: i.use } : {}
6804
6924
  };
6805
6925
  }
6806
6926
  /**
@@ -6845,7 +6965,7 @@ var FlowEngine = class {
6845
6965
  }
6846
6966
  }
6847
6967
  processOrders() {
6848
- const pending = [...this.orders.values()].filter((o) => o.status === "created" && !o.held);
6968
+ const pending = [...this.orders.values()].filter((o) => o.status === "created" && !o.held && o.kind !== WMS_ORDER_KIND.inbound);
6849
6969
  if (pending.some((o) => o.priority !== void 0)) pending.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
6850
6970
  for (const o of pending) this.allocate(o);
6851
6971
  }
@@ -7079,7 +7199,7 @@ var WmsKernel = class extends FlowEngine {
7079
7199
  const poTxn = [{ type: BTT.po, bizTransaction: po }];
7080
7200
  const expiry = this.clockMs + SHELF_MS - this.epcSeq % 5 * SHELF_JITTER_MS;
7081
7201
  const ilmd = { [ILMD_ATTR.expiry]: expiry };
7082
- this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
7202
+ this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd, receivedAtMs: this.nowMs() });
7083
7203
  dock.occupancy++;
7084
7204
  this.emit(transactionEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
7085
7205
  this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
@@ -7173,14 +7293,14 @@ var WmsKernel = class extends FlowEngine {
7173
7293
  const staging = this.builtInLocation("staging", "picked pallets wait here before shipping");
7174
7294
  const chosenAll = [];
7175
7295
  for (const line of o.lines) {
7176
- const already = o.allocated.filter((e) => this.items.get(e)?.gtin === line.gtin).length;
7296
+ const already = allocatedQty(o.allocated.filter((a) => this.items.get(a.epc)?.gtin === line.gtin));
7177
7297
  const need = line.requested - already;
7178
7298
  if (need <= 0) continue;
7179
- const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === "storage").map((i) => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
7299
+ const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === "storage").filter((i) => this.usableLot(i)).map((i) => ({ epc: i.epc, location: i.location, qty: 1, expiry: i.expiry, ...i.receivedAtMs !== void 0 ? { receivedAtMs: i.receivedAtMs } : {} }));
7180
7300
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
7181
- for (const epc of chosen) {
7182
- o.allocated.push(epc);
7183
- chosenAll.push(epc);
7301
+ for (const row of chosen) {
7302
+ o.allocated.push(row);
7303
+ chosenAll.push(row.epc);
7184
7304
  }
7185
7305
  }
7186
7306
  if (chosenAll.length === 0) return this.makeShortLines(o);
@@ -7341,7 +7461,7 @@ var WmsKernel = class extends FlowEngine {
7341
7461
  order.status = "packed";
7342
7462
  this.emit(objectEvent({ eventTime, action: "OBSERVE", bizStep: BIZSTEP.staging_outbound, disposition: DISP.reserved, epcList: [shipment], readPoint: staging.id, bizLocation: staging.id, bizTransactionList: soTxn }));
7343
7463
  this.emit(objectEvent({ eventTime, action: "DELETE", bizStep: BIZSTEP.shipping, disposition: DISP.in_transit, epcList: [shipment], readPoint: shipDock.id, bizTransactionList: soTxn }));
7344
- order.fulfilled += order.allocated.length;
7464
+ order.fulfilled += allocatedQty(order.allocated);
7345
7465
  if (order.lines) for (const epc of order.picked) {
7346
7466
  const g = this.items.get(epc)?.gtin;
7347
7467
  const line = order.lines.find((l) => l.gtin === g && l.requested > 0);
@@ -7420,7 +7540,7 @@ var YmsKernel = class extends FlowEngine {
7420
7540
  requested: 1,
7421
7541
  fulfilled: 0,
7422
7542
  bizTransaction: appt,
7423
- allocated: [epc],
7543
+ allocated: [{ epc, qty: 1 }],
7424
7544
  picked: [],
7425
7545
  dockDoor: door.id,
7426
7546
  /* 내부 스케줄 게이트(시뮬 ms) — 표준 `EarliestStartTime` 과 같은 개념의 우리 단위. */
@@ -7449,7 +7569,7 @@ var YmsKernel = class extends FlowEngine {
7449
7569
  * 하나라도 안 되면 대기(다음 tick 재시도). = 시간창 예약 스케줄링.
7450
7570
  */
7451
7571
  allocate(o) {
7452
- const trailer = this.items.get(o.allocated[0]);
7572
+ const trailer = this.items.get(o.allocated[0]?.epc);
7453
7573
  if (!trailer) return;
7454
7574
  if (this.clockMs < (o.windowStartMs ?? 0)) return;
7455
7575
  const door = o.dockDoor ? this.locations.get(o.dockDoor) : void 0;
@@ -7877,7 +7997,7 @@ The steps are the source of truth for execution order; the hierarchy only names
7877
7997
  if (!this.producesOwnOutputs(t.kind)) return false;
7878
7998
  const order = t.orderId ? this.orders.get(t.orderId) : void 0;
7879
7999
  if (!order) return false;
7880
- for (const epc of epcs) order.allocated.push(epc);
8000
+ for (const epc of epcs) order.allocated.push({ epc, qty: 1 });
7881
8001
  return true;
7882
8002
  }
7883
8003
  // ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
@@ -8182,12 +8302,12 @@ The steps are the source of truth for execution order; the hierarchy only names
8182
8302
  }
8183
8303
  } else {
8184
8304
  for (const i of this.items.ofGtin(g)) {
8185
- if (i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
8305
+ if (i.disposition === DISP.sellable && this.usableLot(i)) available.push({ epc: i.epc, location: i.location, qty: 1 });
8186
8306
  }
8187
8307
  }
8188
8308
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
8189
- if (chosen.length < line.qty) {
8190
- short.push({ material: line.material, need: line.qty, have: chosen.length });
8309
+ if (allocatedQty(chosen) < line.qty) {
8310
+ short.push({ material: line.material, need: line.qty, have: allocatedQty(chosen) });
8191
8311
  continue;
8192
8312
  }
8193
8313
  picks.push(...chosen);
@@ -8198,10 +8318,10 @@ The steps are the source of truth for execution order; the hierarchy only names
8198
8318
  return;
8199
8319
  }
8200
8320
  if (o.shortage) delete o.shortage;
8201
- for (const epc of picks) o.allocated.push(epc);
8202
- this.reserve(picks, MES_BIZSTEP.producing);
8203
- this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
8204
- this.emitStationDef(o, ops[0], o.allocated[0]);
8321
+ for (const row of picks) o.allocated.push(row);
8322
+ this.reserve(allocatedEpcs(picks), MES_BIZSTEP.producing);
8323
+ this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: allocatedEpcs(o.allocated) }));
8324
+ this.emitStationDef(o, ops[0], o.allocated[0]?.epc);
8205
8325
  o.status = "op-" + ops[0].key;
8206
8326
  this.emitOrder(o);
8207
8327
  }
@@ -8232,12 +8352,12 @@ The steps are the source of truth for execution order; the hierarchy only names
8232
8352
  const producedKey = this.producedMaterialKeyOf(t.kind);
8233
8353
  const next = ops[i + 1];
8234
8354
  if (producedKey) {
8235
- const inputs = order.allocated.slice();
8355
+ const inputs = allocatedEpcs(order.allocated);
8236
8356
  const made = this.serialOf(producedKey, ++this.wipSeq);
8237
8357
  this.transform(inputs, [{ epc: made, gtin: this.classOf(producedKey), qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
8238
- order.allocated = [made];
8358
+ order.allocated = [{ epc: made, qty: 1 }];
8239
8359
  }
8240
- const carried = order.allocated[0];
8360
+ const carried = order.allocated[0]?.epc;
8241
8361
  if (!carried && !this.observationDriven) {
8242
8362
  throw new Error(
8243
8363
  `order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' \u2014 the order holds no allocated material and this step declares no produced material. Declare the step's input or its output in the model.`
@@ -8251,7 +8371,7 @@ The steps are the source of truth for execution order; the hierarchy only names
8251
8371
  }
8252
8372
  const declaredFg = this.declaredLocationTypeOfMaterial(rc.outputs[0].material);
8253
8373
  const fgStore = declaredFg ? this.locationOfMaterial(rc.outputs[0].material) : loc;
8254
- const consumed = order.allocated.slice();
8374
+ const consumed = allocatedEpcs(order.allocated);
8255
8375
  if (!consumed.length) {
8256
8376
  if (order.seedIncomplete) {
8257
8377
  order.status = "blocked-seed-incomplete";
@@ -9949,6 +10069,7 @@ function electricityCost(input) {
9949
10069
  }
9950
10070
  // Annotate the CommonJS export names for ESM import in node:
9951
10071
  0 && (module.exports = {
10072
+ ALLOCATION_POLICIES,
9952
10073
  ATTENTION_DEFAULTS,
9953
10074
  ATTENTION_PROPERTY,
9954
10075
  ClassMasterTable,
@@ -9966,6 +10087,9 @@ function electricityCost(input) {
9966
10087
  TwinRuntime,
9967
10088
  WmsKernel,
9968
10089
  YmsKernel,
10090
+ allocatedEpcs,
10091
+ allocatedQty,
10092
+ allocationPolicyOf,
9969
10093
  attributeEnergy,
9970
10094
  compareStates,
9971
10095
  constantDuration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.11.18",
3
+ "version": "0.11.20",
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.28"
31
+ "@operato/ops-contract": "^0.9.30"
32
32
  }
33
33
  }