@operato/twin-kernel 0.3.0 → 0.4.1

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/dist/contract.js CHANGED
@@ -127,6 +127,30 @@ export function hierarchyOf(s) {
127
127
  }
128
128
  };
129
129
  }
130
+ /**
131
+ * 이 시각에 유효 기간 밖인가 — **한 규칙**으로 개체·등급·설비↔자산 매핑을 모두 판정한다.
132
+ *
133
+ * **표준은 날짜만 정하고 "밖이면 어떻게 되는가" 는 정하지 않는다.** 그 판단은 우리 것이므로 여기 밝힌다:
134
+ * 기간 밖이면 **그 자원은 그 시각의 모델에 참여하지 않는다**(배정되지 않고, 가용 분모에 들지 않는다).
135
+ * 지우지는 않는다 — 이유를 달아 남긴다. 조용히 사라지면 "왜 없어졌나" 를 아무도 답할 수 없다.
136
+ *
137
+ * `at` 를 주지 않으면 **판단하지 않는다**(`undefined`). 모르는 시각으로 폐기를 단정하면, 시각을 안 넘긴
138
+ * 소비처 전부가 자원을 잃는다. 파싱 불가한 시각도 같다 — 짐작해 고치지 않는다.
139
+ */
140
+ export function effectivityAt(p, at) {
141
+ if (!p || !at)
142
+ return undefined;
143
+ const atMs = Date.parse(at);
144
+ if (!Number.isFinite(atMs))
145
+ return undefined;
146
+ const from = p.effectiveStart ? Date.parse(p.effectiveStart) : NaN;
147
+ if (Number.isFinite(from) && atMs < from)
148
+ return 'not-yet';
149
+ const to = p.effectiveEnd ? Date.parse(p.effectiveEnd) : NaN;
150
+ if (Number.isFinite(to) && atMs > to)
151
+ return 'expired';
152
+ return undefined;
153
+ }
130
154
  /**
131
155
  * 등급 소속을 **상속을 타고 닫는다** — "이 개체가 이 등급으로 통하는가".
132
156
  *
@@ -137,18 +161,7 @@ export function hierarchyOf(s) {
137
161
  */
