@operato/twin-kernel 0.7.42 → 0.7.44

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.
@@ -23,7 +23,18 @@ export interface PlacementContext {
23
23
  export interface StockRequest {
24
24
  gtin: string;
25
25
  qty: number;
26
- available: readonly StockView[];
26
+ /**
27
+ * 후보 재고 — **한 번만 순회할 수 있는 것으로 본다.**
28
+ *
29
+ * ── 왜 배열이 아닌가 (2026-08-21 프로파일) ────────────────────────────────
30
+ * 예전에는 배열이었다. 그래서 호출부가 자리의 물품 전체를 배열로 만들어 넘겼고, 그것이 틱 시간의
31
+ * 25.1% 였다(물품 16,000 규모). 오더 하나가 요구하는 수는 보통 한 자리인데 후보 전체를 실물로
32
+ * 만들어 낸 것이다.
33
+ *
34
+ * 지연 순회로 바꾸면 기본 정책은 만들지 않고 고를 수 있다. 전체가 필요한 정책(FEFO 는 만료 순으로
35
+ * 정렬해야 한다)은 `[...available]` 로 펼치면 된다 — 그 비용은 그 정책이 실제로 필요해서 치르는 것이다.
36
+ */
37
+ available: Iterable<StockView>;
27
38
  }
28
39
  export interface AllocationPolicy {
29
40
  /** 배치 목적지 슬롯 id. 수용 불가면 null(도크 대기 → 다음 tick 재시도). */
@@ -5,15 +5,55 @@
5
5
  * 결정은 정책에 위임한다. 고객은 FIFO/FEFO/nearest/zone·부분할당 등을 정책 교체로만
6
6
  * 확장 — 코어 수정 없이. (설계 gap #5 "할당 정책 플러그")
7
7
  */
8
+ /*
9
+ * ── 식별자 비교에 `localeCompare` 를 쓰지 않는다 (2026-08-21 프로파일) ───────
10
+ *
11
+ * 정렬 비용이 틱 시간의 17.2% 였다(`sortByEpc` 와 그 비교 함수 합). 원인이 둘이다.
12
+ *
13
+ * ① `localeCompare` 는 **로케일 대조**다. 사람이 읽는 목록을 그 언어의 관행대로 늘어놓기 위한 것이고,
14
+ * 그래서 비교 한 번이 문자열 비교보다 훨씬 비싸다. 그런데 여기서 비교하는 것은 **식별자**다 —
15
+ * 사람에게 보이지 않고, 필요한 것은 「언제 물어도 같은 순서」뿐이다. 부호 순서로 충분하다.
16
+ *
17
+ * ② 전량을 정렬한 뒤 앞의 `qty` 개만 취했다. 오더 하나가 요구하는 수는 보통 한 자리인데 재고는
18
+ * 수만 개가 될 수 있다 — 목표 규모는 물품 10만이다. **가장 작은 k 개**만 찾으면 된다.
19
+ *
20
+ * 결과는 바뀌지 않는다: 같은 순서에서 가장 작은 k 개는 정렬한 뒤 앞의 k 개와 같은 것들이고 순서도 같다.
21
+ */
22
+ const byId = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
8
23
  function freeBinsFirstFit(slots) {
9
- const sorted = [...slots].sort((a, b) => a.id.localeCompare(b.id));
10
- for (const b of sorted)
11
- if (b.occupancy + b.reserved < b.capacity)
12
- return b.id;
13
- return null;
24
+ /* 정렬하지 않는다 가장 작은 id 하나만 찾으면 되므로 한 번 순회로 답한다. */
25
+ let best;
26
+ for (const b of slots) {
27
+ if (b.occupancy + b.reserved >= b.capacity)
28
+ continue;
29
+ if (!best || byId(b.id, best.id) < 0)
30
+ best = b;
31
+ }
32
+ return best?.id ?? null;
14
33
  }
15
- function sortByEpc(available) {
16
- return [...available].sort((a, b) => a.epc.localeCompare(b.epc));
34
+ /**
35
+ * `epc` 가장 작은 **k 개** — 전량 정렬 없이.
36
+ *
37
+ * 작은 정렬 버퍼를 들고 한 번 순회한다. k 가 작을 때(오더가 요구하는 수는 보통 한 자리다) 전량 정렬보다
38
+ * 훨씬 싸고, 결과는 정렬한 뒤 앞의 k 개와 **같다**.
39
+ */
40
+ function smallestByEpc(available, k) {
41
+ if (k <= 0)
42
+ return { picked: [], total: 0 };
43
+ const out = [];
44
+ let total = 0;
45
+ for (const s of available) {
46
+ total++;
47
+ if (out.length === k && byId(s.epc, out[out.length - 1].epc) >= 0)
48
+ continue;
49
+ let i = out.length;
50
+ while (i > 0 && byId(out[i - 1].epc, s.epc) > 0)
51
+ i--;
52
+ out.splice(i, 0, s);
53
+ if (out.length > k)
54
+ out.pop();
55
+ }
56
+ return { picked: out, total };
17
57
  }
18
58
  /**
19
59
  * 기본 정책 — first-fit slot + all-or-nothing 오더 할당.
@@ -22,9 +62,11 @@ function sortByEpc(available) {
22
62
  export const firstFitPolicy = {
23
63
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
24
64
  selectStock: ({ qty, available }) => {
25
- if (available.length < qty)
65
+ /* 고르는 것과 세는 것을 **한 번의 순회**로 한다 — 전량 확보 판정에 개수가 필요하다. */
66
+ const { picked, total } = smallestByEpc(available, qty);
67
+ if (total < qty)
26
68
  return []; // 전량 확보 전엔 대기
27
- return sortByEpc(available).slice(0, qty).map(s => s.epc);
69
+ return picked.map(s => s.epc);
28
70
  }
29
71
  };
30
72
  /**
@@ -33,7 +75,7 @@ export const firstFitPolicy = {
33
75
  */
34
76
  export const partialFitPolicy = {
35
77
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
36
- selectStock: ({ qty, available }) => sortByEpc(available).slice(0, qty).map(s => s.epc)
78
+ selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked.map(s => s.epc)
37
79
  };
38
80
  /**
39
81
  * FEFO 정책 — First-Expired-First-Out. 만료 임박 로트를 먼저 출고(신선/제약 도메인).
@@ -42,9 +84,11 @@ export const partialFitPolicy = {
42
84
  export const fefoPolicy = {
43
85
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
44
86
  selectStock: ({ qty, available }) => {
45
- if (available.length < qty)
87
+ /* 만료 순이 필요하므로 전체를 펼친다 — 이 정책이 실제로 필요해서 치르는 비용이다. */
88
+ const all = [...available];
89
+ if (all.length < qty)
46
90
  return []; // 전량 확보 전엔 대기
47
- const byExpiry = [...available].sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || a.epc.localeCompare(b.epc));
91
+ const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
48
92
  return byExpiry.slice(0, qty).map(s => s.epc);
49
93
  }
50
94
  };
