@operato/twin-kernel 0.7.41 → 0.7.43

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
  /**
@@ -59,7 +59,37 @@ export interface AggregationEventMapping {
59
59
  readPoint?: MapValue;
60
60
  bizLocation?: MapValue;
61
61
  }
62
- export type EventMapping = ObjectEventMapping | AggregationEventMapping;
62
+ /**
63
+ * **변환의 사실** — 무엇이 들어가 무엇이 나왔나(EPCIS `TransformationEvent`).
64
+ *
65
+ * ── 왜 이 갈래가 필요한가 (2026-08-21) ─────────────────────────────────────
66
+ * 정규 레코드 계약이 `ObjectEvent`·`AggregationEvent` 둘만 다뤄서, 원본이 「이 로트를 소비해 이 제품을
67
+ * 만들었다」고 말해도 실을 자리가 없었다. 그 사실이 문 앞에서 사라진다.
68
+ *
69
+ * **HACCP 회수 범위 판정이 정확히 이 자리를 요구한다** — 「이 로트가 어느 제품에 들어갔나」다. 로트를
70
+ * 개체로 식별할 수 없는 현장(연속량·분할 로트)에서는 소비를 `inputQuantityList` 의 **LGTIN 클래스**
71
+ * (`urn:epc:class:lgtin:<프리픽스>.<품번>.<로트>`)로 말한다 — 표준이 로트 단위 클래스를 그렇게 정한다.
72
+ *
73
+ * `action` 이 없다: 표준이 `TransformationEvent` 에 `action` 을 두지 않는다(들어감과 나옴이 곧 뜻이다).
74
+ */
75
+ export interface TransformationEventMapping {
76
+ type: 'TransformationEvent';
77
+ bizStep: MapValue;
78
+ disposition?: MapValue;
79
+ /** 소비된 개체들("$.inputEPCList") — 문자열 배열. */
80
+ inputEPCList?: MapValue;
81
+ /** 소비된 클래스+수량("$.inputQuantityList") — 로트 단위 소비가 이 자리다. */
82
+ inputQuantityList?: MapValue;
83
+ /** 산출된 개체들("$.outputEPCList"). */
84
+ outputEPCList?: MapValue;
85
+ /** 산출된 클래스+수량("$.outputQuantityList"). */
86
+ outputQuantityList?: MapValue;
87
+ /** 여러 이벤트를 한 변환으로 잇는 식별자("$.transformationID") — 한 오더의 여러 단계가 이것으로 묶인다. */
88
+ transformationID?: MapValue;
89
+ readPoint?: MapValue;
90
+ bizLocation?: MapValue;
91
+ }
92
+ export type EventMapping = ObjectEventMapping | AggregationEventMapping | TransformationEventMapping;
63
93
  /**
64
94
  * 이 품목 레코드가 **담김의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
65
95
  *
@@ -67,6 +97,17 @@ export type EventMapping = ObjectEventMapping | AggregationEventMapping;
67
97
  * 있으면 담김으로 본다 — 둘 다 있으면 커넥터가 무엇을 말하려는지 알 수 없으므로 담김으로 받지 않는다.
68
98
  */
69
99
  export declare function isAggregationRecord(record: unknown): boolean;
100
+ /**
101
+ * 이 레코드가 **변환의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
102
+ *
103
+ * 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
104
+ * 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
105
+ *
106
+ * 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 다 있어야 유효한 이벤트가 되지만(아래),
107
+ * 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
108
+ * 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
109
+ */
110
+ export declare function isTransformationRecord(record: unknown): boolean;
70
111
  /** 룰: 소스 레코드의 판별자(sourceType)로 매핑을 고른다. */