138
162
  export function classClosure(directIds, defs, at) {
139
163
  const byId = new Map((defs ?? []).map(d => [d.id, d]));
140
- const atMs = at ? Date.parse(at) : NaN;
141
- const inWindow = (d) => {
142
- if (!d || !Number.isFinite(atMs))
143
- return true;
144
- const from = d.effectiveStart ? Date.parse(d.effectiveStart) : NaN;
145
- const to = d.effectiveEnd ? Date.parse(d.effectiveEnd) : NaN;
146
- if (Number.isFinite(from) && atMs < from)
147
- return false;
148
- if (Number.isFinite(to) && atMs > to)
149
- return false;
150
- return true;
151
- };
164
+ const inWindow = (d) => !d || effectivityAt(d, at) === undefined;
152
165
  const out = new Set();
153
166
  const stack = [...(directIds ?? [])];
154
167
  while (stack.length) {
@@ -165,6 +178,313 @@ export function classClosure(directIds, defs, at) {
165
178
  }
166
179
  return out;
167
180
  }
181
+ /**
182
+ * 우선순위 — **ISA-95 `Priority`**(`JobOrderType`·`OperationsRequestType`, 타입은 `PriorityType` =
183
+ * `NumericType` 제한). 즉 표준은 **숫자라는 것만 정하고 방향은 정하지 않는다.**
184
+ *
185
+ * **그래서 방향은 우리가 정한다: 작은 값이 급하다(1 = 가장 급함).** 흔한 관행이고, 무엇보다
186
+ * 한쪽으로 못 박아 두지 않으면 소비처마다 반대로 읽는다. 우리가 정한 규약이라는 사실을 여기 밝힌다.
187
+ *
188
+ * 미지정은 **0 이 아니라 "우선순위 없음"** 이다 — 선언한 것들 뒤에 선다(0 으로 채우면 미지정이
189
+ * 가장 급한 것이 된다).
190
+ */
191
+ export const PRIORITY_UNSET = Number.POSITIVE_INFINITY;
192
+ /** 정렬 키 — 미지정을 맨 뒤로 보낸다. 같은 우선순위는 **입력 순서**를 지킨다(결정성). */
193
+ export function priorityRank(p) {
194
+ return typeof p === 'number' && Number.isFinite(p) ? p : PRIORITY_UNSET;
195
+ }
196
+ /**
197
+ * 납기 대비 상태 — **파생**이다(저장하지 않는다). `locationStatusOf` 와 같은 규율.
198
+ *
199
+ * 예정 창(`endTime`)이 없으면 `undefined` — **"늦지 않았다" 가 아니라 "판단할 수 없다"** 다.
200
+ * 납기가 없는데 정시라고 말하면 그건 없는 사실을 만드는 것이다.
201
+ */
202
+ export function dueStatusOf(x, nowIso) {
203
+ if (!x.endTime || !nowIso)
204
+ return undefined;
205
+ const due = Date.parse(x.endTime);
206
+ const now = Date.parse(nowIso);
207
+ if (!Number.isFinite(due) || !Number.isFinite(now))
208
+ return undefined;
209
+ return now > due ? 'late' : 'on-time';
210
+ }
211
+ /**
212
+ * 선언된 단위로 수량을 읽는다 — **환산하지 않는다.**
213
+ *
214
+ * 없으면 `undefined`: "0" 이 아니고 "계산한 값" 도 아니다. 환산 계수를 모르는데 값을 만들면
215
+ * 그 뒤 모든 계산이 거짓 위에 선다.
216
+ */
217
+ /**
218
+ * 로트의 부분 식별자를 **한 규칙으로** 만든다 — 표준 `MaterialSubLot.ID`.
219
+ *
220
+ * 시뮬(생산)과 미러(관측)가 각자 만들면 같은 부분이 다른 이름을 갖고, 두 구동이 갈라진다
221
+ * (적합성 하네스가 실제로 잡았다). 부분을 가르는 것은 **자리**다.
222
+ */
223
+ /**
224
+ * 물품을 구별하는 키 — 직렬 물품은 `epc`, 로트의 부분은 `subLotId`(표준 `MaterialSubLot.ID`).
225
+ *
226
+ * **한 곳에서 정한다.** 소비처마다 `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 접히고,
227
+ * 그 순간 재고가 조용히 줄어든다(실제로 그랬다 — §ItemState.subLotId).
228
+ */
229
+ export function subLotIdOf(classUri, location) {
230
+ return `${classUri}@${location}`;
231
+ }
232
+ export function itemKeyOf(item) {
233
+ return item.subLotId ?? item.epc;
234
+ }
235
+ export function quantityIn(item, uom, definitions) {
236
+ const hit = item.quantities?.find(q => (q.uom ?? undefined) === (uom ?? undefined));
237
+ if (hit)
238
+ return hit.value;
239
+ /* 주 수량이 그 단위면 그것을 답한다 — `quantities` 를 안 실은 물품도 답이 나오게. */
240
+ if ((item.uom ?? undefined) === (uom ?? undefined))
241
+ return item.qty;
242
+ /* **선언된 계수가 있으면** 환산한다 — 여전히 지어내지는 않는다(§conversionFactorOf). */
243
+ return convertQuantity(item, uom, definitions);
244
+ }
245
+ /**
246
+ * **우리가 정한 품목 속성 이름** — 표준은 자리만 정하고 이름을 정하지 않는다.
247
+ *
248
+ * `perBaseUnit`: 값은 **기준 단위 하나당 그 단위의 양**이고, 단위는 속성의 `uom` 이 말한다.
249
+ * 예) 한 개(EA)가 2.5 kg 이면 `{ id: 'perBaseUnit', value: 2.5, uom: 'KGM' }`.
250
+ *
251
+ * **왜 이 모양인가**: 임의의 단위쌍 환산표(CS↔KGM↔EA…)는 품목마다 다르고 연쇄가 필요해 금세
252
+ * 커진다. 현장에서 실제로 필요한 것은 **"세는 단위에서 다른 단위로"** 이므로, 기준 단위를 축으로
253
+ * 두면 선언 한 줄로 끝난다. 기준 단위가 아닌 수량에서 출발하는 환산은 **하지 않는다**(§convertQuantity).
254
+ */
255
+ export const MATERIAL_PROPERTY = {
256
+ /** 기준 단위 하나당 이 단위의 양. `uom` 이 대상 단위. */
257
+ perBaseUnit: 'perBaseUnit'
258
+ };
259
+ /**
260
+ * 이 품목에서 `uom` 으로 가는 계수 — **선언된 것만.** 없으면 `undefined`(추정하지 않는다).
261
+ */
262
+ export function conversionFactorOf(def, uom) {
263
+ if (!def || !uom)
264
+ return undefined;
265
+ for (const p of def.properties ?? []) {
266
+ if (p.id !== MATERIAL_PROPERTY.perBaseUnit || p.uom !== uom)
267
+ continue;
268
+ const n = typeof p.value === 'number' ? p.value : Number(p.value);
269
+ if (Number.isFinite(n) && n > 0)
270
+ return n;
271
+ }
272
+ return undefined;
273
+ }
274
+ /**
275
+ * 선언된 계수로 환산한다 — **기준 수량에서만 출발한다.**
276
+ *
277
+ * 기준 수량(`qty`)은 품목을 세는 단위다(EPCIS 는 단위 미지정이면 개수로 읽는다). 그것이 아닌 수량에서
278
+ * 출발하려면 그 단위→기준 단위 역계수가 또 필요한데, 그것을 짐작하면 오차가 곱으로 쌓인다.
279
+ * 그래서 **기준 수량이 없거나 이미 다른 단위로 선언돼 있으면 환산하지 않는다.**
280
+ */
281
+ function convertQuantity(item, uom, definitions) {
282
+ if (!definitions || !uom)
283
+ return undefined;
284
+ if (typeof item.qty !== 'number' || !Number.isFinite(item.qty))
285
+ return undefined;
286
+ if (item.uom)
287
+ return undefined; // 기준 단위가 아닌 수량 — 역계수를 지어내지 않는다
288
+ const def = definitions.get(item.definitionId ?? item.gtin ?? '');
289
+ const factor = conversionFactorOf(def, uom);
290
+ return factor === undefined ? undefined : item.qty * factor;
291
+ }
292
+ /** `HH:MM` → 자정 이후 분. 형식이 아니면 `undefined`(짐작해 고치지 않는다). */
293
+ function minutesOfDay(hhmm) {
294
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm ?? '');
295
+ if (!m)
296
+ return undefined;
297
+ const h = Number(m[1]);
298
+ const mi = Number(m[2]);
299
+ if (h > 23 || mi > 59)
300
+ return undefined;
301
+ return h * 60 + mi;
302
+ }
303
+ /**
304
+ * 이 시각이 근무 시간인가 — **비근무가 근무를 이긴다.**
305
+ *
306
+ * 표준은 구간과 종류를 정하지만 **겹칠 때 무엇이 이기는지는 정하지 않는다.** 그래서 우리가 정했다:
307
+ * 휴일·정비(비근무)는 교대(근무) 위에 얹힌다. 반대로 두면 휴일 선언이 무의미해진다.
308
+ *
309
+ * 캘린더가 비어 있으면 `true` — **24시간 가용이 기존 거동**이고, 선언하지 않은 것을 쉬는 것으로
310
+ * 읽으면 아무 일도 일어나지 않는 트윈이 된다.
311
+ */
312
+ /** 절대 구간을 쓰는 항목인가 — 되풀이(`HH:MM`)와 판정 방법이 다르다. */
313
+ const isAbsolute = (e) => !!(e.startDateTime || e.finishDateTime);
314
+ /** **날짜를 알아야** 판정할 수 있는 항목인가 — 절대 구간이거나 요일이 걸린 것. */
315
+ const needsDate = (e) => isAbsolute(e) || !!e.daysOfWeek?.length;
316
+ /**
317
+ * 이 구간이 **선언된 요일에 시작한 것**인가 — 자정을 넘는 교대를 바르게 세기 위한 규칙.
318
+ *
319
+ * 22:00→06:00 교대에 월~금을 주면 뜻은 "월~금에 **시작**한다" 이다. 그래서 토요일 새벽 2시는
320
+ * **금요일에 시작한 교대**의 일부이고(포함), 월요일 새벽 2시는 일요일 밤에 서지 않은 교대이므로
321
+ * 제외된다. 순간의 요일로 세면 이 둘이 정확히 반대로 틀린다.
322
+ */
323
+ function onDeclaredDay(e, minuteOfDay, weekday) {
324
+ if (!e.daysOfWeek?.length)
325
+ return true;
326
+ const from = minutesOfDay(e.fromTime);
327
+ const to = minutesOfDay(e.toTime);
328
+ const crosses = from !== undefined && to !== undefined && from > to;
329
+ /* 자정을 넘는 구간의 **뒷조각**(자정~종료)은 어제 시작한 것이다. */
330
+ const startedDay = crosses && minuteOfDay < to ? (weekday + 6) % 7 : weekday;
331
+ return e.daysOfWeek.includes(startedDay);
332
+ }
333
+ /** 선언된 기준의 요일(0 일요일 … 6 토요일). */
334
+ export function weekdayAt(ms, utcOffsetMinutes) {
335
+ return new Date(ms + (utcOffsetMinutes ?? 0) * 60_000).getUTCDay();
336
+ }
337
+ /**
338
+ * 절대 구간이 그 시각을 덮는가 — 한쪽만 있으면 그쪽만 본다(열린 구간).
339
+ * 깨진 시각으로는 판정하지 않는다(짐작해 고치지 않는다).
340
+ */
341
+ function coversInstant(e, atMs) {
342
+ const from = e.startDateTime ? Date.parse(e.startDateTime) : NaN;
343
+ const to = e.finishDateTime ? Date.parse(e.finishDateTime) : NaN;
344
+ if (!Number.isFinite(from) && !Number.isFinite(to))
345
+ return false;
346
+ if (Number.isFinite(from) && atMs < from)
347
+ return false;
348
+ if (Number.isFinite(to) && atMs >= to)
349
+ return false;
350
+ return true;
351
+ }
352
+ /** 이 구간이 그 분을 덮는가 — `inWorkCalendar` 와 `activeShiftOf` 가 **같은 규칙**을 쓴다. */
353
+ function coversMinute(e, minuteOfDay) {
354
+ const from = minutesOfDay(e.fromTime);
355
+ const to = minutesOfDay(e.toTime);
356
+ if (from === undefined || to === undefined)
357
+ return false; // 깨진 선언으로 판정하지 않는다
358
+ /* 자정을 넘는 구간(22:00→06:00)은 두 조각으로 읽는다 — 날짜 오프셋이 그것을 명시할 때도 같다. */
359
+ return from <= to ? minuteOfDay >= from && minuteOfDay < to : minuteOfDay >= from || minuteOfDay < to;
360
+ }
361
+ export function inWorkCalendar(entries, minuteOfDay) {
362
+ /* 되풀이만 본다 — 절대 구간은 분(minute)만으로 판정할 수 없다(어느 날인지 모른다).
363
+ 시각(ms)을 가진 소비처는 `inWorkCalendarAt` 을 쓴다. */
364
+ return judgeCalendar(entries, e => coversMinute(e, minuteOfDay), false);
365
+ }
366
+ /**
367
+ * 이 **시각**이 근무 시간인가 — 되풀이(`HH:MM`)와 **한 번뿐인 구간**(휴일·정비창)을 함께 본다.
368
+ *
369
+ * 절대 구간은 시각 기준이 필요 없다(ISO 시각에 이미 들어 있다). 되풀이는 **선언된 기준**으로 읽는다.
370
+ * 겹치면 규칙은 하나다 — **비근무가 근무를 이긴다**(휴일이 교대 위에 얹힌다).
371
+ */
372
+ export function inWorkCalendarAt(entries, atMs, utcOffsetMinutes) {
373
+ const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
374
+ const day = weekdayAt(atMs, utcOffsetMinutes);
375
+ const covers = (e) => {
376
+ if (isAbsolute(e))
377
+ return coversInstant(e, atMs);
378
+ if (!coversMinute(e, minute))
379
+ return false;
380
+ /* 요일이 걸려 있으면 **그 구간이 시작한 날**로 센다(자정을 넘는 교대). */
381
+ return onDeclaredDay(e, minute, day);
382
+ };
383
+ return judgeCalendar(entries, covers, true);
384
+ }
385
+ /**
386
+ * 근무/비근무 판정의 **한 규칙** — 덮는 방법만 다르고 우선순위는 같다.
387
+ *
388
+ * `withAbsolute` 가 false 면 절대 구간 항목은 **없는 셈 친다**(판정할 재료가 없다). 그것을 근무로도
389
+ * 비근무로도 세면 둘 다 거짓이 된다 — 모르면 판단하지 않는다.
390
+ */
391
+ function judgeCalendar(entries, covers, withAbsolute) {
392
+ if (!entries?.length)
393
+ return true;
394
+ const usable = withAbsolute ? entries : entries.filter(e => !needsDate(e));
395
+ if (!usable.length)
396
+ return true;
397
+ const working = usable.filter(e => (e.entryType ?? 'working') === 'working');
398
+ const off = usable.filter(e => e.entryType === 'non-working');
399
+ if (off.some(covers))
400
+ return false; // 비근무가 이긴다
401
+ if (!working.length)
402
+ return true; // 비근무만 선언했다면 그 밖은 근무다
403
+ return working.some(covers);
404
+ }
405
+ /**
406
+ * 지금 어느 교대인가 — **근무 구간의 이름**(표준 `WorkCalendarEntryType.ID`).
407
+ *
408
+ * 비근무가 이기는 규칙은 여기서도 같다: 휴게·정비 중이면 **어느 교대도 아니다**(`undefined`).
409
+ * 이름 없는 구간은 이름을 지어내지 않는다 — 교대를 나눠 놓지 않은 현장에서 `'1'` 같은 값을
410
+ * 만들어 붙이면, 그 뒤 모든 교대별 집계가 없는 구분 위에 선다.
411
+ *
412
+ * 겹치는 근무 구간이 여럿이면 **먼저 선언된 것**을 답한다(선언 순서가 현장의 우선순위다).
413
+ */
414
+ export function activeShiftOf(entries, minuteOfDay) {
415
+ if (!entries?.length)
416
+ return undefined;
417
+ if (!inWorkCalendar(entries, minuteOfDay))
418
+ return undefined; // 비근무 중 — 어느 교대도 아니다
419
+ for (const e of entries) {
420
+ if (needsDate(e) || (e.entryType ?? 'working') !== 'working' || !e.id)
421
+ continue;
422
+ if (coversMinute(e, minuteOfDay))
423
+ return e.id;
424
+ }
425
+ return undefined;
426
+ }
427
+ /**
428
+ * 이 **시각**에 어느 교대인가 — 휴일까지 반영한다.
429
+ *
430
+ * 휴일에 일어난 일은 **어느 교대에도 속하지 않는다**(그날 교대는 서지 않았다). 되풀이만 보는
431
+ * `activeShiftOf` 로는 그것을 알 수 없어, 휴일에 찍힌 기록이 평소 교대로 집계됐다.
432
+ */
433
+ export function activeShiftAt(entries, atMs, utcOffsetMinutes) {
434
+ if (!entries?.length)
435
+ return undefined;
436
+ if (!inWorkCalendarAt(entries, atMs, utcOffsetMinutes))
437
+ return undefined;
438
+ const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
439
+ const day = weekdayAt(atMs, utcOffsetMinutes);
440
+ for (const e of entries) {
441
+ if (isAbsolute(e) || (e.entryType ?? 'working') !== 'working' || !e.id)
442
+ continue;
443
+ if (!coversMinute(e, minute))
444
+ continue;
445
+ if (!onDeclaredDay(e, minute, day))
446
+ continue; // 그날 서지 않은 교대(시작한 날로 센다)
447
+ return e.id;
448
+ }
449
+ return undefined;
450
+ }
451
+ /**
452
+ * 절대 시각(ms) → **선언된 기준의** 하루 중 분(0..1439).
453
+ *
454
+ * `HH:MM` 만으로는 "어느 기준의 06시" 인지 알 수 없다. 예전에 이것을 UTC 로 읽어 Rosarito(UTC−7)의
455
+ * 06시 교대가 **7시간 틀렸다.** 선언이 없으면 UTC 이고, 그 기본값을 숨기지 않고 밝힌다.
456
+ */
457
+ export function minuteOfDayAt(ms, utcOffsetMinutes) {
458
+ const d = new Date(ms + (utcOffsetMinutes ?? 0) * 60_000);
459
+ return d.getUTCHours() * 60 + d.getUTCMinutes();
460
+ }
461
+ export function offCalendarReasonAt(r, ms, utcOffsetMinutes) {
462
+ if (!offCalendarAt(r, ms, utcOffsetMinutes))
463
+ return undefined;
464
+ const entries = r.workCalendar;
465
+ if (!entries?.length)
466
+ return 'off-hours'; // 옛 `window` 경로 — 구간 선언이 없다
467
+ const minute = minuteOfDayAt(ms, utcOffsetMinutes);
468
+ const day = weekdayAt(ms, utcOffsetMinutes);
469
+ for (const e of entries) {
470
+ if (e.entryType !== 'non-working')
471
+ continue;
472
+ const covered = isAbsolute(e) ? coversInstant(e, ms) : coversMinute(e, minute) && onDeclaredDay(e, minute, day);
473
+ if (covered)
474
+ return 'non-working';
475
+ }
476
+ return 'off-hours';
477
+ }
478
+ export function offCalendarAt(r, ms, utcOffsetMinutes) {
479
+ const minute = minuteOfDayAt(ms, utcOffsetMinutes);
480
+ if (r.workCalendar?.length)
481
+ return !inWorkCalendarAt(r.workCalendar, ms, utcOffsetMinutes);
482
+ const w = r.window;
483
+ if (!w)
484
+ return false;
485
+ const h = Math.floor(minute / 60);
486
+ return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
487
+ }
168
488
  // ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