@@ -1163,6 +1163,18 @@ export interface OrderState {
1163
1163
  recipeKey?: string;
1164
1164
  /** 씨앗이 이 오더의 확보분을 다 심지 못했다 — 이 오더의 답은 부족한 씨앗 위에 있다. */
1165
1165
  seedIncomplete?: boolean;
1166
+ /**
1167
+ * **이 오더가 왜 대기하는가** — 모자란 투입 줄(자재 키 · 필요 · 확보 가능).
1168
+ *
1169
+ * 확보는 전량 아니면 대기다. 그 거동은 옳은데 이유를 말하지 않으면 화면은 「running」만 보여 주고,
1170
+ * 사람은 영구히 대기하는 오더를 정상으로 읽는다. 모자란 줄을 **전부** 싣는다 — 하나만 알려 주면
1171
+ * 채운 뒤 다음 줄에서 또 막히고 같은 진단을 반복하게 된다.
1172
+ */
1173
+ shortage?: {
1174
+ material: string;
1175
+ need: number;
1176
+ have: number;
1177
+ }[];
1166
1178
  progress?: number;
1167
1179
  held?: boolean;
1168
1180
  /**
@@ -2003,6 +2015,19 @@ export declare function readBoardEquipment(def: TwinModelDef | Record<string, un
2003
2015
  /** 저장된 보드의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
2004
2016
  export declare function readBoardAssets(def: TwinModelDef | Record<string, unknown>): NonNullable<TwinModelDef['assets']>;
2005
2017
  export interface TwinModelDef {
2018
+ /**
2019
+ * **이 트윈의 정체성 선언** — 팔레트·트레일러·문서의 식별자가 어디서 오나.
2020
+ *
2021
+ * ── 왜 모델에 있나 (2026-08-21) ──────────────────────────────────────────
2022
+ * 처음에는 `ProductionSpec.identity` 하나였다. 그때 선언 모드가 있는 커널이 MES 뿐이었기 때문이다.
2023
+ * 그런데 **정체성은 생산과 무관하다**: 창고는 팔레트(SSCC)를, 야드는 트레일러(GRAI)를 식별하는데
2024
+ * 그 둘은 아무것도 생산하지 않는다. 생산 선언에 두면 생산하지 않는 트윈이 정체성을 선언할 자리가 없고,
2025
+ * 실제로 그래서 두 커널이 프리픽스를 코드에 두고 있었다.
2026
+ *
2027
+ * 읽는 곳은 하나다(§`FlowEngine.identityDeclaration`). `ProductionSpec.identity` 는 **먼저 생긴
2028
+ * 자리**이고, 픽스처·호스트가 이 자리로 옮기면 사라진다.
2029
+ */
2030
+ identity?: IdentityDeclaration;
2006
2031
  /** parallelism = 동시 처리 수(LocationState.parallelism 참조). capacity 는 저장 용량. */
2007
2032
  locations: {
2008
2033
  id: string;
@@ -61,6 +61,17 @@ export interface TwinAxisInfo {
61
61
  * (저장·계약의 이름과 어긋나면 그 순간 방언이 생긴다).
62
62
  */
63
63
  axis: string;
64
+ /**
65
+ * **무엇이 이 축의 항목을 가리키나** — 없으면 소비처가 `id` 를 쓴다.
66
+ *
67
+ * 대부분의 축은 항목마다 `id` 가 있다. 그렇지 않은 축이 있고, 그것이 결함은 아니다 — 수요 구간의
68
+ * 정체성은 **구간이 언제 시작했나**다(요금의 알갱이가 그 시각으로 정해진다).
69
+ *
70
+ * 이 칸이 없던 동안 소비처는 `id`·`key`·`gtin` 을 **짐작**했고, 그래서 화면이 실재하는 항목을
71
+ * 「식별자 없음 — 참조할 수 없는 항목」으로 보였다. 값은 있는데 가리킬 수 없다고 말한 것이다.
72
+ * 짐작을 없애고 선언이 답한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
73
+ */
74
+ idField?: string;
64
75
  /**
65
76
  * 이 축을 **어디서 읽나.**
66
77
  *
@@ -115,7 +115,17 @@ export const TWIN_AXES = [
115
115
  * 아직 세우지 않은 것: **요금 구간**(TariffPeriod)과 **원단위**(EnPI). 둘 다 아직 아무도 만들지
116
116
  * 않는다 — 선언만 하면 개념 지도가 언제나 0 을 보여 주고, 그것은 결손처럼 읽힌다(§10 7단계의 일).
117
117
  */
118
- { axis: 'demandWindows', path: 'energy.closed', label: 'twin.axis.demandWindows', kind: 'instance',
118
+ /*
119
+ * **무엇이 이 항목을 가리키나** — `idField` 가 말한다.
120
+ *
121
+ * 수요 구간에는 `id` 가 없다. 그것이 결함은 아니다: 구간의 정체성은 **그 구간이 언제 시작했나**이고
122
+ * (요금의 알갱이가 그 시각으로 정해진다) 커널도 그것으로 키를 만든다(`contract-projected-over:<startMs>`).
123
+ *
124
+ * 그런데 소비처가 `id`·`key`·`gtin` 을 **짐작**하고 있어서, 화면이 여섯 구간 모두를 「식별자 없음 —
125
+ * 참조할 수 없는 항목」으로 보였다. 값은 실재하는데 가리킬 수 없다고 말한 것이다. 짐작을 없애고
126
+ * 선언이 답하게 한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
127
+ */
128
+ { axis: 'demandWindows', path: 'energy.closed', idField: 'startMs', label: 'twin.axis.demandWindows', kind: 'instance',
119
129
  source: 'state', historical: true, standardClass: {}, systems: ['ems'] }
120
130
  ];
121
131
  /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
package/dist/epcis.d.ts CHANGED
@@ -271,6 +271,20 @@ export declare function gdtiUri(companyPrefix: string, docType: string, serial:
271
271
  * 이름공간의 모양으로 URL 형태와 URN 형태를 가른다. 판정할 수 없는 모양이면 **답하지 않는다**(지어내지
272
272
  * 않는다) — 호출부가 다른 길을 고르게 한다.
273
273
  */
274
+ /**
275
+ * **선언된 이름공간 아래의 개체 식별자** — SSCC·GRAI 를 쓰지 않는 길.
276
+ *
277
+ * 팔레트(SSCC)·트레일러(GRAI)는 개체 식별자이고, 그 조립에는 GS1 회사 프리픽스가 필요하다. 프리픽스가
278
+ * 없는 현장은 그 길로 갈 수 없다 — 그런데 커널이 프리픽스를 지어내면 저널에 남의 번호가 영구히 남는다.
279
+ *
280
+ * 표준이 다른 길을 정해 두었다.
281
+ * · CBV 2.0 §8.2.4 `http(s)://[Subdomain.]Domain/⁎⁎/obj/Objid` — 그 도메인 소유자가 배정
282
+ * · CBV 2.0 §8.2.3 `urn:URNNamespace:⁎⁎:obj:Objid` — URN 이름공간 소유자가 배정
283
+ *
284
+ * `obj` 표지가 필수다(클래스의 `class`·거래문서의 `bt` 와 같은 구조다). 다만 표준은 EPC URI 나 Digital
285
+ * Link 를 **권한다**(SHOULD) — 이 길은 프리픽스가 없을 때의 정합 경로다.
286
+ */
287
+ export declare function objectUri(namespace: string, objId: string | number): string | undefined;
274
288
  export declare function bizTransactionUri(namespace: string, transId: string | number): string | undefined;
275
289
  /** 모든 빌더가 공통으로 받는 표준 헤더 옵션(선택) — 방출부가 필요할 때 채운다. */
276
290
  export interface EpcisHeaderOptions {
package/dist/epcis.js CHANGED
@@ -132,25 +132,50 @@ export function gdtiUri(companyPrefix, docType, serial) {
132
132
  * 이름공간의 모양으로 URL 형태와 URN 형태를 가른다. 판정할 수 없는 모양이면 **답하지 않는다**(지어내지
133
133
  * 않는다) — 호출부가 다른 길을 고르게 한다.
134
134
  */
135
+ /**
136
+ * **선언된 이름공간 아래의 개체 식별자** — SSCC·GRAI 를 쓰지 않는 길.
137
+ *
138
+ * 팔레트(SSCC)·트레일러(GRAI)는 개체 식별자이고, 그 조립에는 GS1 회사 프리픽스가 필요하다. 프리픽스가
139
+ * 없는 현장은 그 길로 갈 수 없다 — 그런데 커널이 프리픽스를 지어내면 저널에 남의 번호가 영구히 남는다.
140
+ *
141
+ * 표준이 다른 길을 정해 두었다.
142
+ * · CBV 2.0 §8.2.4 `http(s)://[Subdomain.]Domain/⁎⁎/obj/Objid` — 그 도메인 소유자가 배정
143
+ * · CBV 2.0 §8.2.3 `urn:URNNamespace:⁎⁎:obj:Objid` — URN 이름공간 소유자가 배정
144
+ *
145
+ * `obj` 표지가 필수다(클래스의 `class`·거래문서의 `bt` 와 같은 구조다). 다만 표준은 EPC URI 나 Digital
146
+ * Link 를 **권한다**(SHOULD) — 이 길은 프리픽스가 없을 때의 정합 경로다.
147
+ */
148
+ export function objectUri(namespace, objId) {
149
+ return underNamespace(namespace, 'obj', objId);
150
+ }
135
151
  export function bizTransactionUri(namespace, transId) {
152
+ return underNamespace(namespace, 'bt', transId);
153
+ }
154
+ /**
155
+ * 선언된 이름공간 아래에 표지를 붙여 식별자를 만든다 — 표지만 다르고 규칙은 같다.
156
+ *
157
+ * `obj`(개체 §8.2.3·§8.2.4) · `class`(클래스 §8.3.3·§8.3.4) · `bt`(거래문서 §8.5.4·§8.5.5) 가 같은
158
+ * 모양이다. 규칙을 세 곳에 적으면 한 곳만 고쳐지는 날이 온다.
159
+ */
160
+ function underNamespace(namespace, marker, id) {
136
161
  const ns = namespace?.trim();
137
162
  if (!ns)
138
163
  return undefined;
139
- const id = String(transId);
140
- /* `transID` 에 구분자가 들어가면 표준이 요구하는 「성분 하나」가 깨진다. */
141
- if (!id || id.includes('/') || id.includes(':'))
164
+ const v = String(id);
165
+ /* 표준이 요구하는 「성분 하나」가 깨진다 — 구분자가 든 값은 만들지 않는다. */
166
+ if (!v || v.includes('/') || v.includes(':'))
142
167
  return undefined;
143
168
  if (/^https?:\/\/[^/\s]+/.test(ns))
144
- return `${ns.replace(/\/+$/, '')}/bt/${id}`;
169
+ return `${ns.replace(/\/+$/, '')}/${marker}/${v}`;
145
170
  /*
146
- * **GS1 이 소유한 URN 공간에는 우리가 `:bt:` 만들 수 없다.** `urn:epc:`·`urn:epcglobal:` 의
147
- * 소유 권한자는 GS1 이고(EPCIS §6.4), 그 안의 형태는 표준이 정해 둔 것만 유효하다. 그 공간을
148
- * 이름공간으로 선언한 현장은 GDTI 문서 타입을 선언하는 쪽으로 가야 한다.
171
+ * **GS1 이 소유한 URN 공간에는 우리가 표지를 만들 수 없다.** `urn:epc:`·`urn:epcglobal:` 의 소유
172
+ * 권한자는 GS1 이고(EPCIS §6.4), 그 안의 형태는 표준이 정해 둔 것만 유효하다. 그 공간을 이름공간으로
173
+ * 선언한 현장은 GS1 키(SSCC·GRAI·GDTI)를 쓰는 쪽으로 가야 한다.
149
174
  */
150
175
  if (/^urn:epc(global)?:/.test(ns))
151
176
  return undefined;
152
177
  if (/^urn:[^:\s]+/.test(ns))
153
- return `${ns.replace(/:+$/, '')}:bt:${id}`;
178
+ return `${ns.replace(/:+$/, '')}:${marker}:${v}`;
154
179
  return undefined;
155
180
  }
156
181
  function header(type, eventTime, bizStep, opts) {
@@ -103,8 +103,9 @@ export declare function isAggregationRecord(record: unknown): boolean;
103
103
  * 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
104
104
  * 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
105
105
  *
106
- * 한쪽만 있는 것도 변환이다 표준이 소실(N→0)과 생성(0→N)을 같은 이벤트로 표현한다. 둘 다 없으면
107
- * 변환이 아니다(무엇이 바뀌었는지 말하지 않은 것이다).
106
+ * 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 있어야 유효한 이벤트가 되지만(아래),
107
+ * 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
108
+ * 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
108
109
  */
109
110
  export declare function isTransformationRecord(record: unknown): boolean;
110
111
  /** 룰: 소스 레코드의 판별자(sourceType)로 매핑을 고른다. */
@@ -27,8 +27,9 @@ export function isAggregationRecord(record) {
27
27
  * 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
28
28
  * 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
29
29
  *
30
- * 한쪽만 있는 것도 변환이다 표준이 소실(N→0)과 생성(0→N)을 같은 이벤트로 표현한다. 둘 다 없으면
31
- * 변환이 아니다(무엇이 바뀌었는지 말하지 않은 것이다).
30
+ * 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 있어야 유효한 이벤트가 되지만(아래),
31
+ * 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
32
+ * 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
32
33
  */
33
34
  export function isTransformationRecord(record) {
34
35
  if (!record || typeof record !== 'object')
@@ -111,16 +112,25 @@ export function mapRecordChecked(record, mapping, eventTime) {
111
112
  const outputEPCList = resolveList(mapping.outputEPCList, record, 'outputEPCList', errors);
112
113
  const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, 'outputQuantityList', errors);
113
114
  const transformationID = resolve(mapping.transformationID, record, 'transformationID', errors);
114
- /* 들어간 것도 나온 것도 없으면 무엇이 바뀌었는지 말하지 않은 것이다 — 검증에 넘기기 전에 여기서
115
- 말해 준다(검증 메시지는 이벤트를 가리키고, 메시지는 **매핑**을 가리킨다). */
116
- if (!inputEPCList.length && !inputQuantityList.length && !outputEPCList.length && !outputQuantityList.length) {
117
- errors.push('입력과 출력이 모두 비었다 변환은 들어간 것이나 나온 하나는 말해야 한다');
115
+ /*
116
+ * **양쪽이 있어야 한다** EPCIS 2.0 §7.4.5 의 정의다: 「objects … are fully or partially
117
+ * consumed as inputs **and** one or more objects … are produced as outputs」. 네 목록 필드가
118
+ * 각각 선택인 것은 개체로 말하든 수량으로 말하든 되기 때문이고, 한쪽을 빼도 된다는 뜻이 아니다.
119
+ *
120
+ * 검증기도 같은 것을 본다. 여기서 함께 말하는 이유는 **가리키는 곳이 다르기** 때문이다 — 검증
121
+ * 메시지는 이벤트를 가리키고 이 메시지는 매핑을 가리킨다. 커넥터가 고칠 자리가 다르다.
122
+ */
123
+ if (!inputEPCList.length && !inputQuantityList.length) {
124
+ errors.push('입력이 비었다 — 변환은 무엇이 들어갔는지 말해야 한다(EPCIS 2.0 §7.4.5)');
125
+ }
126
+ if (!outputEPCList.length && !outputQuantityList.length) {
127
+ errors.push('출력이 비었다 — 변환은 무엇이 나왔는지 말해야 한다(EPCIS 2.0 §7.4.5)');
118
128
  }
119
129
  return {
120
130
  event: transformationEvent({
121
131
  eventTime, bizStep, disposition,
122
132
  /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
123
- 소실(N→0)과 생성(0→N)은 표준이 인정하는 변환이고, 그때 한쪽이 아예 없는 것이 사실이다. */
133
+ 개체로 말한 쪽과 수량으로 말한 하나만 쓰는 것이 정상이다. */
124
134
  ...(inputEPCList.length ? { inputEPCList } : {}),
125
135
  ...(inputQuantityList.length ? { inputQuantityList } : {}),
126
136
  ...(outputEPCList.length ? { outputEPCList } : {}),
@@ -1,4 +1,4 @@
1
- import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift, IdentityGroundingView } from './contract.ts';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -233,6 +233,20 @@ export interface FlowOrder {
233
233
  * 말한다.** 이 표시가 없으면 그 상황과 「우리 계산의 결함」을 구별할 수 없다.
234
234
  */
235
235
  seedIncomplete?: boolean;
236
+ /**
237
+ * **이 오더가 왜 대기하는가** — 마지막 확보 시도에서 모자랐던 투입 줄들.
238
+ *
239
+ * 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳은데 **조용했다**:
240
+ * 실측에서 투입 18줄 중 6줄이 입고되지 않아 오더 다섯이 영구히 대기했고 화면에는 「running」이라
241
+ * 적혔다. 어느 자리도 이유를 말하지 않았다.
242
+ *
243
+ * 매 틱 새로 쓰인다(확보를 다시 시도하므로). 채워지면 지운다 — 남겨 두면 해결된 것을 계속 말한다.
244
+ */
245
+ shortage?: {
246
+ material: string;
247
+ need: number;
248
+ have: number;
249
+ }[];
236
250
  /** 우선순위 — 표준 `OperationsRequest.Priority`(작은 값이 급하다). 할당 순서를 정한다. */
237
251
  priority?: number;
238
252
  /** 예정 창 — 표준 `StartTime`/`EndTime`. 납기가 있어야 "늦었나" 를 물을 수 있다. */
@@ -1239,6 +1253,26 @@ export declare abstract class FlowEngine implements TwinKernel {
1239
1253
  * 코어는 여기서 **정체성의 값을 정하지 않는다**: 「어디서 왔나」만 묻는다.
1240
1254
  */
1241
1255
  protected identityGroundingView(): IdentityGroundingView;
1256
+ /**
1257
+ * **이 트윈의 정체성 선언을 읽는 한 곳.**
1258
+ *
1259
+ * 모델이 갖는다(§`TwinModelDef.identity`) — 정체성은 생산과 무관하고, 생산하지 않는 트윈도 팔레트·
1260
+ * 트레일러를 식별한다. 선언을 든 커널이 자기 것을 먼저 쓰도록 override 할 수 있다(MES 가 그렇게 한다:
1261
+ * `ProductionSpec.identity` 가 먼저 생긴 자리다).
1262
+ *
1263
+ * 규칙을 두 곳에 적지 않기 위해 조립하는 쪽은 전부 이 함수를 지난다.
1264
+ */
1265
+ protected identityDeclaration(): IdentityDeclaration | undefined;
1266
+ /**
1267
+ * 선언된 이름공간 아래의 **개체 식별자** — 없으면 답하지 않는다.
1268
+ *
1269
+ * 팔레트·트레일러는 개체 식별자이고 GS1 키(SSCC·GRAI)에는 회사 프리픽스가 필요하다. 프리픽스가 없는
1270
+ * 현장은 CBV §8.2.3·§8.2.4 의 길로 간다. 선언이 없으면 **지어내지 않고 `undefined` 를 답한다** —
1271
+ * 호출부가 무엇을 할지 정한다(레거시 경로는 아직 상수를 쓴다).
1272
+ */
1273
+ protected declaredObjectId(id: string | number): string | undefined;
1274
+ /** 선언된 이름공간 아래의 **거래 문서 식별자**(발주·주문·어포인트먼트) — 없으면 답하지 않는다. */
1275
+ protected declaredBizTransactionId(id: string | number): string | undefined;
1242
1276
  private produceMaterials;
1243
1277
  /**
1244
1278
  * 이 작업이 **딛고 선 것**이 아직 있나 — 없으면 무엇이 없는지 답한다.
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
- import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
14
+ import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri } from "./epcis.js";
15
15
  import { OP_PARAM } from "./domain-definition.js";
16
16
  import { parseIsoDuration } from "./iso-duration.js";
17
17
  import { analyzeCapacity } from "./capacity.js";
@@ -373,6 +373,11 @@ export class ItemStore {
373
373
  out.set(k, structuredClone(it));
374
374
  return out;
375
375
  }
376
+ /*
377
+ * 지연 순회(`*iterAt`)를 만들어 배열을 없애 보았으나 **더 느렸다** — 물품 16,000 규모에서 틱 합계가
378
+ * 1562ms 에서 2723ms 로 늘었다. 항목마다 생성기 규약을 지나는 비용이 배열 하나를 만드는 비용보다
379
+ * 크다. 측정이 그렇게 답했으므로 배열을 유지한다.
380
+ */
376
381
  /** 그 자리에 있는 물품들 — 색인이 답한다(전체 순회가 아니다). */
377
382
  at(location) {
378
383
  const keys = this.byLocation.get(location);
@@ -1325,6 +1330,8 @@ export class FlowEngine {
1325
1330
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1326
1331
  /* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
1327
1332
  ...(o.seedIncomplete ? { seedIncomplete: true } : {}),
1333
+ /* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
1334
+ ...(o.shortage?.length ? { shortage: o.shortage.map(x => ({ ...x })) } : {}),
1328
1335
  ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
1329
1336
  ...(o.priority !== undefined ? { priority: o.priority } : {}),
1330
1337
  ...(o.startTime ? { startTime: o.startTime } : {}),
@@ -2571,6 +2578,42 @@ export class FlowEngine {
2571
2578
  identityGroundingView() {
2572
2579
  return identityGroundingOf(undefined);
2573
2580
  }
2581
+ /**
2582
+ * **이 트윈의 정체성 선언을 읽는 한 곳.**
2583
+ *
2584
+ * 모델이 갖는다(§`TwinModelDef.identity`) — 정체성은 생산과 무관하고, 생산하지 않는 트윈도 팔레트·
2585
+ * 트레일러를 식별한다. 선언을 든 커널이 자기 것을 먼저 쓰도록 override 할 수 있다(MES 가 그렇게 한다:
2586
+ * `ProductionSpec.identity` 가 먼저 생긴 자리다).
2587
+ *
2588
+ * 규칙을 두 곳에 적지 않기 위해 조립하는 쪽은 전부 이 함수를 지난다.
2589
+ */
2590
+ identityDeclaration() {
2591
+ return this.boardDef?.identity;
2592
+ }
2593
+ /**
2594
+ * 선언된 이름공간 아래의 **개체 식별자** — 없으면 답하지 않는다.
2595
+ *
2596
+ * 팔레트·트레일러는 개체 식별자이고 GS1 키(SSCC·GRAI)에는 회사 프리픽스가 필요하다. 프리픽스가 없는
2597
+ * 현장은 CBV §8.2.3·§8.2.4 의 길로 간다. 선언이 없으면 **지어내지 않고 `undefined` 를 답한다** —
2598
+ * 호출부가 무엇을 할지 정한다(레거시 경로는 아직 상수를 쓴다).
2599
+ */
2600
+ declaredObjectId(id) {
2601
+ for (const ns of this.identityDeclaration()?.namespaces ?? []) {
2602
+ const uri = objectUri(ns, id);
2603
+ if (uri)
2604
+ return uri;
2605
+ }
2606
+ return undefined;
2607
+ }
2608
+ /** 선언된 이름공간 아래의 **거래 문서 식별자**(발주·주문·어포인트먼트) — 없으면 답하지 않는다. */
2609
+ declaredBizTransactionId(id) {
2610
+ for (const ns of this.identityDeclaration()?.namespaces ?? []) {
2611
+ const uri = bizTransactionUri(ns, id);
2612
+ if (uri)
2613
+ return uri;
2614
+ }
2615
+ return undefined;
2616
+ }
2574
2617
  produceMaterials(t) {
2575
2618
  /* 도메인이 소유를 주장하면 코어는 비켜선다 — 둘이 만들면 재고가 두 배가 된다. */
2576
2619
  if (this.producesOwnOutputs(t.kind))
package/dist/kernel.js CHANGED
@@ -57,13 +57,17 @@ export class WmsKernel extends FlowEngine {
57
57
  /** 입고 도착 — §4 라이프사이클: ASN(PO) → 팔레트 조립 → 수령 → putaway task. */
58
58
  onArrival(spec) {
59
59
  const dock = this.builtInLocation('dock', 'inbound pallets land here');
60
- const epc = ssccUri(LEGACY_COMPANY_PREFIX, ++this.epcSeq); // 팔레트 SSCC
60
+ /* 선언이 있으면 선언에서(CBV §8.2.4) 없으면 레거시 상수. 가드가 그 상수를 계속 센다. */
61
+ const seq = ++this.epcSeq;
62
+ const epc = this.declaredObjectId(`PAL-${seq}`) ?? ssccUri(LEGACY_COMPANY_PREFIX, seq); // 팔레트
61
63
  const gtin = this.pickGtin(spec.content.skuMix); // SGTIN idpat = epcClass
62
64
  /* 품목 구성이 비어 있으면 **도착을 만들지 않는다** — 무엇이 왔는지 말할 수 없는 입고는 사실이 아니다. */
63
65
  if (!gtin)
64
66
  return;
65
67
  const qty = this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max); // 케이스 수(비직렬)
66
- const po = gdtiUri(LEGACY_COMPANY_PREFIX, '401', ++this.poSeq);
68
+ const poSeq = ++this.poSeq;
69
+ /* 발주 문서 식별자 — 선언된 이름공간 아래(CBV §8.5.5) 또는 선언된 GDTI 문서 타입. */
70
+ const po = this.declaredBizTransactionId(`PO-${poSeq}`) ?? gdtiUri(LEGACY_COMPANY_PREFIX, this.identityDeclaration()?.documentTypes?.purchaseorder ?? '401', poSeq);
67
71
  const eventTime = this.now();
68
72
  const qtyList = [{ epcClass: gtin, quantity: qty }];
69
73
  const poTxn = [{ type: BTT.po, bizTransaction: po }];
@@ -137,7 +141,8 @@ export class WmsKernel extends FlowEngine {
137
141
  /** 오더 생성 — 약속(납기·우선순위)은 base `promiseOf` 가 계산한다(도메인마다 다르게 재지 않는다). */
138
142
  createSalesOrder(lines, spec) {
139
143
  const id = `order-${++this.orderSeq}`;
140
- const so = gdtiUri(LEGACY_COMPANY_PREFIX, '402', ++this.soSeq);
144
+ const soSeq = ++this.soSeq;
145
+ const so = this.declaredBizTransactionId(`SO-${soSeq}`) ?? gdtiUri(LEGACY_COMPANY_PREFIX, this.identityDeclaration()?.documentTypes?.salesorder ?? '402', soSeq);
141
146
  const requested = lines.reduce((s, l) => s + l.qty, 0);
142
147
  const order = {
143
148
  id, kind: 'outbound', status: 'created', requested, fulfilled: 0, bizTransaction: so,
@@ -349,7 +354,8 @@ export class WmsKernel extends FlowEngine {
349
354
  continue;
350
355
  /* 클래스 줄을 팔레트로 바꾼다 — 자리 점유는 줄 하나에서 줄 하나로(늘지 않는다). */
351
356
  this.items.delete(key);
352
- const pallet = ssccUri(LEGACY_COMPANY_PREFIX, ++this.epcSeq);
357
+ const palSeq = ++this.epcSeq;
358
+ const pallet = this.declaredObjectId(`PAL-${palSeq}`) ?? ssccUri(LEGACY_COMPANY_PREFIX, palSeq);
353
359
  const qtyList = [{ epcClass: m.definitionId, quantity: qty }];
354
360
  this.items.set(pallet, { epc: pallet, gtin: m.definitionId, qty, location: at, disposition: DISP.in_progress });
355
361
  const eventTime = this.now();
@@ -366,7 +372,8 @@ export class WmsKernel extends FlowEngine {
366
372
  창고의 모양이다(도크 단계가 없는 현장이 있다). 그 사실은 화물의 출발 자리로 저널에 남는다. */
367
373
  const shipDock = this.locationByType('dock-ship') ?? staging;
368
374
  const eventTime = this.now();
369
- const shipment = ssccUri(LEGACY_COMPANY_PREFIX, ++this.epcSeq);
375
+ const shpSeq = ++this.epcSeq;
376
+ const shipment = this.declaredObjectId(`SHP-${shpSeq}`) ?? ssccUri(LEGACY_COMPANY_PREFIX, shpSeq);
370
377
  order.shipmentEpc = shipment;
371
378
  const soTxn = [{ type: BTT.so, bizTransaction: order.bizTransaction }];
372
379
  // 패킹: 화물(shipment) ← 팔레트 조립(merge). 팔레트는 출하 DELETE 까지 독립 유지 → consume 없음.
@@ -1,4 +1,4 @@
1
- import type { GeneratorSpec, Command, CommandAck, ProductionSpec, TwinModelDef, IdentityGroundingView } from './contract.ts';
1
+ import type { GeneratorSpec, Command, CommandAck, ProductionSpec, TwinModelDef, IdentityGroundingView, Attention } from './contract.ts';
2
2
  import type { AllocationPolicy } from './allocation-policy.ts';
3
3
  import { FlowEngine } from './flow-engine.ts';
4
4
  import type { FlowOrder, FlowTask } from './flow-engine.ts';
@@ -37,6 +37,13 @@ export declare class MesKernel extends FlowEngine {
37
37
  * 아니다). 선언이 없는 MES 트윈은 이제 만들 수 없으므로(`loadTwinModel` 이 거절한다) `fabricated` 로
38
38
  * 판정될 일이 없다.
39
39
  */
40
+ /**
41
+ * MES 는 **생산 선언의 정체성을 먼저** 쓴다 — `ProductionSpec.identity` 가 먼저 생긴 자리다.
42
+ *
43
+ * 모델 자리(§`TwinModelDef.identity`)로 옮기면 이 override 는 사라진다. 두 자리를 동시에 읽는 것이
44
+ * 아니라 **하나를 고르는** 것이므로 규칙이 갈리지 않는다.
45
+ */
46
+ protected identityDeclaration(): import("./contract.ts").IdentityDeclaration | undefined;
40
47
  protected identityGroundingView(): IdentityGroundingView;
41
48
  /**
42
49
  * 선언이 말하는 자리 타입이 **이 트윈에 있나** — 로드 시점에 한 번, 모아서 말한다 (2026-08-20).
@@ -60,6 +67,20 @@ export declare class MesKernel extends FlowEngine {
60
67
  * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
61
68
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
62
69
  */
70
+ /**
71
+ * **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
72
+ *
73
+ * ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
74
+ * 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
75
+ * 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
76
+ * 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
77
+ *
78
+ * 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
79
+ * 한 곳에서 맡는다(확장점 규약).
80
+ *
81
+ * 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
82
+ */
83
+ protected collectAttentions(): Attention[];
63
84
  protected handleCommand(cmd: Command): CommandAck;
64
85
  /** 부품 수령 — 도착한 품목이 선언된 입력 자재면 그 자재가 선언한 자리에 생성. */
65
86
  protected onArrival(spec: GeneratorSpec): void;
@@ -99,6 +99,15 @@ export class MesKernel extends FlowEngine {
99
99
  * 아니다). 선언이 없는 MES 트윈은 이제 만들 수 없으므로(`loadTwinModel` 이 거절한다) `fabricated` 로
100
100
  * 판정될 일이 없다.
101
101
  */
102
+ /**
103
+ * MES 는 **생산 선언의 정체성을 먼저** 쓴다 — `ProductionSpec.identity` 가 먼저 생긴 자리다.
104
+ *
105
+ * 모델 자리(§`TwinModelDef.identity`)로 옮기면 이 override 는 사라진다. 두 자리를 동시에 읽는 것이
106
+ * 아니라 **하나를 고르는** 것이므로 규칙이 갈리지 않는다.
107
+ */
108
+ identityDeclaration() {
109
+ return this.productionSpec?.identity ?? super.identityDeclaration();
110
+ }
102
111
  identityGroundingView() {
103
112
  return identityGroundingOf(this.productionSpec);
104
113
  }
@@ -149,6 +158,43 @@ export class MesKernel extends FlowEngine {
149
158
  * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
150
159
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
151
160
  */
161
+ /**
162
+ * **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
163
+ *
164
+ * ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
165
+ * 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
166
+ * 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
167
+ * 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
168
+ *
169
+ * 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
170
+ * 한 곳에서 맡는다(확장점 규약).
171
+ *
172
+ * 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
173
+ */
174
+ collectAttentions() {
175
+ const out = super.collectAttentions();
176
+ for (const o of this.orders.values()) {
177
+ const short = o.shortage;
178
+ if (!short?.length)
179
+ continue;
180
+ out.push({
181
+ id: `material-short:${o.id}`,
182
+ kind: 'material-short',
183
+ /* 한 줄이 모자란 것과 여러 줄이 모자란 것은 할 일이 다르다 — 여럿이면 자재 공급 자체가 문제다. */
184
+ severity: short.length > 1 ? 'high' : 'medium',
185
+ anchor: { orderId: o.id },
186
+ params: {
187
+ /* 모자란 줄 수와 투입 줄 수 — 「6/18」이 「하나 모자람」과 다른 상황임을 한눈에 말한다. */
188
+ shortLines: short.length,
189
+ /* 자재 키를 그대로 낸다. 커널이 사람이 읽는 이름을 만들지 않는다 — 이름은 모델이 갖는다. */
190
+ materials: short.map(x => x.material).join(', '),
191
+ need: short.reduce((n, x) => n + x.need, 0),
192
+ have: short.reduce((n, x) => n + x.have, 0)
193
+ }
194
+ });
195
+ }
196
+ return out;
197
+ }
152
198
  handleCommand(cmd) {
153
199
  if (cmd.type === MES_CMD.changeover) {
154
200
  const a = cmd.args;
@@ -417,6 +463,7 @@ export class MesKernel extends FlowEngine {
417
463
  }
418
464
  const rc = this.recipeDef(o);
419
465
  const picks = [];
466
+ const short = [];
420
467
  /*
421
468
  * ── 자리별 색인을 사용한다 (2026-08-21) ────────────────────────────────────
422
469
  * 예전에는 투입 줄마다 `[...this.items.values()].filter(...)` 로 **물품 전체를 순회**했다. 오더 하나에
@@ -438,6 +485,11 @@ export class MesKernel extends FlowEngine {
438
485
  const g = this.classOf(line.material);
439
486
  /* 자리 필터는 남는다 — 완제품도 `sellable` 이라, 반제품→완제품 체인에서 이것이 없으면 산출물을 자재로 소비한다. */
440
487
  const fromType = this.locationTypeOfMaterial(line.material);
488
+ /*
489
+ * 후보를 배열로 모은다. 지연 순회(생성기)로 바꿔 보았으나 **더 느렸다** — 물품 16,000 규모에서
490
+ * 1562ms → 2723ms 였다. 항목마다 생성기 규약을 지나는 비용이 배열을 만드는 비용보다 크다.
491
+ * 측정이 그렇게 답했으므로 배열을 유지한다(§StockRequest.available 은 지연도 받는다).
492
+ */
441
493
  const available = [];
442
494
  for (const n of locsOfType.get(fromType) ?? []) {
443
495
  for (const i of this.items.at(n.id)) {
@@ -446,10 +498,31 @@ export class MesKernel extends FlowEngine {
446
498
  }
447
499
  }
448
500
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
449
- if (chosen.length < line.qty)
450
- return; // 자재 부족 → 대기
501
+ if (chosen.length < line.qty) {
502
+ /*
503
+ * **왜 대기하는지 남긴다** (2026-08-21).
504
+ *
505
+ * 예전에는 첫 부족한 줄에서 그대로 반환했다. 거동은 옳다(전량 확보 전에는 만들지 않는다) —
506
+ * 문제는 **조용한 것**이었다. 실측: Rosarito MES 는 투입 18줄 중 6줄이 한 번도 입고되지 않아
507
+ * 오더 다섯이 영구히 대기했고, 화면에는 「running」이라 적혔다. 어느 자리도 이유를 말하지 않았다.
508
+ *
509
+ * 첫 줄에서 멈추지 않고 **모자란 줄을 다 센다.** 하나만 알려 주면 그것을 채운 뒤 다음 줄에서
510
+ * 또 막히고, 사람은 같은 진단을 여섯 번 반복한다.
511
+ */
512
+ short.push({ material: line.material, need: line.qty, have: chosen.length });
513
+ continue;
514
+ }
451
515
  picks.push(...chosen);
452
516
  }
517
+ if (short.length) {
518
+ /* 확보는 하지 않는다 — 사실만 남기고 다음 틱에 다시 본다(값은 매 틱 새로 쓰인다). */
519
+ o.shortage = short;
520
+ this.emitOrder(o);
521
+ return;
522
+ }
523
+ /* 채워졌으면 표시를 지운다 — 남겨 두면 화면이 이미 해결된 것을 계속 말한다. */
524
+ if (o.shortage)
525
+ delete o.shortage;
453
526
  for (const epc of picks)
454
527
  o.allocated.push(epc);
455
528
  this.reserve(picks, MES_BIZSTEP.producing);
@@ -64,19 +64,25 @@ export class YmsKernel extends FlowEngine {
64
64
  if (!gate || doors.length === 0)
65
65
  return;
66
66
  const inbound = kind === 'appointment';
67
- const epc = graiUri(LEGACY_CP, '10', ++this.epcSeq);
67
+ const trSeq = ++this.epcSeq;
68
+ /* 선언이 있으면 선언에서(CBV §8.2.4) — 없으면 레거시 상수. */
69
+ const epc = this.declaredObjectId(`TRL-${trSeq}`) ?? graiUri(LEGACY_CP, this.identityDeclaration()?.documentTypes?.trailer ?? '10', trSeq);
68
70
  this.items.set(epc, { epc, location: gate.id, disposition: DISP.in_progress });
69
71
  gate.occupancy++;
70
72
  this.emit(objectEvent({ eventTime: this.now(), action: 'ADD', bizStep: YARD_BIZSTEP.arriving, disposition: DISP.in_progress, epcList: [epc], readPoint: gate.id, bizLocation: gate.id }));
71
73
  // 인바운드: 화물 적재된 채 도착 → 조립(트레일러←화물) 기록. 아웃바운드: 빈 트레일러(화물은 도크에서 적재).
72
74
  if (inbound) {
73
- const cargo = Array.from({ length: CARGO_PER_TRAILER }, () => ssccUri(LEGACY_CP, ++this.cargoSeq));
75
+ const cargo = Array.from({ length: CARGO_PER_TRAILER }, () => {
76
+ const c = ++this.cargoSeq;
77
+ return this.declaredObjectId(`CGO-${c}`) ?? ssccUri(LEGACY_CP, c);
78
+ });
74
79
  this.trailerCargo.set(epc, cargo);
75
80
  this.aggregate(epc, cargo, { bizStep: YARD_BIZSTEP.arriving, readPoint: gate.id }); // 적재된 채 도착(자식 opaque, 미materialize)
76
81
  }
77
82
  const door = doors[this.doorRR++ % doors.length];
78
83
  const id = `order-${++this.orderSeq}`;
79
- const appt = gdtiUri(LEGACY_CP, '404', ++this.soSeq);
84
+ const apSeq = ++this.soSeq;
85
+ const appt = this.declaredBizTransactionId(`APPT-${apSeq}`) ?? gdtiUri(LEGACY_CP, this.identityDeclaration()?.documentTypes?.appointment ?? '404', apSeq);
80
86
  const order = { id, kind, status: 'created', requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [epc], picked: [], dockDoor: door.id,
81
87
  /* 내부 스케줄 게이트(시뮬 ms) — 표준 `EarliestStartTime` 과 같은 개념의 우리 단위. */
82
88
  windowStartMs: this.clockMs + WINDOW_DELAY_MS,
@@ -138,6 +138,7 @@ __export(index_exports, {
138
138
  monteCarloForecast: () => monteCarloForecast,
139
139
  monteCarloForecastAsync: () => monteCarloForecastAsync,
140
140
  objectEvent: () => objectEvent,
141
+ objectUri: () => objectUri,
141
142
  offCalendarAt: () => offCalendarAt,
142
143
  offCalendarReasonAt: () => offCalendarReasonAt,
143
144
  operationalKindOf: () => operationalKindOf,
@@ -307,14 +308,14 @@ function classIndex(defs) {
307
308
  }
308
309
  var EMPTY_CLASS_INDEX = /* @__PURE__ */ new Map();
309
310
  function classClosure(directIds, defs, at) {
310
- const byId = classIndex(defs);
311
+ const byId2 = classIndex(defs);
311
312
  const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
312
313
  const out = /* @__PURE__ */ new Set();
313
314
  const stack = [...directIds ?? []];
314
315
  while (stack.length) {
315
316
  const id = stack.pop();
316
317
  if (out.has(id)) continue;
317
- const def = byId.get(id);
318
+ const def = byId2.get(id);
318
319
  if (!inWindow(def)) continue;
319
320
  out.add(id);
320
321
  for (const b of def?.baseIds ?? []) if (!out.has(b)) stack.push(b);
@@ -947,14 +948,20 @@ function parseEpc(uri) {
947
948
  function gdtiUri(companyPrefix, docType, serial) {
948
949
  return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
949
950
  }
951
+ function objectUri(namespace, objId) {
952
+ return underNamespace(namespace, "obj", objId);
953
+ }
950
954
  function bizTransactionUri(namespace, transId) {
955
+ return underNamespace(namespace, "bt", transId);
956
+ }
957
+ function underNamespace(namespace, marker, id) {
951
958
  const ns = namespace?.trim();
952
959
  if (!ns) return void 0;
953
- const id = String(transId);
954
- if (!id || id.includes("/") || id.includes(":")) return void 0;
955
- if (/^https?:\/\/[^/\s]+/.test(ns)) return `${ns.replace(/\/+$/, "")}/bt/${id}`;
960
+ const v = String(id);
961
+ if (!v || v.includes("/") || v.includes(":")) return void 0;
962
+ if (/^https?:\/\/[^/\s]+/.test(ns)) return `${ns.replace(/\/+$/, "")}/${marker}/${v}`;
956
963
  if (/^urn:epc(global)?:/.test(ns)) return void 0;
957
- if (/^urn:[^:\s]+/.test(ns)) return `${ns.replace(/:+$/, "")}:bt:${id}`;
964
+ if (/^urn:[^:\s]+/.test(ns)) return `${ns.replace(/:+$/, "")}:${marker}:${v}`;
958
965
  return void 0;
959
966
  }
960
967
  function header(type, eventTime, bizStep, opts) {
@@ -2122,11 +2129,11 @@ var EMS_TYPES = [
2122
2129
  }
2123
2130
  ];
2124
2131
  function electricalUpstreamOf(locations, id) {
2125
- const byId = new Map((locations ?? []).filter((l) => l?.id).map((l) => [String(l.id), l]));
2126
- const self = byId.get(String(id));
2132
+ const byId2 = new Map((locations ?? []).filter((l) => l?.id).map((l) => [String(l.id), l]));
2133
+ const self = byId2.get(String(id));
2127
2134
  if (!self) return void 0;
2128
2135
  if (self.upstreamId) return String(self.upstreamId);
2129
- const parent = self.parentId ? byId.get(String(self.parentId)) : void 0;
2136
+ const parent = self.parentId ? byId2.get(String(self.parentId)) : void 0;
2130
2137
  if (!parent) return void 0;
2131
2138
  return isElectricalLocationType(String(parent.type ?? "")) ? String(parent.id) : void 0;
2132
2139
  }
@@ -2526,9 +2533,20 @@ var TWIN_AXES = [
2526
2533
  * 아직 세우지 않은 것: **요금 구간**(TariffPeriod)과 **원단위**(EnPI). 둘 다 아직 아무도 만들지
2527
2534
  * 않는다 — 선언만 하면 개념 지도가 언제나 0 을 보여 주고, 그것은 결손처럼 읽힌다(§10 7단계의 일).
2528
2535
  */
2536
+ /*
2537
+ * **무엇이 이 항목을 가리키나** — `idField` 가 말한다.
2538
+ *
2539
+ * 수요 구간에는 `id` 가 없다. 그것이 결함은 아니다: 구간의 정체성은 **그 구간이 언제 시작했나**이고
2540
+ * (요금의 알갱이가 그 시각으로 정해진다) 커널도 그것으로 키를 만든다(`contract-projected-over:<startMs>`).
2541
+ *
2542
+ * 그런데 소비처가 `id`·`key`·`gtin` 을 **짐작**하고 있어서, 화면이 여섯 구간 모두를 「식별자 없음 —
2543
+ * 참조할 수 없는 항목」으로 보였다. 값은 실재하는데 가리킬 수 없다고 말한 것이다. 짐작을 없애고
2544
+ * 선언이 답하게 한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
2545
+ */
2529
2546
  {
2530
2547
  axis: "demandWindows",
2531
2548
  path: "energy.closed",
2549
+ idField: "startMs",
2532
2550
  label: "twin.axis.demandWindows",
2533
2551
  kind: "instance",
2534
2552
  source: "state",
@@ -2671,30 +2689,47 @@ function capabilitiesForType(system, typeKey) {
2671
2689
  }
2672
2690
 
2673
2691
  // src/allocation-policy.ts
2692
+ var byId = (a, b) => a < b ? -1 : a > b ? 1 : 0;
2674
2693
  function freeBinsFirstFit(slots) {
2675
- const sorted = [...slots].sort((a, b) => a.id.localeCompare(b.id));
2676
- for (const b of sorted) if (b.occupancy + b.reserved < b.capacity) return b.id;
2677
- return null;
2694
+ let best;
2695
+ for (const b of slots) {
2696
+ if (b.occupancy + b.reserved >= b.capacity) continue;
2697
+ if (!best || byId(b.id, best.id) < 0) best = b;
2698
+ }
2699
+ return best?.id ?? null;
2678
2700
  }
2679
- function sortByEpc(available) {
2680
- return [...available].sort((a, b) => a.epc.localeCompare(b.epc));
2701
+ function smallestByEpc(available, k) {
2702
+ if (k <= 0) return { picked: [], total: 0 };
2703
+ const out = [];
2704
+ let total = 0;
2705
+ for (const s of available) {
2706
+ total++;
2707
+ if (out.length === k && byId(s.epc, out[out.length - 1].epc) >= 0) continue;
2708
+ let i = out.length;
2709
+ while (i > 0 && byId(out[i - 1].epc, s.epc) > 0) i--;
2710
+ out.splice(i, 0, s);
2711
+ if (out.length > k) out.pop();
2712
+ }
2713
+ return { picked: out, total };
2681
2714
  }
2682
2715
  var firstFitPolicy = {
2683
2716
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
2684
2717
  selectStock: ({ qty, available }) => {
2685
- if (available.length < qty) return [];
2686
- return sortByEpc(available).slice(0, qty).map((s) => s.epc);
2718
+ const { picked, total } = smallestByEpc(available, qty);
2719
+ if (total < qty) return [];
2720
+ return picked.map((s) => s.epc);
2687
2721
  }
2688
2722
  };
2689
2723
  var partialFitPolicy = {
2690
2724
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
2691
- selectStock: ({ qty, available }) => sortByEpc(available).slice(0, qty).map((s) => s.epc)
2725
+ selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked.map((s) => s.epc)
2692
2726
  };
2693
2727
  var fefoPolicy = {
2694
2728
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
2695
2729
  selectStock: ({ qty, available }) => {
2696
- if (available.length < qty) return [];
2697
- const byExpiry = [...available].sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || a.epc.localeCompare(b.epc));
2730
+ const all = [...available];
2731
+ if (all.length < qty) return [];
2732
+ const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
2698
2733
  return byExpiry.slice(0, qty).map((s) => s.epc);
2699
2734
  }
2700
2735
  };
@@ -2861,8 +2896,11 @@ function mapRecordChecked(record, mapping, eventTime) {
2861
2896
  const outputEPCList = resolveList(mapping.outputEPCList, record, "outputEPCList", errors);
2862
2897
  const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, "outputQuantityList", errors);
2863
2898
  const transformationID = resolve(mapping.transformationID, record, "transformationID", errors);
2864
- if (!inputEPCList.length && !inputQuantityList.length && !outputEPCList.length && !outputQuantityList.length) {
2865
- errors.push("\uC785\uB825\uACFC \uCD9C\uB825\uC774 \uBAA8\uB450 \uBE44\uC5C8\uB2E4 \u2014 \uBCC0\uD658\uC740 \uB4E4\uC5B4\uAC04 \uAC83\uC774\uB098 \uB098\uC628 \uAC83 \uC911 \uD558\uB098\uB294 \uB9D0\uD574\uC57C \uD55C\uB2E4");
2899
+ if (!inputEPCList.length && !inputQuantityList.length) {
2900
+ errors.push("\uC785\uB825\uC774 \uBE44\uC5C8\uB2E4 \u2014 \uBCC0\uD658\uC740 \uBB34\uC5C7\uC774 \uB4E4\uC5B4\uAC14\uB294\uC9C0 \uB9D0\uD574\uC57C \uD55C\uB2E4(EPCIS 2.0 \xA77.4.5)");
2901
+ }
2902
+ if (!outputEPCList.length && !outputQuantityList.length) {
2903
+ errors.push("\uCD9C\uB825\uC774 \uBE44\uC5C8\uB2E4 \u2014 \uBCC0\uD658\uC740 \uBB34\uC5C7\uC774 \uB098\uC654\uB294\uC9C0 \uB9D0\uD574\uC57C \uD55C\uB2E4(EPCIS 2.0 \xA77.4.5)");
2866
2904
  }
2867
2905
  return {
2868
2906
  event: transformationEvent({
@@ -2870,7 +2908,7 @@ function mapRecordChecked(record, mapping, eventTime) {
2870
2908
  bizStep,
2871
2909
  disposition,
2872
2910
  /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
2873
- 소실(N→0)과 생성(0→N)은 표준이 인정하는 변환이고, 그때 한쪽이 아예 없는 것이 사실이다. */
2911
+ 개체로 말한 쪽과 수량으로 말한 하나만 쓰는 것이 정상이다. */
2874
2912
  ...inputEPCList.length ? { inputEPCList } : {},
2875
2913
  ...inputQuantityList.length ? { inputQuantityList } : {},
2876
2914
  ...outputEPCList.length ? { outputEPCList } : {},
@@ -3429,6 +3467,11 @@ var ItemStore = class _ItemStore {
3429
3467
  for (const [k, it] of this.map) out.set(k, structuredClone(it));
3430
3468
  return out;
3431
3469
  }
3470
+ /*
3471
+ * 지연 순회(`*iterAt`)를 만들어 배열을 없애 보았으나 **더 느렸다** — 물품 16,000 규모에서 틱 합계가
3472
+ * 1562ms 에서 2723ms 로 늘었다. 항목마다 생성기 규약을 지나는 비용이 배열 하나를 만드는 비용보다
3473
+ * 크다. 측정이 그렇게 답했으므로 배열을 유지한다.
3474
+ */
3432
3475
  /** 그 자리에 있는 물품들 — 색인이 답한다(전체 순회가 아니다). */
3433
3476
  at(location) {
3434
3477
  const keys = this.byLocation.get(location);
@@ -4286,6 +4329,8 @@ var FlowEngine = class {
4286
4329
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
4287
4330
  /* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
4288
4331
  ...o.seedIncomplete ? { seedIncomplete: true } : {},
4332
+ /* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
4333
+ ...o.shortage?.length ? { shortage: o.shortage.map((x) => ({ ...x })) } : {},
4289
4334
  ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {},
4290
4335
  ...o.priority !== void 0 ? { priority: o.priority } : {},
4291
4336
  ...o.startTime ? { startTime: o.startTime } : {},
@@ -5403,6 +5448,40 @@ var FlowEngine = class {
5403
5448
  identityGroundingView() {
5404
5449
  return identityGroundingOf(void 0);
5405
5450
  }
5451
+ /**
5452
+ * **이 트윈의 정체성 선언을 읽는 한 곳.**
5453
+ *
5454
+ * 모델이 갖는다(§`TwinModelDef.identity`) — 정체성은 생산과 무관하고, 생산하지 않는 트윈도 팔레트·
5455
+ * 트레일러를 식별한다. 선언을 든 커널이 자기 것을 먼저 쓰도록 override 할 수 있다(MES 가 그렇게 한다:
5456
+ * `ProductionSpec.identity` 가 먼저 생긴 자리다).
5457
+ *
5458
+ * 규칙을 두 곳에 적지 않기 위해 조립하는 쪽은 전부 이 함수를 지난다.
5459
+ */
5460
+ identityDeclaration() {
5461
+ return this.boardDef?.identity;
5462
+ }
5463
+ /**
5464
+ * 선언된 이름공간 아래의 **개체 식별자** — 없으면 답하지 않는다.
5465
+ *
5466
+ * 팔레트·트레일러는 개체 식별자이고 GS1 키(SSCC·GRAI)에는 회사 프리픽스가 필요하다. 프리픽스가 없는
5467
+ * 현장은 CBV §8.2.3·§8.2.4 의 길로 간다. 선언이 없으면 **지어내지 않고 `undefined` 를 답한다** —
5468
+ * 호출부가 무엇을 할지 정한다(레거시 경로는 아직 상수를 쓴다).
5469
+ */
5470
+ declaredObjectId(id) {
5471
+ for (const ns of this.identityDeclaration()?.namespaces ?? []) {
5472
+ const uri = objectUri(ns, id);
5473
+ if (uri) return uri;
5474
+ }
5475
+ return void 0;
5476
+ }
5477
+ /** 선언된 이름공간 아래의 **거래 문서 식별자**(발주·주문·어포인트먼트) — 없으면 답하지 않는다. */
5478
+ declaredBizTransactionId(id) {
5479
+ for (const ns of this.identityDeclaration()?.namespaces ?? []) {
5480
+ const uri = bizTransactionUri(ns, id);
5481
+ if (uri) return uri;
5482
+ }
5483
+ return void 0;
5484
+ }
5406
5485
  produceMaterials(t) {
5407
5486
  if (this.producesOwnOutputs(t.kind)) return;
5408
5487
  const made = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter((m) => m.use === "produced");
@@ -6046,11 +6125,13 @@ var WmsKernel = class extends FlowEngine {
6046
6125
  /** 입고 도착 — §4 라이프사이클: ASN(PO) → 팔레트 조립 → 수령 → putaway task. */
6047
6126
  onArrival(spec) {
6048
6127
  const dock = this.builtInLocation("dock", "inbound pallets land here");
6049
- const epc = ssccUri(LEGACY_COMPANY_PREFIX, ++this.epcSeq);
6128
+ const seq = ++this.epcSeq;
6129
+ const epc = this.declaredObjectId(`PAL-${seq}`) ?? ssccUri(LEGACY_COMPANY_PREFIX, seq);
6050
6130
  const gtin = this.pickGtin(spec.content.skuMix);
6051
6131
  if (!gtin) return;
6052
6132
  const qty = this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max);
6053
- const po = gdtiUri(LEGACY_COMPANY_PREFIX, "401", ++this.poSeq);
6133
+ const poSeq = ++this.poSeq;
6134
+ const po = this.declaredBizTransactionId(`PO-${poSeq}`) ?? gdtiUri(LEGACY_COMPANY_PREFIX, this.identityDeclaration()?.documentTypes?.purchaseorder ?? "401", poSeq);
6054
6135
  const eventTime = this.now();
6055
6136
  const qtyList = [{ epcClass: gtin, quantity: qty }];
6056
6137
  const poTxn = [{ type: BTT.po, bizTransaction: po }];
@@ -6121,7 +6202,8 @@ var WmsKernel = class extends FlowEngine {
6121
6202
  /** 오더 생성 — 약속(납기·우선순위)은 base `promiseOf` 가 계산한다(도메인마다 다르게 재지 않는다). */
6122
6203
  createSalesOrder(lines, spec) {
6123
6204
  const id = `order-${++this.orderSeq}`;
6124
- const so = gdtiUri(LEGACY_COMPANY_PREFIX, "402", ++this.soSeq);
6205
+ const soSeq = ++this.soSeq;
6206
+ const so = this.declaredBizTransactionId(`SO-${soSeq}`) ?? gdtiUri(LEGACY_COMPANY_PREFIX, this.identityDeclaration()?.documentTypes?.salesorder ?? "402", soSeq);
6125
6207
  const requested = lines.reduce((s, l) => s + l.qty, 0);
6126
6208
  const order = {
6127
6209
  id,
@@ -6294,7 +6376,8 @@ var WmsKernel = class extends FlowEngine {
6294
6376
  const qty = row.qty ?? 0;
6295
6377
  if (qty <= 0) continue;
6296
6378
  this.items.delete(key);
6297
- const pallet = ssccUri(LEGACY_COMPANY_PREFIX, ++this.epcSeq);
6379
+ const palSeq = ++this.epcSeq;
6380
+ const pallet = this.declaredObjectId(`PAL-${palSeq}`) ?? ssccUri(LEGACY_COMPANY_PREFIX, palSeq);
6298
6381
  const qtyList = [{ epcClass: m.definitionId, quantity: qty }];
6299
6382
  this.items.set(pallet, { epc: pallet, gtin: m.definitionId, qty, location: at, disposition: DISP.in_progress });
6300
6383
  const eventTime = this.now();
@@ -6308,7 +6391,8 @@ var WmsKernel = class extends FlowEngine {
6308
6391
  finalizeOrder(order, staging) {
6309
6392
  const shipDock = this.locationByType("dock-ship") ?? staging;
6310
6393
  const eventTime = this.now();
6311
- const shipment = ssccUri(LEGACY_COMPANY_PREFIX, ++this.epcSeq);
6394
+ const shpSeq = ++this.epcSeq;
6395
+ const shipment = this.declaredObjectId(`SHP-${shpSeq}`) ?? ssccUri(LEGACY_COMPANY_PREFIX, shpSeq);
6312
6396
  order.shipmentEpc = shipment;
6313
6397
  const soTxn = [{ type: BTT.so, bizTransaction: order.bizTransaction }];
6314
6398
  this.aggregate(shipment, order.picked.slice(), { bizStep: BIZSTEP.packing, readPoint: staging.id, bizLocation: staging.id });
@@ -6371,18 +6455,23 @@ var YmsKernel = class extends FlowEngine {
6371
6455
  const doors = [...this.locations.values()].filter((n) => n.type === "dock-door").sort((a, b) => a.id.localeCompare(b.id));
6372
6456
  if (!gate || doors.length === 0) return;
6373
6457
  const inbound = kind === "appointment";
6374
- const epc = graiUri(LEGACY_CP, "10", ++this.epcSeq);
6458
+ const trSeq = ++this.epcSeq;
6459
+ const epc = this.declaredObjectId(`TRL-${trSeq}`) ?? graiUri(LEGACY_CP, this.identityDeclaration()?.documentTypes?.trailer ?? "10", trSeq);
6375
6460
  this.items.set(epc, { epc, location: gate.id, disposition: DISP.in_progress });
6376
6461
  gate.occupancy++;
6377
6462
  this.emit(objectEvent({ eventTime: this.now(), action: "ADD", bizStep: YARD_BIZSTEP.arriving, disposition: DISP.in_progress, epcList: [epc], readPoint: gate.id, bizLocation: gate.id }));
6378
6463
  if (inbound) {
6379
- const cargo = Array.from({ length: CARGO_PER_TRAILER }, () => ssccUri(LEGACY_CP, ++this.cargoSeq));
6464
+ const cargo = Array.from({ length: CARGO_PER_TRAILER }, () => {
6465
+ const c = ++this.cargoSeq;
6466
+ return this.declaredObjectId(`CGO-${c}`) ?? ssccUri(LEGACY_CP, c);
6467
+ });
6380
6468
  this.trailerCargo.set(epc, cargo);
6381
6469
  this.aggregate(epc, cargo, { bizStep: YARD_BIZSTEP.arriving, readPoint: gate.id });
6382
6470
  }
6383
6471
  const door = doors[this.doorRR++ % doors.length];
6384
6472
  const id = `order-${++this.orderSeq}`;
6385
- const appt = gdtiUri(LEGACY_CP, "404", ++this.soSeq);
6473
+ const apSeq = ++this.soSeq;
6474
+ const appt = this.declaredBizTransactionId(`APPT-${apSeq}`) ?? gdtiUri(LEGACY_CP, this.identityDeclaration()?.documentTypes?.appointment ?? "404", apSeq);
6386
6475
  const order = {
6387
6476
  id,
6388
6477
  kind,
@@ -6575,6 +6664,15 @@ var MesKernel = class extends FlowEngine {
6575
6664
  * 아니다). 선언이 없는 MES 트윈은 이제 만들 수 없으므로(`loadTwinModel` 이 거절한다) `fabricated` 로
6576
6665
  * 판정될 일이 없다.
6577
6666
  */
6667
+ /**
6668
+ * MES 는 **생산 선언의 정체성을 먼저** 쓴다 — `ProductionSpec.identity` 가 먼저 생긴 자리다.
6669
+ *
6670
+ * 모델 자리(§`TwinModelDef.identity`)로 옮기면 이 override 는 사라진다. 두 자리를 동시에 읽는 것이
6671
+ * 아니라 **하나를 고르는** 것이므로 규칙이 갈리지 않는다.
6672
+ */
6673
+ identityDeclaration() {
6674
+ return this.productionSpec?.identity ?? super.identityDeclaration();
6675
+ }
6578
6676
  identityGroundingView() {
6579
6677
  return identityGroundingOf(this.productionSpec);
6580
6678
  }
@@ -6620,6 +6718,42 @@ var MesKernel = class extends FlowEngine {
6620
6718
  * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
6621
6719
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
6622
6720
  */
6721
+ /**
6722
+ * **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
6723
+ *
6724
+ * ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
6725
+ * 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
6726
+ * 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
6727
+ * 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
6728
+ *
6729
+ * 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
6730
+ * 한 곳에서 맡는다(확장점 규약).
6731
+ *
6732
+ * 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
6733
+ */
6734
+ collectAttentions() {
6735
+ const out = super.collectAttentions();
6736
+ for (const o of this.orders.values()) {
6737
+ const short = o.shortage;
6738
+ if (!short?.length) continue;
6739
+ out.push({
6740
+ id: `material-short:${o.id}`,
6741
+ kind: "material-short",
6742
+ /* 한 줄이 모자란 것과 여러 줄이 모자란 것은 할 일이 다르다 — 여럿이면 자재 공급 자체가 문제다. */
6743
+ severity: short.length > 1 ? "high" : "medium",
6744
+ anchor: { orderId: o.id },
6745
+ params: {
6746
+ /* 모자란 줄 수와 투입 줄 수 — 「6/18」이 「하나 모자람」과 다른 상황임을 한눈에 말한다. */
6747
+ shortLines: short.length,
6748
+ /* 자재 키를 그대로 낸다. 커널이 사람이 읽는 이름을 만들지 않는다 — 이름은 모델이 갖는다. */
6749
+ materials: short.map((x) => x.material).join(", "),
6750
+ need: short.reduce((n, x) => n + x.need, 0),
6751
+ have: short.reduce((n, x) => n + x.have, 0)
6752
+ }
6753
+ });
6754
+ }
6755
+ return out;
6756
+ }
6623
6757
  handleCommand(cmd) {
6624
6758
  if (cmd.type === MES_CMD.changeover) {
6625
6759
  const a = cmd.args;
@@ -6862,6 +6996,7 @@ var MesKernel = class extends FlowEngine {
6862
6996
  }
6863
6997
  const rc = this.recipeDef(o);
6864
6998
  const picks = [];
6999
+ const short = [];
6865
7000
  const locsOfType = /* @__PURE__ */ new Map();
6866
7001
  for (const n of this.locations.values()) {
6867
7002
  const bin = locsOfType.get(n.type);
@@ -6878,9 +7013,18 @@ var MesKernel = class extends FlowEngine {
6878
7013
  }
6879
7014
  }
6880
7015
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
6881
- if (chosen.length < line.qty) return;
7016
+ if (chosen.length < line.qty) {
7017
+ short.push({ material: line.material, need: line.qty, have: chosen.length });
7018
+ continue;
7019
+ }
6882
7020
  picks.push(...chosen);
6883
7021
  }
7022
+ if (short.length) {
7023
+ o.shortage = short;
7024
+ this.emitOrder(o);
7025
+ return;
7026
+ }
7027
+ if (o.shortage) delete o.shortage;
6884
7028
  for (const epc of picks) o.allocated.push(epc);
6885
7029
  this.reserve(picks, MES_BIZSTEP.producing);
6886
7030
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
@@ -8073,7 +8217,7 @@ function ingestOperationalRecords(records, opts) {
8073
8217
  // src/energy-attribution.ts
8074
8218
  var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
8075
8219
  function attributeEnergy(opts) {
8076
- const byId = new Map(opts.consumers.map((c) => [c.id, c]));
8220
+ const byId2 = new Map(opts.consumers.map((c) => [c.id, c]));
8077
8221
  const dedicated = /* @__PURE__ */ new Map();
8078
8222
  for (const c of opts.consumers) if (c.meterId) dedicated.set(c.meterId, c);
8079
8223
  const shares = [];
@@ -8090,7 +8234,7 @@ function attributeEnergy(opts) {
8090
8234
  }
8091
8235
  if (pool.overhead) {
8092
8236
  const alloc = opts.overheadAllocation;
8093
- const targets = (alloc?.processConsumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
8237
+ const targets = (alloc?.processConsumerIds ?? []).map((id) => byId2.get(id)).filter((c) => !!c);
8094
8238
  const wOf = (c) => alloc?.weightKind === "equal" ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
8095
8239
  const sharing2 = alloc?.weightKind === "equal" ? targets : targets.filter((c) => wOf(c) > 0);
8096
8240
  const ws = sharing2.map(wOf);
@@ -8116,7 +8260,7 @@ function attributeEnergy(opts) {
8116
8260
  });
8117
8261
  continue;
8118
8262
  }
8119
- const covered = (pool.consumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
8263
+ const covered = (pool.consumerIds ?? []).map((id) => byId2.get(id)).filter((c) => !!c);
8120
8264
  if (!covered.length) {
8121
8265
  unattributed.push({ poolMeterId: pool.meterId, kWh, reason: pool.remainder ? "not-submetered" : "no-consumers" });
8122
8266
  continue;
@@ -8416,6 +8560,7 @@ function retiredVocabularyIn(line) {
8416
8560
  monteCarloForecast,
8417
8561
  monteCarloForecastAsync,
8418
8562
  objectEvent,
8563
+ objectUri,
8419
8564
  offCalendarAt,
8420
8565
  offCalendarReasonAt,
8421
8566
  operationalKindOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.42",
3
+ "version": "0.7.44",
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": {