71
112
  export interface AdapterRule {
72
113
  sourceType: string;
@@ -8,7 +8,7 @@
8
8
  * 매핑 엔진은 walking-skeleton 범위에서 무의존 declarative spec 사용.
9
9
  * (jsonata/템플릿 엔진 최종 선택은 열린 결정 — face2-adapters.md. 커널 zero-dep 유지 위해 여기선 미도입.)
10
10
  */
11
- import { aggregationEvent, objectEvent, validateEpcisEvent } from "./epcis.js";
11
+ import { aggregationEvent, objectEvent, transformationEvent, validateEpcisEvent } from "./epcis.js";
12
12
  /**
13
13
  * 이 품목 레코드가 **담김의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
14
14
  *
@@ -21,6 +21,25 @@ export function isAggregationRecord(record) {
21
21
  const r = record;
22
22
  return typeof r.parentID === 'string' && r.parentID.trim().length > 0 && r.epc === undefined;
23
23
  }
24
+ /**
25
+ * 이 레코드가 **변환의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
26
+ *
27
+ * 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
28
+ * 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
29
+ *
30
+ * 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 다 있어야 유효한 이벤트가 되지만(아래),
31
+ * 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
32
+ * 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
33
+ */
34
+ export function isTransformationRecord(record) {
35
+ if (!record || typeof record !== 'object')
36
+ return false;
37
+ const r = record;
38
+ if (r.epc !== undefined || r.parentID !== undefined)
39
+ return false;
40
+ const has = (k) => Array.isArray(r[k]) && r[k].length > 0;
41
+ return has('inputEPCList') || has('inputQuantityList') || has('outputEPCList') || has('outputQuantityList');
42
+ }
24
43
  function get(obj, path) {
25
44
  return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
26
45
  }
@@ -83,12 +102,47 @@ function resolveQuantityList(v, record, field, errors) {
83
102
  /** 단일 레코드 → 정규 EPCIS 이벤트 + 매핑에서 드러난 문제(검증은 `ingest` 가 이어서 한다). */
84
103
  export function mapRecordChecked(record, mapping, eventTime) {
85
104
  const errors = [];
86
- const action = (resolve(mapping.action, record, 'action', errors) ?? '');
87
105
  const bizStep = resolve(mapping.bizStep, record, 'bizStep', errors) ?? '';
88
106
  const disposition = resolve(mapping.disposition, record, 'disposition', errors);
89
107
  const readPoint = resolve(mapping.readPoint, record, 'readPoint', errors);
90
108
  const bizLocation = resolve(mapping.bizLocation, record, 'bizLocation', errors);
109
+ if (mapping.type === 'TransformationEvent') {
110
+ const inputEPCList = resolveList(mapping.inputEPCList, record, 'inputEPCList', errors);
111
+ const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, 'inputQuantityList', errors);
112
+ const outputEPCList = resolveList(mapping.outputEPCList, record, 'outputEPCList', errors);
113
+ const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, 'outputQuantityList', errors);
114
+ const transformationID = resolve(mapping.transformationID, record, 'transformationID', errors);
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)');
128
+ }
129
+ return {
130
+ event: transformationEvent({
131
+ eventTime, bizStep, disposition,
132
+ /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
133
+ 개체로 말한 쪽과 수량으로 말한 쪽 중 하나만 쓰는 것이 정상이다. */
134
+ ...(inputEPCList.length ? { inputEPCList } : {}),
135
+ ...(inputQuantityList.length ? { inputQuantityList } : {}),
136
+ ...(outputEPCList.length ? { outputEPCList } : {}),
137
+ ...(outputQuantityList.length ? { outputQuantityList } : {}),
138
+ ...(transformationID ? { transformationID } : {}),
139
+ readPoint, bizLocation
140
+ }),
141
+ errors
142
+ };
143
+ }
91
144
  if (mapping.type === 'AggregationEvent') {
145
+ const action = (resolve(mapping.action, record, 'action', errors) ?? '');
92
146
  const parentID = resolve(mapping.parentID, record, 'parentID', errors) ?? '';
93
147
  const childEPCs = resolveList(mapping.childEPCs, record, 'childEPCs', errors);
94
148
  const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, 'childQuantityList', errors);
@@ -103,6 +157,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
103
157
  errors
104
158
  };
105
159
  }
160
+ const action = (resolve(mapping.action, record, 'action', errors) ?? '');
106
161
  const epc = resolve(mapping.epc, record, 'epc', errors);
107
162
  const quantityList = resolveQuantityList(mapping.quantityList, record, 'quantityList', errors);