169
489
  // EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·equipment·orders 의 운영 상태는
170
490
  // 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
@@ -221,8 +541,12 @@ export function readBoardLocations(def) {
221
541
  return d.locations ?? d.nodes ?? []; // vocabulary-guard: allow — 옛 키 흡수
222
542
  }
223
543
  export function readBoardEquipment(def) {
544
+ /* `movers` 는 개명 **이전의 이름**이다(movers → equipmentList → equipment). 저장된 보드에는 세 세대가
545
+ 섞여 있어(실측: 23개 중 13개가 `movers`) 하나라도 빠뜨리면 그 보드는 **설비가 0인 공장**으로 읽힌다 —
546
+ 오류 없이. 실제로 그랬다: 화면의 설비 수가 0이고, 용량 판정에 자원이 없고, 카탈로그 통합 테스트가
547
+ "완제품 0" 으로 떨어졌다. 셋 다 원인이 이 한 줄이었다. */
224
548
  const d = def; // vocabulary-guard: allow — 옛 키를 읽어야 하는 자리
225
- const list = d.equipment ?? d.equipmentList ?? []; // vocabulary-guard: allow — 옛 키 흡수
549
+ const list = d.equipment ?? d.equipmentList ?? d.movers ?? []; // vocabulary-guard: allow — 옛 키 흡수
226
550
  /* 소속 자리 키도 함께 정규화한다 — `homeNode → homeLocation` 개명 전 보드가 23개 있다. (vocabulary-guard: allow — 옛 키 정규화 설명)
227
551
  배열만 흡수하고 안쪽 키를 놓치면 설비는 나타나지만 **소속이 전부 비어** 롤업이 통째로 사라진다. */
228
552
  return list.map(e => normalizeHomeLocation(e));
@@ -5,6 +5,7 @@
5
5
  * 상태를 같은 시점에 비교해 **드리프트(모델과 현실의 이탈)** 를 탐지한다. 발산 = 이상/개입 신호.
6
6
  * (fork 로 예측 → 관측으로 실제 → 여기서 대조. execution-model.md 시뮬↔모니터링 커플링.)
7
7
  */
8
+ import { itemKeyOf } from "./contract.js";
8
9
  function diffBy(predicted, actual, idOf, valOf) {
9
10
  const p = new Map(predicted.map(e => [idOf(e), valOf(e)]));
10
11
  const a = new Map(actual.map(e => [idOf(e), valOf(e)]));
@@ -19,8 +20,10 @@ function diffBy(predicted, actual, idOf, valOf) {
19
20
  }
20
21
  /** predicted(fork forecast)와 actual(관측) 스냅샷을 대조. 같은 sim 시점에 호출하는 것이 의미 있음. */
21
22
  export function compareStates(predicted, actual) {
22
- const itemLocation = diffBy(predicted.items, actual.items, i => i.epc, i => i.location);
23
- const itemDisposition = diffBy(predicted.items, actual.items, i => i.epc, i => i.disposition);
23
+ /* 키는 **한 규칙**으로 잡는다(`itemKeyOf`) 같은 로트의 두 부분을 `epc` 접으면 발산 비교가
24
+ "같은 물품이 곳에 있다" 자기 오류로 착각한다. */
25
+ const itemLocation = diffBy(predicted.items, actual.items, itemKeyOf, i => i.location);
26
+ const itemDisposition = diffBy(predicted.items, actual.items, itemKeyOf, i => i.disposition);
24
27
  const locationOccupancy = diffBy(predicted.locations, actual.locations, n => n.id, n => n.occupancy);
25
28
  const orderStatus = diffBy(predicted.orders, actual.orders, o => o.id, o => o.status);
26
29
  const hasDrift = itemLocation.length > 0 || itemDisposition.length > 0 || locationOccupancy.length > 0 || orderStatus.length > 0;
@@ -139,6 +139,51 @@ export interface OperationDef {
139
139
  equipmentClass?: string;
140
140
  quantity: number;
141
141
  }[];
142
+ /**
143
+ * 필요·산출 자재 — **ISA-95 `OperationsSegment.MaterialSpecification`**(`OpMaterialSpecificationType`).
144
+ *
145
+ * ── 4대 자원 중 자재만 빠져 있었다 ────────────────────────────────────────
146
+ * 인원·설비·자산 셋은 공정이 "등급 + 수량" 으로 요구하는데 **자재만 그 자리가 없었다.** 그래서
147
+ * 트레일러 조립 공장을 모델링해도 *"차축 하나에 바퀴 둘"* 을 말할 방법이 없고, 부품이 없어서
148
+ * 라인이 서는 상황이 **예측에 아예 나타나지 않는다** — 자재는 현장에서 사람만큼 자주 부족하다.
149
+ *
150
+ * ── BOM 은 어디 있나 (1차 출처 확인) ─────────────────────────────────────
151
+ * `MaterialDefinition.AssemblyDefinition` 은 **재귀 구조**(무엇이 무엇으로 이루어지나)일 뿐
152
+ * **수량이 없다.** 수량은 공정 쪽 `MaterialSpecification` 이 든다(`Quantity` + `MaterialUse`).
153
+ * 즉 표준에서 **BOM 의 "몇 개" 는 공정의 사실**이다 — 같은 부품이라도 공정마다 소요가 다르다.
154
+ *
155
+ * `use` 는 표준 `MaterialUse` 를 소문자로 쓴다(우리 어휘 규약). 지금 커널이 소비하는 것은
156
+ * `consumed`(작업이 시작되려면 있어야 하고 시작 시 빠진다)뿐이고, 나머지는 **선언만 받아 둔다** —
157
+ * 자리가 없으면 사실이 들어오지 못한다.
158
+ */
159
+ materialSpecification?: OpMaterialSpecification[];
160
+ }
161
+ /**
162
+ * 공정 하나의 자재 명세 — **ISA-95 `OpMaterialSpecificationType`** 의 우리 부분집합.
163
+ *
164
+ * 1차 출처: `ID` · `MaterialClassID*` · `MaterialDefinitionID*` · `MaterialLotID*` · `MaterialSubLotID*` ·
165
+ * `Description*` · **`MaterialUse`** · `HierarchyScope` · `StorageLocation` · `SpatialDefinition` ·
166
+ * **`Quantity*`** · `AssemblySpecification*`(재귀) · `AssemblyType` · `AssemblyRelationship` ·
167
+ * `MaterialSpecificationProperty*` · `TestSpecificationID*`.
168
+ *
169
+ * 우리는 **요구를 표현하는 데 필요한 것**만 든다(인원·자산 명세와 같은 모양). 로트·하위로트 지목,
170
+ * 재귀 조립 명세, 저장 위치 한정은 아직 없다 — 필요해질 때 표준 이름 그대로 얹는다.
171
+ */
172
+ export interface OpMaterialSpecification {
173
+ /** 표준 `ID` — 명세를 가리키는 이름(선택). */
174
+ id?: string;
175
+ /** 품목 등급으로 요구 — 표준 `MaterialClassID`. */
176
+ materialClass?: string;
177
+ /** 특정 품목으로 요구 — 표준 `MaterialDefinitionID`. 등급과 함께 주면 품목이 좁은 쪽이다. */
178
+ materialDefinition?: string;
179
+ /**
180
+ * 이 공정에서의 쓰임 — 표준 `MaterialUse`.
181
+ * `consumed` 없어지며 들어간다 · `produced` 만들어져 나온다 · `consumable` 쓰이지만 제품에 남지 않는다.
182
+ */
183
+ use: 'consumed' | 'produced' | 'consumable';
184
+ /** 수량 — 표준 `Quantity`. 단위 미지정이면 개수(EPCIS 규약과 같다). */
185
+ quantity: number;
186
+ uom?: string;
142
187
  }
143
188
  /** 라우트(오퍼레이션 시퀀스) — ISA-95 ProcessSegment 연결. */
144
189
  export interface RouteDef {
package/dist/epcis.d.ts CHANGED
@@ -9,6 +9,18 @@ export declare const DISP: {
9
9
  readonly in_transit: "urn:epcglobal:cbv:disp:in_transit";
10
10
  readonly non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other";
11
11
  };
12
+ /**
13
+ * **자재 소비·산출의 CBV 단계** — 도메인 무관하게 코어가 쓴다.
14
+ *
15
+ * 업종별 단계(입고·피킹·출하…)는 각 프로파일이 갖지만, "자재가 들어갔다/나왔다" 는 셋 다 하는 일이라
16
+ * 코어에 있어야 한다. 프로파일 하나에 두면 다른 업종이 그것을 가져다 쓰면서 방언이 생긴다.
17
+ */
18
+ export declare const CBV_BIZSTEP: {
19
+ /** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
20
+ readonly consuming: "urn:epcglobal:cbv:bizstep:consuming";
21
+ /** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
22
+ readonly commissioning: "urn:epcglobal:cbv:bizstep:commissioning";
23
+ };
12
24
  export type EpcisEventType = 'ObjectEvent' | 'AggregationEvent' | 'TransactionEvent' | 'TransformationEvent';
13
25
  export type EpcisAction = 'ADD' | 'OBSERVE' | 'DELETE';
14
26
  /**
package/dist/epcis.js CHANGED
@@ -18,6 +18,18 @@ export const DISP = {
18
18
  in_transit: 'urn:epcglobal:cbv:disp:in_transit',
19
19
  non_sellable: 'urn:epcglobal:cbv:disp:non_sellable_other' // 불량/scrap
20
20
  };
21
+ /**
22
+ * **자재 소비·산출의 CBV 단계** — 도메인 무관하게 코어가 쓴다.
23
+ *
24
+ * 업종별 단계(입고·피킹·출하…)는 각 프로파일이 갖지만, "자재가 들어갔다/나왔다" 는 셋 다 하는 일이라
25
+ * 코어에 있어야 한다. 프로파일 하나에 두면 다른 업종이 그것을 가져다 쓰면서 방언이 생긴다.
26
+ */
27
+ export const CBV_BIZSTEP = {
28
+ /** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
29
+ consuming: 'urn:epcglobal:cbv:bizstep:consuming',
30
+ /** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
31
+ commissioning: 'urn:epcglobal:cbv:bizstep:commissioning'
32
+ };
21
33
  // ── GS1 EPC URI 헬퍼 (표준) ────────────────────────────────────────────────
22
34
  /** SSCC (물류단위: 팔레트/화물/트레일러) — 결정적 카운터 기반. */
23
35
  export function ssccUri(companyPrefix, serial) {
@@ -15,3 +15,33 @@ export declare class EventJournal {
15
15
  }
16
16
  /** 이벤트열 → 상태 재구성(시간여행). board = 마스터(토폴로지·설비). */
17
17
  export declare function replay(board: BoardDef, events: readonly CanonicalEnvelope[]): ProjectedState;
18
+ /** 한 구조 아래에서 일어난 이벤트들 — 재생의 한 마디. */
19
+ export interface StructureSegment {
20
+ board: BoardDef;
21
+ events: readonly CanonicalEnvelope[];
22
+ }
23
+ /** 구조가 바뀐 지점마다 무엇이 사라졌는지. 조용히 넘어가지 않는다. */
24
+ export interface StructureShift {
25
+ index: number;
26
+ locationsAdded: number;
27
+ locationsDropped: number;
28
+ equipmentDropped: number;
29
+ personsDropped: number;
30
+ assetsDropped: number;
31
+ }
32
+ /**
33
+ * **구조가 바뀐 이력까지 이어서 재생한다.**
34
+ *
35
+ * 공장은 바뀐다. 지금까지는 구조가 바뀌면 저널을 지우는 것이 유일한 길이었다 — 안 지우면 옛 이벤트를
36
+ * 새 공장에 대고 접게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
37
+ * 공장에 접는다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
38
+ *
39
+ * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 갈아탄다(관측된 사실은 이어
40
+ * 간다). 그러면 이력이 "그때 그 공장의 사실" 로 계속 읽힌다.
41
+ *
42
+ * 경계에서 사라진 자원은 결과에 실어 보낸다 — 수가 줄어든 것을 사용자가 눈치채지 못하면 안 된다.
43
+ */
44
+ export declare function replaySegments(segments: readonly StructureSegment[]): {
45
+ state: ProjectedState;
46
+ shifts: StructureShift[];
47
+ };
@@ -39,3 +39,29 @@ export function replay(board, events) {
39
39
  proj.apply(e);
40
40
  return proj.snapshot();
41
41
  }
42
+ /**
43
+ * **구조가 바뀐 이력까지 이어서 재생한다.**
44
+ *
45
+ * 공장은 바뀐다. 지금까지는 구조가 바뀌면 저널을 지우는 것이 유일한 길이었다 — 안 지우면 옛 이벤트를
46
+ * 새 공장에 대고 접게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
47
+ * 공장에 접는다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
48
+ *
49
+ * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 갈아탄다(관측된 사실은 이어
50
+ * 간다). 그러면 이력이 "그때 그 공장의 사실" 로 계속 읽힌다.
51
+ *
52
+ * 경계에서 사라진 자원은 결과에 실어 보낸다 — 수가 줄어든 것을 사용자가 눈치채지 못하면 안 된다.
53
+ */
54
+ export function replaySegments(segments) {
55
+ if (!segments.length)
56
+ throw new Error('재생할 마디가 없다 — 구조를 하나도 주지 않았다');
57
+ const proj = new StateProjector(segments[0].board);
58
+ const shifts = [];
59
+ for (let i = 0; i < segments.length; i++) {
60
+ /* 첫 마디는 생성자가 이미 그 구조로 섰다 — 두 번 세우면 관측 전 상태를 다시 덮는다. */
61
+ if (i > 0)
62
+ shifts.push({ index: i, ...proj.adoptStructure(segments[i].board) });
63
+ for (const e of segments[i].events)
64
+ proj.apply(e);
65
+ }
66
+ return { state: proj.snapshot(), shifts };
67
+ }