108
163
  /* 개체도 수량도 없으면 무엇을 관측했는지 말하지 않은 것이다 — 검증에 넘기기 전에 여기서 말해 준다
@@ -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`. 납기가 있어야 "늦었나" 를 물을 수 있다. */
@@ -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 } : {}),
@@ -2084,6 +2091,13 @@ export class FlowEngine {
2084
2091
  * 시뮬레이션에서 이것은 **우리 계산의 결함**이다(우리가 없는 물건을 소비하라고 요구했다). 그래서
2085
2092
  * 멈춘다. 미러는 처방이 다르다 — 원본이 진실이므로 받아들이고 어긋난 횟수를 남긴다.
2086
2093
  *
2094
+ * ── 자리 규칙은 **여기서 확인하지 않는다** (2026-08-21에 호출부를 확인한 결과) ────
2095
+ * 「자재는 작업이 일어나는 자리에 있어야 소비된다」는 규칙이 있고, 그 확인은 **소비 경로**
2096
+ * (`claimMaterials`)가 이미 한다(그 자리에 있는 것만 선택한다). 이 원시에 같은 확인을 추가하면
2097
+ * 정상 경로를 막는다: MES 선언 경로는 자재를 **라인사이드 보관 자리**에서 선택하고 변환은
2098
+ * **공정 자리**에서 일어난다(`mes-kernel` 의 라우트 단계 — 두 자리가 다른 것이 그 경로의 설계다).
2099
+ * 추가하려면 먼저 그 경로가 물건을 공정 자리로 이동시키게 만들어야 하고, 그것은 별개의 결정이다.
2100
+ *
2087
2101
  * ── 미러를 멈추면 안 되는 이유 (2026-08-20, 시험으로 확인) ─────────────────
2088
2102
  * 씨앗으로 세운 커널은 그대로 `tick` 한다(§`hydrateObserved`). 원본이 확보분 하나를 누락한
2089
2103
  * 스냅샷을 주면 — 실 연동에서 흔한 빈틈이다 — 그 커널은 **첫 틱에 멈춘다.** 원본의 한 건이
@@ -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';
@@ -60,6 +60,20 @@ export declare class MesKernel extends FlowEngine {
60
60
  * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
61
61
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
62
62
  */
63
+ /**
64
+ * **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
65
+ *
66
+ * ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
67
+ * 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
68
+ * 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
69
+ * 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
70
+ *
71
+ * 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
72
+ * 한 곳에서 맡는다(확장점 규약).
73
+ *
74
+ * 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
75
+ */
76
+ protected collectAttentions(): Attention[];
63
77
  protected handleCommand(cmd: Command): CommandAck;
64
78
  /** 부품 수령 — 도착한 품목이 선언된 입력 자재면 그 자재가 선언한 자리에 생성. */
65
79
  protected onArrival(spec: GeneratorSpec): void;
@@ -149,6 +149,43 @@ export class MesKernel extends FlowEngine {
149
149
  * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
150
150
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
151
151
  */
152
+ /**
153
+ * **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
154
+ *
155
+ * ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
156
+ * 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
157
+ * 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
158
+ * 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
159
+ *
160
+ * 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
161
+ * 한 곳에서 맡는다(확장점 규약).
162
+ *
163
+ * 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
164
+ */
165
+ collectAttentions() {
166
+ const out = super.collectAttentions();
167
+ for (const o of this.orders.values()) {
168
+ const short = o.shortage;
169
+ if (!short?.length)
170
+ continue;
171
+ out.push({
172
+ id: `material-short:${o.id}`,
173
+ kind: 'material-short',
174
+ /* 한 줄이 모자란 것과 여러 줄이 모자란 것은 할 일이 다르다 — 여럿이면 자재 공급 자체가 문제다. */
175
+ severity: short.length > 1 ? 'high' : 'medium',
176
+ anchor: { orderId: o.id },
177
+ params: {
178
+ /* 모자란 줄 수와 투입 줄 수 — 「6/18」이 「하나 모자람」과 다른 상황임을 한눈에 말한다. */
179
+ shortLines: short.length,
180
+ /* 자재 키를 그대로 낸다. 커널이 사람이 읽는 이름을 만들지 않는다 — 이름은 모델이 갖는다. */
181
+ materials: short.map(x => x.material).join(', '),
182
+ need: short.reduce((n, x) => n + x.need, 0),
183
+ have: short.reduce((n, x) => n + x.have, 0)
184
+ }
185
+ });
186
+ }
187
+ return out;
188
+ }
152
189
  handleCommand(cmd) {
153
190
  if (cmd.type === MES_CMD.changeover) {
154
191
  const a = cmd.args;
@@ -417,6 +454,7 @@ export class MesKernel extends FlowEngine {
417
454
  }
418
455
  const rc = this.recipeDef(o);
419
456
  const picks = [];
457
+ const short = [];
420
458
  /*
421
459
  * ── 자리별 색인을 사용한다 (2026-08-21) ────────────────────────────────────
422
460
  * 예전에는 투입 줄마다 `[...this.items.values()].filter(...)` 로 **물품 전체를 순회**했다. 오더 하나에
@@ -438,6 +476,11 @@ export class MesKernel extends FlowEngine {
438
476
  const g = this.classOf(line.material);
439
477
  /* 자리 필터는 남는다 — 완제품도 `sellable` 이라, 반제품→완제품 체인에서 이것이 없으면 산출물을 자재로 소비한다. */
440
478
  const fromType = this.locationTypeOfMaterial(line.material);
479
+ /*
480
+ * 후보를 배열로 모은다. 지연 순회(생성기)로 바꿔 보았으나 **더 느렸다** — 물품 16,000 규모에서
481
+ * 1562ms → 2723ms 였다. 항목마다 생성기 규약을 지나는 비용이 배열을 만드는 비용보다 크다.
482
+ * 측정이 그렇게 답했으므로 배열을 유지한다(§StockRequest.available 은 지연도 받는다).
483
+ */
441
484
  const available = [];
442
485
  for (const n of locsOfType.get(fromType) ?? []) {
443
486
  for (const i of this.items.at(n.id)) {
@@ -446,10 +489,31 @@ export class MesKernel extends FlowEngine {
446
489
  }
447
490
  }
448
491
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
449
- if (chosen.length < line.qty)
450
- return; // 자재 부족 → 대기
492
+ if (chosen.length < line.qty) {
493
+ /*
494
+ * **왜 대기하는지 남긴다** (2026-08-21).
495
+ *
496
+ * 예전에는 첫 부족한 줄에서 그대로 반환했다. 거동은 옳다(전량 확보 전에는 만들지 않는다) —
497
+ * 문제는 **조용한 것**이었다. 실측: Rosarito MES 는 투입 18줄 중 6줄이 한 번도 입고되지 않아
498
+ * 오더 다섯이 영구히 대기했고, 화면에는 「running」이라 적혔다. 어느 자리도 이유를 말하지 않았다.
499
+ *
500
+ * 첫 줄에서 멈추지 않고 **모자란 줄을 다 센다.** 하나만 알려 주면 그것을 채운 뒤 다음 줄에서
501
+ * 또 막히고, 사람은 같은 진단을 여섯 번 반복한다.
502
+ */
503
+ short.push({ material: line.material, need: line.qty, have: chosen.length });
504
+ continue;
505
+ }
451
506
  picks.push(...chosen);
452
507
  }
508
+ if (short.length) {
509
+ /* 확보는 하지 않는다 — 사실만 남기고 다음 틱에 다시 본다(값은 매 틱 새로 쓰인다). */
510
+ o.shortage = short;
511
+ this.emitOrder(o);
512
+ return;
513
+ }
514
+ /* 채워졌으면 표시를 지운다 — 남겨 두면 화면이 이미 해결된 것을 계속 말한다. */
515
+ if (o.shortage)
516
+ delete o.shortage;
453
517
  for (const epc of picks)
454
518
  o.allocated.push(epc);
455
519
  this.reserve(picks, MES_BIZSTEP.producing);
@@ -125,6 +125,7 @@ __export(index_exports, {
125
125
  isEnergyRecord: () => isEnergyRecord,
126
126
  isEquipmentLevel: () => isEquipmentLevel,
127
127
  isOperationalRecord: () => isOperationalRecord,
128
+ isTransformationRecord: () => isTransformationRecord,
128
129
  isoDurationHours: () => isoDurationHours,
129
130
  itemKeyOf: () => itemKeyOf,
130
131
  levelOfLocationType: () => levelOfLocationType,
@@ -306,14 +307,14 @@ function classIndex(defs) {
306
307
  }
307
308
  var EMPTY_CLASS_INDEX = /* @__PURE__ */ new Map();
308
309
  function classClosure(directIds, defs, at) {
309
- const byId = classIndex(defs);
310
+ const byId2 = classIndex(defs);
310
311
  const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
311
312
  const out = /* @__PURE__ */ new Set();
312
313
  const stack = [...directIds ?? []];
313
314
  while (stack.length) {
314
315
  const id = stack.pop();
315
316
  if (out.has(id)) continue;
316
- const def = byId.get(id);
317
+ const def = byId2.get(id);
317
318
  if (!inWindow(def)) continue;
318
319
  out.add(id);
319
320
  for (const b of def?.baseIds ?? []) if (!out.has(b)) stack.push(b);
@@ -2121,11 +2122,11 @@ var EMS_TYPES = [
2121
2122
  }
2122
2123
  ];
2123
2124
  function electricalUpstreamOf(locations, id) {
2124
- const byId = new Map((locations ?? []).filter((l) => l?.id).map((l) => [String(l.id), l]));
2125
- const self = byId.get(String(id));
2125
+ const byId2 = new Map((locations ?? []).filter((l) => l?.id).map((l) => [String(l.id), l]));
2126
+ const self = byId2.get(String(id));
2126
2127
  if (!self) return void 0;
2127
2128
  if (self.upstreamId) return String(self.upstreamId);
2128
- const parent = self.parentId ? byId.get(String(self.parentId)) : void 0;
2129
+ const parent = self.parentId ? byId2.get(String(self.parentId)) : void 0;
2129
2130
  if (!parent) return void 0;
2130
2131
  return isElectricalLocationType(String(parent.type ?? "")) ? String(parent.id) : void 0;
2131
2132
  }
@@ -2670,30 +2671,47 @@ function capabilitiesForType(system, typeKey) {
2670
2671
  }
2671
2672
 
2672
2673
  // src/allocation-policy.ts
2674
+ var byId = (a, b) => a < b ? -1 : a > b ? 1 : 0;
2673
2675
  function freeBinsFirstFit(slots) {
2674
- const sorted = [...slots].sort((a, b) => a.id.localeCompare(b.id));
2675
- for (const b of sorted) if (b.occupancy + b.reserved < b.capacity) return b.id;
2676
- return null;
2676
+ let best;
2677
+ for (const b of slots) {
2678
+ if (b.occupancy + b.reserved >= b.capacity) continue;
2679
+ if (!best || byId(b.id, best.id) < 0) best = b;
2680
+ }
2681
+ return best?.id ?? null;
2677
2682
  }
2678
- function sortByEpc(available) {
2679
- return [...available].sort((a, b) => a.epc.localeCompare(b.epc));
2683
+ function smallestByEpc(available, k) {
2684
+ if (k <= 0) return { picked: [], total: 0 };
2685
+ const out = [];
2686
+ let total = 0;
2687
+ for (const s of available) {
2688
+ total++;
2689
+ if (out.length === k && byId(s.epc, out[out.length - 1].epc) >= 0) continue;
2690
+ let i = out.length;
2691
+ while (i > 0 && byId(out[i - 1].epc, s.epc) > 0) i--;
2692
+ out.splice(i, 0, s);
2693
+ if (out.length > k) out.pop();
2694
+ }
2695
+ return { picked: out, total };
2680
2696
  }
2681
2697
  var firstFitPolicy = {
2682
2698
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
2683
2699
  selectStock: ({ qty, available }) => {
2684
- if (available.length < qty) return [];
2685
- return sortByEpc(available).slice(0, qty).map((s) => s.epc);
2700
+ const { picked, total } = smallestByEpc(available, qty);
2701
+ if (total < qty) return [];
2702
+ return picked.map((s) => s.epc);
2686
2703
  }
2687
2704
  };
2688
2705
  var partialFitPolicy = {
2689
2706
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
2690
- selectStock: ({ qty, available }) => sortByEpc(available).slice(0, qty).map((s) => s.epc)
2707
+ selectStock: ({ qty, available }) => smallestByEpc(available, qty).picked.map((s) => s.epc)
2691
2708
  };
2692
2709
  var fefoPolicy = {
2693
2710
  selectPlacement: ({ slots }) => freeBinsFirstFit(slots),
2694
2711
  selectStock: ({ qty, available }) => {
2695
- if (available.length < qty) return [];
2696
- const byExpiry = [...available].sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || a.epc.localeCompare(b.epc));
2712
+ const all = [...available];
2713
+ if (all.length < qty) return [];
2714
+ const byExpiry = all.sort((a, b) => (a.expiry ?? Infinity) - (b.expiry ?? Infinity) || byId(a.epc, b.epc));
2697
2715
  return byExpiry.slice(0, qty).map((s) => s.epc);
2698
2716
  }
2699
2717
  };
@@ -2807,6 +2825,13 @@ function isAggregationRecord(record) {
2807
2825
  const r = record;
2808
2826
  return typeof r.parentID === "string" && r.parentID.trim().length > 0 && r.epc === void 0;
2809
2827
  }
2828
+ function isTransformationRecord(record) {
2829
+ if (!record || typeof record !== "object") return false;
2830
+ const r = record;
2831
+ if (r.epc !== void 0 || r.parentID !== void 0) return false;
2832
+ const has = (k) => Array.isArray(r[k]) && r[k].length > 0;
2833
+ return has("inputEPCList") || has("inputQuantityList") || has("outputEPCList") || has("outputQuantityList");
2834
+ }
2810
2835
  function get(obj, path) {
2811
2836
  return path.split(".").reduce((o, k) => o == null ? o : o[k], obj);
2812
2837
  }
@@ -2843,19 +2868,49 @@ function resolveQuantityList(v, record, field, errors) {
2843
2868
  }
2844
2869
  function mapRecordChecked(record, mapping, eventTime) {
2845
2870
  const errors = [];
2846
- const action = resolve(mapping.action, record, "action", errors) ?? "";
2847
2871
  const bizStep = resolve(mapping.bizStep, record, "bizStep", errors) ?? "";
2848
2872
  const disposition = resolve(mapping.disposition, record, "disposition", errors);
2849
2873
  const readPoint = resolve(mapping.readPoint, record, "readPoint", errors);
2850
2874
  const bizLocation = resolve(mapping.bizLocation, record, "bizLocation", errors);
2875
+ if (mapping.type === "TransformationEvent") {
2876
+ const inputEPCList = resolveList(mapping.inputEPCList, record, "inputEPCList", errors);
2877
+ const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, "inputQuantityList", errors);
2878
+ const outputEPCList = resolveList(mapping.outputEPCList, record, "outputEPCList", errors);
2879
+ const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, "outputQuantityList", errors);
2880
+ const transformationID = resolve(mapping.transformationID, record, "transformationID", errors);
2881
+ if (!inputEPCList.length && !inputQuantityList.length) {
2882
+ 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)");
2883
+ }
2884
+ if (!outputEPCList.length && !outputQuantityList.length) {
2885
+ 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)");
2886
+ }
2887
+ return {
2888
+ event: transformationEvent({
2889
+ eventTime,
2890
+ bizStep,
2891
+ disposition,
2892
+ /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
2893
+ 개체로 말한 쪽과 수량으로 말한 쪽 중 하나만 쓰는 것이 정상이다. */
2894
+ ...inputEPCList.length ? { inputEPCList } : {},
2895
+ ...inputQuantityList.length ? { inputQuantityList } : {},
2896
+ ...outputEPCList.length ? { outputEPCList } : {},
2897
+ ...outputQuantityList.length ? { outputQuantityList } : {},
2898
+ ...transformationID ? { transformationID } : {},
2899
+ readPoint,
2900
+ bizLocation
2901
+ }),
2902
+ errors
2903
+ };
2904
+ }
2851
2905
  if (mapping.type === "AggregationEvent") {
2906
+ const action2 = resolve(mapping.action, record, "action", errors) ?? "";
2852
2907
  const parentID = resolve(mapping.parentID, record, "parentID", errors) ?? "";
2853
2908
  const childEPCs = resolveList(mapping.childEPCs, record, "childEPCs", errors);
2854
2909
  const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, "childQuantityList", errors);
2855
2910
  return {
2856
2911
  event: aggregationEvent({
2857
2912
  eventTime,
2858
- action,
2913
+ action: action2,
2859
2914
  bizStep,
2860
2915
  disposition,
2861
2916
  parentID,
@@ -2868,6 +2923,7 @@ function mapRecordChecked(record, mapping, eventTime) {
2868
2923
  errors
2869
2924
  };
2870
2925
  }
2926
+ const action = resolve(mapping.action, record, "action", errors) ?? "";
2871
2927
  const epc = resolve(mapping.epc, record, "epc", errors);
2872
2928
  const quantityList = resolveQuantityList(mapping.quantityList, record, "quantityList", errors);
2873
2929
  if (!epc && !quantityList.length) {
@@ -3393,6 +3449,11 @@ var ItemStore = class _ItemStore {
3393
3449
  for (const [k, it] of this.map) out.set(k, structuredClone(it));
3394
3450
  return out;
3395
3451
  }
3452
+ /*
3453
+ * 지연 순회(`*iterAt`)를 만들어 배열을 없애 보았으나 **더 느렸다** — 물품 16,000 규모에서 틱 합계가
3454
+ * 1562ms 에서 2723ms 로 늘었다. 항목마다 생성기 규약을 지나는 비용이 배열 하나를 만드는 비용보다
3455
+ * 크다. 측정이 그렇게 답했으므로 배열을 유지한다.
3456
+ */
3396
3457
  /** 그 자리에 있는 물품들 — 색인이 답한다(전체 순회가 아니다). */
3397
3458
  at(location) {
3398
3459
  const keys = this.byLocation.get(location);
@@ -4250,6 +4311,8 @@ var FlowEngine = class {
4250
4311
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
4251
4312
  /* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
4252
4313
  ...o.seedIncomplete ? { seedIncomplete: true } : {},
4314
+ /* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
4315
+ ...o.shortage?.length ? { shortage: o.shortage.map((x) => ({ ...x })) } : {},
4253
4316
  ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {},
4254
4317
  ...o.priority !== void 0 ? { priority: o.priority } : {},
4255
4318
  ...o.startTime ? { startTime: o.startTime } : {},
@@ -6584,6 +6647,42 @@ var MesKernel = class extends FlowEngine {
6584
6647
  * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
6585
6648
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
6586
6649
  */
6650
+ /**
6651
+ * **왜 대기하는지 화면에 말한다** — 자재가 모자라 확보가 미뤄진 오더.
6652
+ *
6653
+ * ── 왜 필요한가 (2026-08-21 실측) ─────────────────────────────────────────
6654
+ * 확보는 전량 아니면 대기다(부분 투입을 하면 재고가 거짓이 된다). 그 거동은 옳다. 그런데 조용해서,
6655
+ * 투입 18줄 중 6줄이 한 번도 입고되지 않은 공장에서 **오더 다섯이 영구히 대기하는 동안 화면에는
6656
+ * 「running」이라 적혔다.** 아무 자리도 이유를 말하지 않았다.
6657
+ *
6658
+ * 판단만 여기서 한다 — 확인(ack)·처음 성립한 시각·사라진 조건 정리는 `computeAttentions` 가
6659
+ * 한 곳에서 맡는다(확장점 규약).
6660
+ *
6661
+ * 사람이 읽는 문장은 만들지 않는다. `kind` 와 언어 중립 `params` 만 내고 문장은 표현계층이 만든다.
6662
+ */
6663
+ collectAttentions() {
6664
+ const out = super.collectAttentions();
6665
+ for (const o of this.orders.values()) {
6666
+ const short = o.shortage;
6667
+ if (!short?.length) continue;
6668
+ out.push({
6669
+ id: `material-short:${o.id}`,
6670
+ kind: "material-short",
6671
+ /* 한 줄이 모자란 것과 여러 줄이 모자란 것은 할 일이 다르다 — 여럿이면 자재 공급 자체가 문제다. */
6672
+ severity: short.length > 1 ? "high" : "medium",
6673
+ anchor: { orderId: o.id },
6674
+ params: {
6675
+ /* 모자란 줄 수와 투입 줄 수 — 「6/18」이 「하나 모자람」과 다른 상황임을 한눈에 말한다. */
6676
+ shortLines: short.length,
6677
+ /* 자재 키를 그대로 낸다. 커널이 사람이 읽는 이름을 만들지 않는다 — 이름은 모델이 갖는다. */
6678
+ materials: short.map((x) => x.material).join(", "),
6679
+ need: short.reduce((n, x) => n + x.need, 0),
6680
+ have: short.reduce((n, x) => n + x.have, 0)
6681
+ }
6682
+ });
6683
+ }
6684
+ return out;
6685
+ }
6587
6686
  handleCommand(cmd) {
6588
6687
  if (cmd.type === MES_CMD.changeover) {
6589
6688
  const a = cmd.args;
@@ -6826,6 +6925,7 @@ var MesKernel = class extends FlowEngine {
6826
6925
  }
6827
6926
  const rc = this.recipeDef(o);
6828
6927
  const picks = [];
6928
+ const short = [];
6829
6929
  const locsOfType = /* @__PURE__ */ new Map();
6830
6930
  for (const n of this.locations.values()) {
6831
6931
  const bin = locsOfType.get(n.type);
@@ -6842,9 +6942,18 @@ var MesKernel = class extends FlowEngine {
6842
6942
  }
6843
6943
  }
6844
6944
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
6845
- if (chosen.length < line.qty) return;
6945
+ if (chosen.length < line.qty) {
6946
+ short.push({ material: line.material, need: line.qty, have: chosen.length });
6947
+ continue;
6948
+ }
6846
6949
  picks.push(...chosen);
6847
6950
  }
6951
+ if (short.length) {
6952
+ o.shortage = short;
6953
+ this.emitOrder(o);
6954
+ return;
6955
+ }
6956
+ if (o.shortage) delete o.shortage;
6848
6957
  for (const epc of picks) o.allocated.push(epc);
6849
6958
  this.reserve(picks, MES_BIZSTEP.producing);
6850
6959
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
@@ -8037,7 +8146,7 @@ function ingestOperationalRecords(records, opts) {
8037
8146
  // src/energy-attribution.ts
8038
8147
  var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
8039
8148
  function attributeEnergy(opts) {
8040
- const byId = new Map(opts.consumers.map((c) => [c.id, c]));
8149
+ const byId2 = new Map(opts.consumers.map((c) => [c.id, c]));
8041
8150
  const dedicated = /* @__PURE__ */ new Map();
8042
8151
  for (const c of opts.consumers) if (c.meterId) dedicated.set(c.meterId, c);
8043
8152
  const shares = [];
@@ -8054,7 +8163,7 @@ function attributeEnergy(opts) {
8054
8163
  }
8055
8164
  if (pool.overhead) {
8056
8165
  const alloc = opts.overheadAllocation;
8057
- const targets = (alloc?.processConsumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
8166
+ const targets = (alloc?.processConsumerIds ?? []).map((id) => byId2.get(id)).filter((c) => !!c);
8058
8167
  const wOf = (c) => alloc?.weightKind === "equal" ? 1 : Number.isFinite(Number(c.weight)) && Number(c.weight) > 0 ? Number(c.weight) : 0;
8059
8168
  const sharing2 = alloc?.weightKind === "equal" ? targets : targets.filter((c) => wOf(c) > 0);
8060
8169
  const ws = sharing2.map(wOf);
@@ -8080,7 +8189,7 @@ function attributeEnergy(opts) {
8080
8189
  });
8081
8190
  continue;
8082
8191
  }
8083
- const covered = (pool.consumerIds ?? []).map((id) => byId.get(id)).filter((c) => !!c);
8192
+ const covered = (pool.consumerIds ?? []).map((id) => byId2.get(id)).filter((c) => !!c);
8084
8193
  if (!covered.length) {
8085
8194
  unattributed.push({ poolMeterId: pool.meterId, kWh, reason: pool.remainder ? "not-submetered" : "no-consumers" });
8086
8195
  continue;
@@ -8367,6 +8476,7 @@ function retiredVocabularyIn(line) {
8367
8476
  isEnergyRecord,
8368
8477
  isEquipmentLevel,
8369
8478
  isOperationalRecord,
8479
+ isTransformationRecord,
8370
8480
  isoDurationHours,
8371
8481
  itemKeyOf,
8372
8482
  levelOfLocationType,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.41",
3
+ "version": "0.7.43",
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": {