@operato/twin-kernel 0.1.0 → 0.2.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.
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  EPCIS_CONTEXT: () => EPCIS_CONTEXT,
33
33
  EventJournal: () => EventJournal,
34
34
  FlowEngine: () => FlowEngine,
35
+ ILMD_ATTR: () => ILMD_ATTR,
35
36
  MES_BIZSTEP: () => MES_BIZSTEP,
36
37
  MES_NODE_TYPES: () => MES_NODE_TYPES,
37
38
  MES_PART_GTINS: () => MES_PART_GTINS,
@@ -39,8 +40,11 @@ __export(index_exports, {
39
40
  MES_PRODUCT_GTINS: () => MES_PRODUCT_GTINS,
40
41
  MES_TYPES: () => MES_TYPES,
41
42
  MesKernel: () => MesKernel,
43
+ NODE_SATURATION_NEAR: () => NODE_SATURATION_NEAR,
42
44
  OP_EVENT: () => OP_EVENT,
43
- StateProjector: () => StateProjector,
45
+ OP_PARAM: () => OP_PARAM,
46
+ ObservedReducer: () => ObservedReducer,
47
+ StateProjector: () => ObservedReducer,
44
48
  TwinHistory: () => TwinHistory,
45
49
  TwinObserver: () => TwinObserver,
46
50
  TwinRuntime: () => TwinRuntime,
@@ -65,9 +69,13 @@ __export(index_exports, {
65
69
  gdtiUri: () => gdtiUri,
66
70
  graiUri: () => graiUri,
67
71
  ingest: () => ingest,
72
+ lgtinClass: () => lgtinClass,
68
73
  mapRecord: () => mapRecord,
69
74
  monteCarloForecast: () => monteCarloForecast,
75
+ nodeStatusOf: () => nodeStatusOf,
70
76
  objectEvent: () => objectEvent,
77
+ parseEpc: () => parseEpc,
78
+ parseIsoDuration: () => parseIsoDuration,
71
79
  partialFitPolicy: () => partialFitPolicy,
72
80
  replay: () => replay,
73
81
  sgtinClass: () => sgtinClass,
@@ -82,9 +90,20 @@ __export(index_exports, {
82
90
  module.exports = __toCommonJS(index_exports);
83
91
 
84
92
  // src/contract.ts
93
+ var NODE_SATURATION_NEAR = 0.9;
94
+ function nodeStatusOf(n) {
95
+ const cap = n.capacity;
96
+ if (!(typeof cap === "number" && cap > 0)) return void 0;
97
+ const r = (n.occupancy ?? 0) / cap;
98
+ return r >= 1 ? "full" : r >= NODE_SATURATION_NEAR ? "near-full" : "available";
99
+ }
85
100
  var OP_EVENT = {
86
101
  task: "task.status",
87
102
  equipment: "equipment.status",
103
+ /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
104
+ person: "person.status",
105
+ /** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
106
+ asset: "asset.status",
88
107
  order: "order.status",
89
108
  quality: "quality.output"
90
109
  // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
@@ -111,6 +130,13 @@ var CMD = {
111
130
  };
112
131
 
113
132
  // src/domain-definition.ts
133
+ var OP_PARAM = {
134
+ /** 양품률(0..1, 무차원). 없으면 커널 기본값 — 기본값을 쓴 사실은 `specCoverage()` 가 밝힌다. */
135
+ yield: "yield",
136
+ /** 셋업·체인지오버 소요(ISO 8601 기간 문자열). ISA-95 는 셋업을 별도 세그먼트로도 표현하지만,
137
+ * 현재 커널은 작업에 붙는 셋업으로 다루므로 모수로 받는다. */
138
+ setupDuration: "setupDuration"
139
+ };
114
140
  var INTENTS = ["transport", "process", "dwell"];
115
141
  function dupes(keys) {
116
142
  const seen = /* @__PURE__ */ new Set();
@@ -179,6 +205,9 @@ function compareStates(predicted, actual) {
179
205
  }
180
206
 
181
207
  // src/counterfactual.ts
208
+ function clockOf(twin) {
209
+ return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
210
+ }
182
211
  var TwinHistory = class {
183
212
  live;
184
213
  tickMs;
@@ -189,7 +218,7 @@ var TwinHistory = class {
189
218
  }
190
219
  /** 현재를 체크포인트로 저장(호스트가 주기적으로 호출). */
191
220
  checkpoint() {
192
- this.checkpoints.push({ simMs: this.live.getSnapshot().simClockMs, twin: this.live.fork() });
221
+ this.checkpoints.push({ simMs: clockOf(this.live), twin: this.live.fork() });
193
222
  }
194
223
  get count() {
195
224
  return this.checkpoints.length;
@@ -201,7 +230,7 @@ var TwinHistory = class {
201
230
  if (!best) return void 0;
202
231
  const t = best.twin.fork();
203
232
  let guard = 0;
204
- while (t.getSnapshot().simClockMs < simMs && guard++ < 1e6) t.tick(this.tickMs);
233
+ while (clockOf(t) < simMs && guard++ < 1e6) t.tick(this.tickMs);
205
234
  return t;
206
235
  }
207
236
  };
@@ -215,7 +244,7 @@ function counterfactualAt(history, atSimMs, opts) {
215
244
  const baseline = base.fork();
216
245
  const run = (t) => {
217
246
  let g = 0;
218
- while (t.getSnapshot().simClockMs < target && g++ < 1e6) t.tick(step);
247
+ while (clockOf(t) < target && g++ < 1e6) t.tick(step);
219
248
  };
220
249
  run(withAlt);
221
250
  run(baseline);
@@ -223,8 +252,11 @@ function counterfactualAt(history, atSimMs, opts) {
223
252
  }
224
253
 
225
254
  // src/forecast.ts
255
+ function clockOf2(twin) {
256
+ return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
257
+ }
226
258
  function monteCarloForecast(twin, opts) {
227
- const now = twin.getSnapshot().simClockMs;
259
+ const now = clockOf2(twin);
228
260
  const step = opts.tickMs ?? 1e3;
229
261
  const baseSeed = opts.scenario.seed ?? 1;
230
262
  const samples = [];
@@ -234,7 +266,7 @@ function monteCarloForecast(twin, opts) {
234
266
  fc.scenario.start();
235
267
  const target = now + opts.horizonMs;
236
268
  let guard = 0;
237
- while (fc.getSnapshot().simClockMs < target && guard++ < 1e6) fc.tick(step);
269
+ while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
238
270
  samples.push(opts.metric(fc.getSnapshot()));
239
271
  }
240
272
  return summarize(opts.runs, samples);
@@ -247,6 +279,9 @@ function summarize(runs, samples) {
247
279
  }
248
280
 
249
281
  // src/twin-observer.ts
282
+ function clockOf3(twin) {
283
+ return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
284
+ }
250
285
  var TwinObserver = class {
251
286
  live;
252
287
  opts;
@@ -257,7 +292,7 @@ var TwinObserver = class {
257
292
  }
258
293
  /** 관측 1회 — 만기 예측을 실제와 대조(발산 알림) + 새 예측 생성. 호스트가 주기적으로 호출. */
259
294
  observe() {
260
- const now = this.live.getSnapshot().simClockMs;
295
+ const now = clockOf3(this.live);
261
296
  const due = this.pending.filter((p) => p.horizonSimMs <= now);
262
297
  this.pending = this.pending.filter((p) => p.horizonSimMs > now);
263
298
  for (const p of due) {
@@ -268,8 +303,9 @@ var TwinObserver = class {
268
303
  const step = this.opts.tickMs ?? 1e3;
269
304
  const target = now + this.opts.horizonMs;
270
305
  let guard = 0;
271
- while (fc.getSnapshot().simClockMs < target && guard++ < 1e6) fc.tick(step);
272
- this.pending.push({ madeAtSimMs: now, horizonSimMs: fc.getSnapshot().simClockMs, predicted: fc.getSnapshot() });
306
+ while (clockOf3(fc) < target && guard++ < 1e6) fc.tick(step);
307
+ const predicted = fc.getSnapshot();
308
+ this.pending.push({ madeAtSimMs: now, horizonSimMs: predicted.simClockMs, predicted });
273
309
  }
274
310
  /** 대기 중(아직 만기 안 된) 예측 수 — 진단용. */
275
311
  get pendingCount() {
@@ -277,19 +313,243 @@ var TwinObserver = class {
277
313
  }
278
314
  };
279
315
 
280
- // src/state-projector.ts
281
- var StateProjector = class {
316
+ // src/epcis.ts
317
+ var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
318
+ var UTC_OFFSET = "+00:00";
319
+ var DISP = {
320
+ in_progress: "urn:epcglobal:cbv:disp:in_progress",
321
+ sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
322
+ reserved: "urn:epcglobal:cbv:disp:reserved",
323
+ in_transit: "urn:epcglobal:cbv:disp:in_transit",
324
+ non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
325
+ // 불량/scrap
326
+ };
327
+ function ssccUri(companyPrefix, serial) {
328
+ return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
329
+ }
330
+ function sgtinClass(companyPrefix, itemRef) {
331
+ return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
332
+ }
333
+ var ILMD_ATTR = {
334
+ /** 유통기한·만료(로트 단위). */
335
+ expiry: "cbvmda:itemExpirationDate",
336
+ /** 로트·배치 번호(직렬 개체에 로트를 붙일 때). */
337
+ lot: "cbvmda:lotNumber"
338
+ };
339
+ function lgtinClass(companyPrefix, itemRefAndIndicator, lot) {
340
+ return `urn:epc:class:lgtin:${companyPrefix}.${itemRefAndIndicator}.${encodeURIComponent(lot)}`;
341
+ }
342
+ function parseEpc(uri) {
343
+ const raw = String(uri ?? "");
344
+ const cls = raw.match(/^urn:epc:class:lgtin:(.+)$/);
345
+ if (cls) {
346
+ const seg = cls[1].split(".");
347
+ const lot = seg.slice(2).join(".");
348
+ return {
349
+ scheme: "lgtin",
350
+ instance: false,
351
+ gtinKey: seg.slice(0, 2).join("."),
352
+ lot: lot ? decodeURIComponent(lot) : void 0,
353
+ uri: raw
354
+ };
355
+ }
356
+ const pat = raw.match(/^urn:epc:idpat:sgtin:(.+)$/);
357
+ if (pat) {
358
+ const seg = pat[1].split(".");
359
+ return { scheme: "idpat", instance: false, gtinKey: seg.slice(0, 2).join("."), uri: raw };
360
+ }
361
+ const id = raw.match(/^urn:epc:id:([a-z]+):(.+)$/);
362
+ if (id) {
363
+ const scheme = id[1];
364
+ const seg = id[2].split(".");
365
+ const known = ["sgtin", "sscc", "gdti", "grai", "giai", "sgln"].includes(scheme);
366
+ return {
367
+ scheme: known ? scheme : "unknown",
368
+ instance: true,
369
+ ...scheme === "sgtin" ? { gtinKey: seg.slice(0, 2).join("."), serial: seg[2] } : { serial: seg.slice(1).join(".") },
370
+ uri: raw
371
+ };
372
+ }
373
+ return { scheme: "unknown", instance: false, uri: raw };
374
+ }
375
+ function gdtiUri(companyPrefix, docType, serial) {
376
+ return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
377
+ }
378
+ function header(type, eventTime, bizStep, opts) {
379
+ const h = {
380
+ "@context": EPCIS_CONTEXT,
381
+ type,
382
+ eventTime,
383
+ eventTimeZoneOffset: UTC_OFFSET,
384
+ bizStep
385
+ };
386
+ if (opts?.eventID) h.eventID = opts.eventID;
387
+ if (opts?.recordTime) h.recordTime = opts.recordTime;
388
+ if (opts?.errorDeclaration) h.errorDeclaration = opts.errorDeclaration;
389
+ if (opts?.ilmd) h.ilmd = opts.ilmd;
390
+ if (opts?.sourceList) h.sourceList = opts.sourceList;
391
+ if (opts?.destinationList) h.destinationList = opts.destinationList;
392
+ if (opts?.persistentDisposition) h.persistentDisposition = opts.persistentDisposition;
393
+ if (opts?.sensorElementList) h.sensorElementList = opts.sensorElementList;
394
+ if (opts?.certificationInfo) h.certificationInfo = opts.certificationInfo;
395
+ return h;
396
+ }
397
+ function common(type, eventTime, action, bizStep, opts) {
398
+ return { ...header(type, eventTime, bizStep, opts), action };
399
+ }
400
+ function objectEvent(p) {
401
+ const e = { ...common("ObjectEvent", p.eventTime, p.action, p.bizStep, p), epcList: p.epcList };
402
+ if (p.disposition) e.disposition = p.disposition;
403
+ if (p.quantityList) e.quantityList = p.quantityList;
404
+ if (p.readPoint) e.readPoint = { id: p.readPoint };
405
+ if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
406
+ if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
407
+ return e;
408
+ }
409
+ function aggregationEvent(p) {
410
+ const e = { ...common("AggregationEvent", p.eventTime, p.action, p.bizStep, p), parentID: p.parentID };
411
+ if (p.disposition) e.disposition = p.disposition;
412
+ if (p.childEPCs) e.childEPCs = p.childEPCs;
413
+ if (p.childQuantityList) e.childQuantityList = p.childQuantityList;
414
+ if (p.readPoint) e.readPoint = { id: p.readPoint };
415
+ if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
416
+ return e;
417
+ }
418
+ function transactionEvent(p) {
419
+ const e = {
420
+ ...common("TransactionEvent", p.eventTime, p.action, p.bizStep, p),
421
+ bizTransactionList: p.bizTransactionList
422
+ };
423
+ if (p.disposition) e.disposition = p.disposition;
424
+ if (p.parentID) e.parentID = p.parentID;
425
+ if (p.epcList) e.epcList = p.epcList;
426
+ if (p.quantityList) e.quantityList = p.quantityList;
427
+ if (p.readPoint) e.readPoint = { id: p.readPoint };
428
+ if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
429
+ return e;
430
+ }
431
+ function transformationEvent(p) {
432
+ const e = header("TransformationEvent", p.eventTime, p.bizStep, p);
433
+ if (p.disposition) e.disposition = p.disposition;
434
+ if (p.inputEPCList) e.inputEPCList = p.inputEPCList;
435
+ if (p.inputQuantityList) e.inputQuantityList = p.inputQuantityList;
436
+ if (p.outputEPCList) e.outputEPCList = p.outputEPCList;
437
+ if (p.outputQuantityList) e.outputQuantityList = p.outputQuantityList;
438
+ if (p.transformationID) e.transformationID = p.transformationID;
439
+ if (p.readPoint) e.readPoint = { id: p.readPoint };
440
+ if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
441
+ if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
442
+ return e;
443
+ }
444
+ var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
445
+ var TZ_RE = /^[+-]\d{2}:\d{2}$/;
446
+ var ACTIONS = ["ADD", "OBSERVE", "DELETE"];
447
+ function validateEpcisEvent(e) {
448
+ const v = [];
449
+ if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
450
+ if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
451
+ if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
452
+ if (typeof e.eventTimeZoneOffset !== "string" || !TZ_RE.test(e.eventTimeZoneOffset)) v.push("eventTimeZoneOffset \uD615\uC2DD \uC624\uB958");
453
+ if (typeof e.bizStep !== "string" || !e.bizStep) v.push("bizStep \uB204\uB77D");
454
+ if (e.eventID !== void 0 && (typeof e.eventID !== "string" || !e.eventID)) v.push("eventID \uAC00 \uBE48 \uBB38\uC790\uC5F4");
455
+ if (e.recordTime !== void 0 && (typeof e.recordTime !== "string" || !ISO_RE.test(e.recordTime))) {
456
+ v.push("recordTime ISO8601 \uC544\uB2D8");
457
+ }
458
+ if (e.ilmd !== void 0) {
459
+ const allowed = e.type === "ObjectEvent" && e.action === "ADD" || e.type === "TransformationEvent";
460
+ if (!allowed) v.push("ilmd \uB294 ObjectEvent(action=ADD) \uB610\uB294 TransformationEvent \uC5D0\uB9CC \uC2E4\uC744 \uC218 \uC788\uB2E4");
461
+ }
462
+ for (const sd of e.sourceList ?? []) {
463
+ if (!sd?.type || !sd?.source) v.push("sourceList \uD56D\uBAA9\uC5D0 type \uB610\uB294 source \uB204\uB77D");
464
+ }
465
+ for (const sd of e.destinationList ?? []) {
466
+ if (!sd?.type || !sd?.destination) v.push("destinationList \uD56D\uBAA9\uC5D0 type \uB610\uB294 destination \uB204\uB77D");
467
+ }
468
+ if (e.persistentDisposition !== void 0) {
469
+ const set = e.persistentDisposition.set ?? [];
470
+ const unset = e.persistentDisposition.unset ?? [];
471
+ if (!set.length && !unset.length) v.push("persistentDisposition \uC774 set\xB7unset \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
472
+ const both = set.filter((x) => unset.includes(x));
473
+ if (both.length) v.push(`persistentDisposition \uC774 \uAC19\uC740 \uAC12\uC744 set\xB7unset \uB3D9\uC2DC \uC9C0\uC815: ${both.join(", ")}`);
474
+ }
475
+ for (const se of e.sensorElementList ?? []) {
476
+ if (!Array.isArray(se?.sensorReport) || se.sensorReport.length === 0) {
477
+ v.push("sensorElement \uC5D0 sensorReport \uAC00 \uD558\uB098\uB3C4 \uC5C6\uC74C");
478
+ }
479
+ }
480
+ if (e.errorDeclaration !== void 0) {
481
+ const d = e.errorDeclaration;
482
+ if (typeof d?.declarationTime !== "string" || !ISO_RE.test(d.declarationTime)) {
483
+ v.push("errorDeclaration.declarationTime ISO8601 \uC544\uB2D8/\uB204\uB77D");
484
+ }
485
+ if (d?.correctiveEventIDs !== void 0) {
486
+ if (!Array.isArray(d.correctiveEventIDs)) v.push("errorDeclaration.correctiveEventIDs \uBC30\uC5F4 \uC544\uB2D8");
487
+ else if (d.correctiveEventIDs.some((x) => typeof x !== "string" || !x)) {
488
+ v.push("errorDeclaration.correctiveEventIDs \uC5D0 \uBE48 \uAC12");
489
+ }
490
+ }
491
+ }
492
+ if (e.type === "ObjectEvent") {
493
+ if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
494
+ if (!Array.isArray(e.epcList)) v.push("ObjectEvent.epcList \uB204\uB77D");
495
+ else if (e.epcList.length === 0 && !e.quantityList?.length) v.push("ObjectEvent: epcList/quantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
496
+ } else if (e.type === "AggregationEvent") {
497
+ if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
498
+ if (!e.parentID) v.push("AggregationEvent.parentID \uB204\uB77D");
499
+ if (!e.childEPCs?.length && !e.childQuantityList?.length) v.push("AggregationEvent: childEPCs/childQuantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
500
+ } else if (e.type === "TransactionEvent") {
501
+ if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
502
+ if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0) v.push("TransactionEvent.bizTransactionList \uB204\uB77D");
503
+ } else if (e.type === "TransformationEvent") {
504
+ if (!e.inputEPCList?.length && !e.inputQuantityList?.length) v.push("TransformationEvent: input \uBE44\uC5B4\uC788\uC74C");
505
+ if (!e.outputEPCList?.length && !e.outputQuantityList?.length) v.push("TransformationEvent: output \uBE44\uC5B4\uC788\uC74C");
506
+ }
507
+ const qtyLists = [
508
+ "quantityList" in e ? e.quantityList : void 0,
509
+ "childQuantityList" in e ? e.childQuantityList : void 0,
510
+ "inputQuantityList" in e ? e.inputQuantityList : void 0,
511
+ "outputQuantityList" in e ? e.outputQuantityList : void 0
512
+ ];
513
+ for (const list of qtyLists) for (const q of list ?? []) {
514
+ if (!q.epcClass?.startsWith("urn:epc:idpat:") && !q.epcClass?.startsWith("urn:epc:class:")) v.push(`quantity epcClass \uBD80\uC815: ${q.epcClass}`);
515
+ const hasQty = q.quantity !== void 0 && q.quantity !== null;
516
+ if (!hasQty) {
517
+ if (q.uom !== void 0) v.push("quantity \uC5C6\uC73C\uBA74 uom \uB3C4 \uC5C6\uC5B4\uC57C \uD55C\uB2E4(\uC218\uB7C9 \uBBF8\uC9C0\uC815)");
518
+ continue;
519
+ }
520
+ if (typeof q.quantity !== "number" || !Number.isFinite(q.quantity) || q.quantity <= 0) {
521
+ v.push("quantity \uB294 \uC591\uC218\uC5EC\uC57C \uD55C\uB2E4(\uBAA8\uB974\uBA74 \uC0DD\uB7B5)");
522
+ continue;
523
+ }
524
+ if (q.uom === void 0 && !Number.isInteger(q.quantity)) v.push("uom \uC5C6\uB294 quantity \uB294 \uC815\uC218(\uAC1C\uC218)\uC5EC\uC57C \uD55C\uB2E4");
525
+ if (q.uom !== void 0 && !/^[A-Z0-9]{2,3}$/.test(q.uom)) v.push(`uom \uD615\uC2DD \uBD80\uC815(UN/CEFACT \uAD8C\uACE0 20 \uC758 2~3\uC790 \uCF54\uB4DC): ${q.uom}`);
526
+ }
527
+ return v;
528
+ }
529
+
530
+ // src/observed-reducer.ts
531
+ var UNKNOWN_TYPE = "unknown";
532
+ var ObservedReducer = class {
533
+ /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
282
534
  master = /* @__PURE__ */ new Map();
283
535
  items = /* @__PURE__ */ new Map();
284
536
  aggregation = /* @__PURE__ */ new Map();
285
- // parent SSCC child EPCs
537
+ /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
538
+ pendingParent = /* @__PURE__ */ new Map();
539
+ // 자식 EPC → 부모(물류단위)
286
540
  tasks = /* @__PURE__ */ new Map();
287
541
  movers = /* @__PURE__ */ new Map();
542
+ persons = /* @__PURE__ */ new Map();
543
+ assets = /* @__PURE__ */ new Map();
288
544
  orders = /* @__PURE__ */ new Map();
289
545
  revision = 0;
546
+ /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
547
+ corrections = [];
290
548
  constructor(board) {
291
- for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parentId: n.parentId });
292
- for (const m of board.movers) this.movers.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeNode });
549
+ for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
550
+ for (const m of board.movers) this.movers.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeNode, origin: "master" });
551
+ for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle" });
552
+ for (const a of board.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle" });
293
553
  }
294
554
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
295
555
  applyMaster(u) {
@@ -298,48 +558,173 @@ var StateProjector = class {
298
558
  return;
299
559
  }
300
560
  const cur = this.master.get(u.node.id);
561
+ const capacity = u.node.capacity ?? cur?.capacity;
562
+ const parallelism = u.node.parallelism ?? cur?.parallelism;
301
563
  this.master.set(u.node.id, {
302
564
  id: u.node.id,
303
- type: u.node.type ?? cur?.type ?? "unknown",
304
- capacity: u.node.capacity ?? cur?.capacity ?? 0
565
+ type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
566
+ ...capacity === void 0 ? {} : { capacity },
567
+ ...parallelism === void 0 ? {} : { parallelism },
568
+ ...u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {},
569
+ /* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
570
+ origin: "master"
305
571
  });
306
572
  }
573
+ /**
574
+ * 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
575
+ *
576
+ * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
577
+ * 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
578
+ * 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
579
+ * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
580
+ *
581
+ * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
582
+ * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
583
+ */
584
+ touchLocation(id) {
585
+ if (!id || this.master.has(id)) return;
586
+ this.master.set(id, { id, type: UNKNOWN_TYPE, origin: "observed" });
587
+ }
588
+ /**
589
+ * 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
590
+ *
591
+ * 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
592
+ * 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
593
+ * 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
594
+ *
595
+ * 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
596
+ * 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
597
+ */
598
+ stale(key, e) {
599
+ const at = Date.parse(String(e.eventTime ?? ""));
600
+ if (!Number.isFinite(at)) return false;
601
+ const recorded = Date.parse(String(e.data?.recordTime ?? ""));
602
+ const seen = this.lastAt.get(key);
603
+ if (seen === void 0) {
604
+ this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : void 0 });
605
+ return false;
606
+ }
607
+ if (at < seen.at) return true;
608
+ if (at === seen.at && Number.isFinite(recorded) && seen.recorded !== void 0 && recorded < seen.recorded) return true;
609
+ this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : seen.recorded });
610
+ return false;
611
+ }
612
+ /** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
613
+ lastAt = /* @__PURE__ */ new Map();
307
614
  /** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
308
615
  apply(e) {
309
616
  this.revision++;
310
617
  if (e.eventType.startsWith("epcis.")) {
311
- this.applyEpcis(e.data);
618
+ this.applyEpcis(e.data, e);
312
619
  return;
313
620
  }
314
621
  switch (e.eventType) {
315
622
  case OP_EVENT.task: {
316
623
  const d = e.data;
317
- this.tasks.set(d.taskId, { id: d.taskId, kind: d.kind, status: d.status, fromNode: d.fromNode, toNode: d.toNode, itemRefs: d.itemRefs, resourceRef: d.resourceRef });
624
+ if (this.stale(`task:${d.taskId}`, e)) return;
625
+ this.touchLocation(d.fromNode);
626
+ this.touchLocation(d.toNode);
627
+ this.tasks.set(d.taskId, {
628
+ id: d.taskId,
629
+ kind: d.kind,
630
+ status: d.status,
631
+ fromNode: d.fromNode,
632
+ toNode: d.toNode,
633
+ itemRefs: d.itemRefs,
634
+ resourceRef: d.resourceRef,
635
+ orderId: d.orderId,
636
+ intent: d.intent,
637
+ progress: d.progress,
638
+ remainingMs: d.remainingMs,
639
+ durationMs: d.durationMs,
640
+ startedAtSimMs: d.startedAtSimMs,
641
+ ...d.personnel?.length ? { personnel: d.personnel.slice() } : {},
642
+ ...d.assets?.length ? { assets: d.assets.slice() } : {}
643
+ });
318
644
  break;
319
645
  }
320
646
  case OP_EVENT.equipment: {
321
647
  const d = e.data;
322
- this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, motion: d.motion });
648
+ if (this.stale(`mover:${d.moverId}`, e)) return;
649
+ this.touchLocation(d.location);
650
+ const known = this.movers.get(d.moverId);
651
+ this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, taskId: d.taskId, motion: d.motion, origin: known?.origin ?? "observed" });
652
+ break;
653
+ }
654
+ case OP_EVENT.person: {
655
+ const d = e.data;
656
+ if (this.stale(`person:${d.personId}`, e)) return;
657
+ this.persons.set(d.personId, {
658
+ id: d.personId,
659
+ personnelClass: d.personnelClass ?? this.persons.get(d.personId)?.personnelClass,
660
+ status: d.status,
661
+ taskId: d.taskId,
662
+ ...d.offShift ? { offShift: true } : {}
663
+ });
664
+ break;
665
+ }
666
+ case OP_EVENT.asset: {
667
+ const d = e.data;
668
+ if (this.stale(`asset:${d.assetId}`, e)) return;
669
+ const cur = this.assets.get(d.assetId);
670
+ this.assets.set(d.assetId, {
671
+ id: d.assetId,
672
+ assetClass: d.assetClass ?? cur?.assetClass,
673
+ location: d.location ?? cur?.location,
674
+ status: d.status,
675
+ taskId: d.taskId,
676
+ ...d.carrying ? { carrying: d.carrying } : {}
677
+ });
678
+ this.touchLocation(d.location);
323
679
  break;
324
680
  }
325
681
  case OP_EVENT.order: {
326
682
  const d = e.data;
683
+ if (this.stale(`order:${d.orderId}`, e)) return;
327
684
  this.orders.set(d.orderId, { id: d.orderId, kind: d.kind, status: d.status, progress: d.requested ? d.fulfilled / d.requested : 0, held: d.held });
328
685
  break;
329
686
  }
330
687
  }
331
688
  }
332
- applyEpcis(ev) {
689
+ applyEpcis(ev, envelope) {
690
+ if (ev.errorDeclaration) {
691
+ this.corrections.push({
692
+ declaredAt: String(ev.errorDeclaration.declarationTime ?? ""),
693
+ reason: ev.errorDeclaration.reason,
694
+ correctiveEventIDs: ev.errorDeclaration.correctiveEventIDs ?? [],
695
+ eventID: ev.eventID
696
+ });
697
+ return;
698
+ }
333
699
  if (ev.type === "AggregationEvent") {
334
- if (ev.action === "ADD" && ev.childEPCs?.length) this.aggregation.set(ev.parentID, [...ev.childEPCs]);
335
- else if (ev.action === "DELETE") this.aggregation.delete(ev.parentID);
700
+ if (ev.action === "ADD" && ev.childEPCs?.length) {
701
+ this.aggregation.set(ev.parentID, [...ev.childEPCs]);
702
+ for (const child of ev.childEPCs) {
703
+ const cur = this.items.get(child);
704
+ if (cur) {
705
+ this.items.set(child, { ...cur, parent: ev.parentID });
706
+ continue;
707
+ }
708
+ this.pendingParent.set(child, ev.parentID);
709
+ }
710
+ } else if (ev.action === "DELETE") {
711
+ for (const child of this.aggregation.get(ev.parentID) ?? []) {
712
+ const cur = this.items.get(child);
713
+ if (cur) this.items.set(child, { ...cur, parent: void 0 });
714
+ this.pendingParent.delete(child);
715
+ }
716
+ this.aggregation.delete(ev.parentID);
717
+ }
336
718
  return;
337
719
  }
338
720
  if (ev.type === "TransactionEvent") return;
339
721
  if (ev.type === "TransformationEvent") {
340
722
  for (const epc of ev.inputEPCList ?? []) this.remove(epc);
341
723
  const loc2 = ev.readPoint?.id ?? "";
342
- for (const epc of ev.outputEPCList ?? []) this.items.set(epc, { epc, location: loc2, disposition: ev.disposition });
724
+ this.touchLocation(loc2);
725
+ for (const epc of ev.outputEPCList ?? []) {
726
+ this.items.set(epc, this.mergeItem(epc, { location: loc2, disposition: ev.disposition, ilmd: ev.ilmd }));
727
+ }
343
728
  return;
344
729
  }
345
730
  if (ev.action === "DELETE") {
@@ -347,11 +732,57 @@ var StateProjector = class {
347
732
  return;
348
733
  }
349
734
  const loc = ev.readPoint?.id;
350
- const gtin = ev.quantityList?.[0]?.epcClass;
735
+ const q = ev.quantityList?.[0];
736
+ this.touchLocation(loc);
351
737
  for (const epc of ev.epcList) {
352
- const cur = this.items.get(epc);
353
- this.items.set(epc, { epc, gtin: gtin ?? cur?.gtin, location: loc ?? cur?.location ?? "", disposition: ev.disposition ?? cur?.disposition });
738
+ if (envelope && this.stale(`item:${epc}`, envelope)) continue;
739
+ this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q));
740
+ }
741
+ if (!ev.epcList?.length) {
742
+ for (const qe of ev.quantityList ?? []) {
743
+ if (qe?.epcClass) this.items.set(qe.epcClass, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe));
744
+ }
745
+ }
746
+ }
747
+ /**
748
+ * 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
749
+ * 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
750
+ */
751
+ /**
752
+ * 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
753
+ *
754
+ * 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
755
+ * `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
756
+ */
757
+ expiryOf(ilmd) {
758
+ const raw = ilmd?.[ILMD_ATTR.expiry];
759
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
760
+ if (typeof raw === "string") {
761
+ const t = Date.parse(raw);
762
+ if (Number.isFinite(t)) return t;
354
763
  }
764
+ return void 0;
765
+ }
766
+ mergeItem(epc, patch, q) {
767
+ const cur = this.items.get(epc);
768
+ const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : void 0;
769
+ const parsedSelf = parseEpc(epc);
770
+ const classUri = q?.epcClass ?? (parsedSelf.instance ? void 0 : epc);
771
+ return {
772
+ epc,
773
+ gtin: classUri ?? cur?.gtin,
774
+ gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
775
+ location: patch.location ?? cur?.location ?? "",
776
+ disposition: patch.disposition ?? cur?.disposition,
777
+ parent: cur?.parent ?? this.pendingParent.get(epc),
778
+ qty: q?.quantity ?? cur?.qty,
779
+ uom: q?.uom ?? cur?.uom,
780
+ /* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
781
+ ilmd: patch.ilmd ?? cur?.ilmd,
782
+ expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
783
+ /* 로트는 LGTIN(식별자)에서 오지만, 직렬 개체는 마스터데이터에 실려 온다. */
784
+ lot: parsedClass?.lot ?? parsedSelf.lot ?? (typeof patch.ilmd?.[ILMD_ATTR.lot] === "string" ? patch.ilmd[ILMD_ATTR.lot] : void 0) ?? cur?.lot
785
+ };
355
786
  }
356
787
  /** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
357
788
  remove(epc) {
@@ -368,8 +799,26 @@ var StateProjector = class {
368
799
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
369
800
  return {
370
801
  revision: this.revision,
371
- nodes: [...this.master.values()].map((n) => ({ id: n.id, type: n.type, capacity: n.capacity, occupancy: occ.get(n.id) ?? 0, parentId: n.parentId })),
372
- items: [...this.items.values()].map((i) => ({ epc: i.epc, gtin: i.gtin, location: i.location, disposition: i.disposition })),
802
+ ...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
803
+ nodes: [...this.master.values()].map((n) => {
804
+ const occupancy = occ.get(n.id) ?? 0;
805
+ const status = nodeStatusOf({ occupancy, capacity: n.capacity });
806
+ return {
807
+ id: n.id,
808
+ type: n.type,
809
+ occupancy,
810
+ ...status ? { status } : {},
811
+ /* 용량 미상은 **키를 만들지 않는다** — 0 으로 실으면 "자리 없음" 이라는 없는 사실이 생긴다. */
812
+ ...n.capacity === void 0 ? {} : { capacity: n.capacity },
813
+ ...n.parallelism === void 0 ? {} : { parallelism: n.parallelism },
814
+ ...n.parentId ? { parentId: n.parentId } : {},
815
+ origin: n.origin
816
+ };
817
+ }),
818
+ /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
819
+ items: [...this.items.values()].map((i) => ({ ...i })),
820
+ persons: [...this.persons.values()].map((p) => ({ ...p })),
821
+ assets: [...this.assets.values()].map((a) => ({ ...a })),
373
822
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
374
823
  movers: [...this.movers.values()].map((m) => ({ ...m })),
375
824
  orders: [...this.orders.values()].map((o) => ({ ...o }))
@@ -404,119 +853,11 @@ var EventJournal = class {
404
853
  }
405
854
  };
406
855
  function replay(board, events) {
407
- const proj = new StateProjector(board);
856
+ const proj = new ObservedReducer(board);
408
857
  for (const e of events) proj.apply(e);
409
858
  return proj.snapshot();
410
859
  }
411
860
 
412
- // src/epcis.ts
413
- var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
414
- var UTC_OFFSET = "+00:00";
415
- var DISP = {
416
- in_progress: "urn:epcglobal:cbv:disp:in_progress",
417
- sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
418
- reserved: "urn:epcglobal:cbv:disp:reserved",
419
- in_transit: "urn:epcglobal:cbv:disp:in_transit",
420
- non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
421
- // 불량/scrap
422
- };
423
- function ssccUri(companyPrefix, serial) {
424
- return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
425
- }
426
- function sgtinClass(companyPrefix, itemRef) {
427
- return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
428
- }
429
- function gdtiUri(companyPrefix, docType, serial) {
430
- return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
431
- }
432
- function header(type, eventTime, bizStep) {
433
- return { "@context": EPCIS_CONTEXT, type, eventTime, eventTimeZoneOffset: UTC_OFFSET, bizStep };
434
- }
435
- function common(type, eventTime, action, bizStep) {
436
- return { ...header(type, eventTime, bizStep), action };
437
- }
438
- function objectEvent(p) {
439
- const e = { ...common("ObjectEvent", p.eventTime, p.action, p.bizStep), epcList: p.epcList };
440
- if (p.disposition) e.disposition = p.disposition;
441
- if (p.quantityList) e.quantityList = p.quantityList;
442
- if (p.readPoint) e.readPoint = { id: p.readPoint };
443
- if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
444
- if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
445
- return e;
446
- }
447
- function aggregationEvent(p) {
448
- const e = { ...common("AggregationEvent", p.eventTime, p.action, p.bizStep), parentID: p.parentID };
449
- if (p.disposition) e.disposition = p.disposition;
450
- if (p.childEPCs) e.childEPCs = p.childEPCs;
451
- if (p.childQuantityList) e.childQuantityList = p.childQuantityList;
452
- if (p.readPoint) e.readPoint = { id: p.readPoint };
453
- if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
454
- return e;
455
- }
456
- function transactionEvent(p) {
457
- const e = {
458
- ...common("TransactionEvent", p.eventTime, p.action, p.bizStep),
459
- bizTransactionList: p.bizTransactionList
460
- };
461
- if (p.disposition) e.disposition = p.disposition;
462
- if (p.parentID) e.parentID = p.parentID;
463
- if (p.epcList) e.epcList = p.epcList;
464
- if (p.quantityList) e.quantityList = p.quantityList;
465
- if (p.readPoint) e.readPoint = { id: p.readPoint };
466
- if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
467
- return e;
468
- }
469
- function transformationEvent(p) {
470
- const e = header("TransformationEvent", p.eventTime, p.bizStep);
471
- if (p.disposition) e.disposition = p.disposition;
472
- if (p.inputEPCList) e.inputEPCList = p.inputEPCList;
473
- if (p.inputQuantityList) e.inputQuantityList = p.inputQuantityList;
474
- if (p.outputEPCList) e.outputEPCList = p.outputEPCList;
475
- if (p.outputQuantityList) e.outputQuantityList = p.outputQuantityList;
476
- if (p.transformationID) e.transformationID = p.transformationID;
477
- if (p.readPoint) e.readPoint = { id: p.readPoint };
478
- if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
479
- if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
480
- return e;
481
- }
482
- var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
483
- var TZ_RE = /^[+-]\d{2}:\d{2}$/;
484
- var ACTIONS = ["ADD", "OBSERVE", "DELETE"];
485
- function validateEpcisEvent(e) {
486
- const v = [];
487
- if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
488
- if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
489
- if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
490
- if (typeof e.eventTimeZoneOffset !== "string" || !TZ_RE.test(e.eventTimeZoneOffset)) v.push("eventTimeZoneOffset \uD615\uC2DD \uC624\uB958");
491
- if (typeof e.bizStep !== "string" || !e.bizStep) v.push("bizStep \uB204\uB77D");
492
- if (e.type === "ObjectEvent") {
493
- if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
494
- if (!Array.isArray(e.epcList)) v.push("ObjectEvent.epcList \uB204\uB77D");
495
- else if (e.epcList.length === 0 && !e.quantityList?.length) v.push("ObjectEvent: epcList/quantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
496
- } else if (e.type === "AggregationEvent") {
497
- if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
498
- if (!e.parentID) v.push("AggregationEvent.parentID \uB204\uB77D");
499
- if (!e.childEPCs?.length && !e.childQuantityList?.length) v.push("AggregationEvent: childEPCs/childQuantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
500
- } else if (e.type === "TransactionEvent") {
501
- if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
502
- if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0) v.push("TransactionEvent.bizTransactionList \uB204\uB77D");
503
- } else if (e.type === "TransformationEvent") {
504
- if (!e.inputEPCList?.length && !e.inputQuantityList?.length) v.push("TransformationEvent: input \uBE44\uC5B4\uC788\uC74C");
505
- if (!e.outputEPCList?.length && !e.outputQuantityList?.length) v.push("TransformationEvent: output \uBE44\uC5B4\uC788\uC74C");
506
- }
507
- const qtyLists = [
508
- "quantityList" in e ? e.quantityList : void 0,
509
- "childQuantityList" in e ? e.childQuantityList : void 0,
510
- "inputQuantityList" in e ? e.inputQuantityList : void 0,
511
- "outputQuantityList" in e ? e.outputQuantityList : void 0
512
- ];
513
- for (const list of qtyLists) for (const q of list ?? []) {
514
- if (!q.epcClass?.startsWith("urn:epc:idpat:") && !q.epcClass?.startsWith("urn:epc:class:")) v.push(`quantity epcClass \uBD80\uC815: ${q.epcClass}`);
515
- if (typeof q.quantity !== "number" || q.quantity < 0) v.push("quantity \uBD80\uC815");
516
- }
517
- return v;
518
- }
519
-
520
861
  // src/wms-profile.ts
521
862
  var BIZSTEP = {
522
863
  receiving: "urn:epcglobal:cbv:bizstep:receiving",
@@ -682,6 +1023,25 @@ var fefoPolicy = {
682
1023
  // src/duration-estimator.ts
683
1024
  var constantDuration = (ms2) => ({ estimate: () => ms2 });
684
1025
 
1026
+ // src/iso-duration.ts
1027
+ var RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
1028
+ function parseIsoDuration(text) {
1029
+ if (typeof text !== "string") return void 0;
1030
+ const s = text.trim();
1031
+ if (!s || s === "P" || s === "PT") return void 0;
1032
+ if (/\d+Y/.test(s)) return void 0;
1033
+ const tIdx = s.indexOf("T");
1034
+ const datePart = tIdx === -1 ? s : s.slice(0, tIdx);
1035
+ if (/\d+M/.test(datePart)) return void 0;
1036
+ const m = RE.exec(s);
1037
+ if (!m) return void 0;
1038
+ const [, sign, w, d, h, min, sec] = m;
1039
+ if (!w && !d && !h && !min && !sec) return void 0;
1040
+ const ms2 = (Number(w ?? 0) * 7 + Number(d ?? 0)) * 864e5 + Number(h ?? 0) * 36e5 + Number(min ?? 0) * 6e4 + Number(sec ?? 0) * 1e3;
1041
+ if (!Number.isFinite(ms2)) return void 0;
1042
+ return sign ? -ms2 : ms2;
1043
+ }
1044
+
685
1045
  // src/task-fold.ts
686
1046
  function ms(value) {
687
1047
  if (!value) return null;
@@ -931,19 +1291,41 @@ var FlowEngine = class {
931
1291
  nodes = /* @__PURE__ */ new Map();
932
1292
  items = /* @__PURE__ */ new Map();
933
1293
  movers = /* @__PURE__ */ new Map();
1294
+ /** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
1295
+ persons = /* @__PURE__ */ new Map();
1296
+ /** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
1297
+ assets = /* @__PURE__ */ new Map();
934
1298
  tasks = /* @__PURE__ */ new Map();
935
1299
  orders = /* @__PURE__ */ new Map();
936
1300
  revision = 0;
937
1301
  clockMs = 0;
938
1302
  rng = mulberry32(1);
939
1303
  policy;
940
- /** duration 시임(선택) — 미주입 시 도메인 상수. 씬/보드 바인딩이 거리·속도 기반 estimator 주입. */
1304
+ /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
941
1305
  durationEstimator;
1306
+ /**
1307
+ * 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
1308
+ * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
1309
+ */
1310
+ operationSpecs = /* @__PURE__ */ new Map();
1311
+ /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
1312
+ observer;
1313
+ /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
1314
+ observedDirty = false;
1315
+ observeMode = false;
1316
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
1317
+ boardDef;
1318
+ /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
1319
+ specUse = /* @__PURE__ */ new Map();
942
1320
  epcSeq = 0;
943
1321
  taskSeq = 0;
944
1322
  orderSeq = 0;
945
1323
  soSeq = 0;
946
1324
  handlers = [];
1325
+ /** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
1326
+ handlersRef() {
1327
+ return this.handlers;
1328
+ }
947
1329
  gens = [];
948
1330
  generating = false;
949
1331
  speed = 1;
@@ -954,9 +1336,12 @@ var FlowEngine = class {
954
1336
  }
955
1337
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
956
1338
  loadBoard(def) {
957
- for (const n of def.nodes) this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, occupancy: 0, status: "idle", parentId: n.parentId });
1339
+ this.boardDef = def;
1340
+ for (const n of def.nodes) this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId });
1341
+ for (const p of def.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null, window: p.window });
1342
+ for (const a of def.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle", taskId: null });
958
1343
  for (const m of def.movers) {
959
- const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
1344
+ const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window };
960
1345
  if (m.mtbfMs !== void 0) {
961
1346
  mover.mtbfMs = m.mtbfMs;
962
1347
  mover.mttrMs = m.mttrMs;
@@ -987,10 +1372,82 @@ var FlowEngine = class {
987
1372
  * 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
988
1373
  */
989
1374
  hydrateObserved(snap, orders = []) {
990
- for (const n of snap.nodes) this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, occupancy: n.occupancy ?? 0, status: "idle", parentId: n.parentId });
1375
+ for (const n of snap.nodes) {
1376
+ this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? "idle", parentId: n.parentId });
1377
+ }
991
1378
  this.items.clear();
992
- for (const it of snap.items) this.items.set(it.epc, { epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable, gtin: it.gtin, qty: it.qty ?? 1 });
993
- for (const m of snap.movers) this.movers.set(m.id, { id: m.id, kind: m.kind, location: m.location ?? "", status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 });
1379
+ for (const it of snap.items) {
1380
+ this.items.set(it.epc, {
1381
+ /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
1382
+ epc: it.epc,
1383
+ location: it.location,
1384
+ disposition: it.disposition ?? DISP.sellable,
1385
+ gtin: it.gtin,
1386
+ qty: it.qty ?? 1,
1387
+ uom: it.uom,
1388
+ parent: it.parent,
1389
+ carriedBy: it.carriedBy,
1390
+ expiry: it.expiry,
1391
+ ilmd: it.ilmd
1392
+ });
1393
+ }
1394
+ for (const m of snap.movers) {
1395
+ const oee = m.oee;
1396
+ this.movers.set(m.id, {
1397
+ id: m.id,
1398
+ kind: m.kind,
1399
+ location: m.location ?? "",
1400
+ status: m.status ?? "idle",
1401
+ taskId: null,
1402
+ runMs: oee?.runMs ?? 0,
1403
+ setupMs: oee?.setupMs ?? 0,
1404
+ downMs: oee?.downMs ?? 0,
1405
+ goodCount: oee?.goodCount ?? 0,
1406
+ scrapCount: oee?.scrapCount ?? 0
1407
+ });
1408
+ }
1409
+ for (const p of snap.persons ?? []) {
1410
+ this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null });
1411
+ }
1412
+ for (const a of snap.assets ?? []) {
1413
+ this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.location, status: "idle", taskId: null, carrying: a.carrying });
1414
+ }
1415
+ for (const t of snap.tasks ?? []) {
1416
+ if (t.status === "completed") continue;
1417
+ const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
1418
+ this.tasks.set(t.id, {
1419
+ id: t.id,
1420
+ kind: t.kind,
1421
+ status: known && t.status === "in-progress" ? "in-progress" : "created",
1422
+ itemEpc: t.itemRefs?.[0] ?? "",
1423
+ fromNode: t.fromNode ?? "",
1424
+ toNode: t.toNode ?? "",
1425
+ resource: known && t.status === "in-progress" ? t.resourceRef ?? null : null,
1426
+ remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
1427
+ startedAtSimMs: t.startedAtSimMs,
1428
+ durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
1429
+ orderId: t.orderId,
1430
+ intent: t.intent
1431
+ });
1432
+ if (known && t.status === "in-progress" && t.resourceRef) {
1433
+ const mv = this.movers.get(t.resourceRef);
1434
+ if (mv) {
1435
+ mv.status = "busy";
1436
+ mv.taskId = t.id;
1437
+ }
1438
+ }
1439
+ if (known && t.status === "in-progress") {
1440
+ const restored = this.tasks.get(t.id);
1441
+ if (restored) restored.personnel = t.personnel ? [...t.personnel] : void 0;
1442
+ for (const id of t.personnel ?? []) {
1443
+ const pp = this.persons.get(id);
1444
+ if (pp) {
1445
+ pp.status = "busy";
1446
+ pp.taskId = t.id;
1447
+ }
1448
+ }
1449
+ }
1450
+ }
994
1451
  for (const o of orders) {
995
1452
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
996
1453
  const remaining = lines.reduce((s, l) => s + l.requested, 0);
@@ -1121,7 +1578,7 @@ var FlowEngine = class {
1121
1578
  start: () => {
1122
1579
  if (this.generating) return;
1123
1580
  this.generating = true;
1124
- for (const g of this.gens) g.nextMs = this.clockMs + this.intervalMs(g.spec);
1581
+ for (const g of this.gens) g.nextMs = this.nextFireMs(g.spec, this.clockMs);
1125
1582
  },
1126
1583
  pause: () => {
1127
1584
  this.generating = false;
@@ -1143,18 +1600,52 @@ var FlowEngine = class {
1143
1600
  this.processTasks(dt);
1144
1601
  }
1145
1602
  getSnapshot() {
1603
+ this.settleObserved();
1146
1604
  return {
1147
1605
  revision: this.revision,
1148
1606
  simClockMs: this.clockMs,
1149
- nodes: [...this.nodes.values()].map((n) => ({ ...n })),
1150
- items: [...this.items.values()].map((i) => ({ epc: i.epc, gtin: i.gtin, qty: i.qty, location: i.location, disposition: i.disposition, expiry: i.expiry })),
1607
+ /* 출처 표시 — 보드(마스터)에서 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
1608
+ * 시뮬이 아무 표시도 하면 소비처가 스냅샷을 같은 규칙으로 읽지 못한다. */
1609
+ nodes: [...this.nodes.values()].map((n) => {
1610
+ const { status, ...rest } = n;
1611
+ const derived = nodeStatusOf(n);
1612
+ return { ...rest, ...derived ? { status: derived } : {}, origin: "master" };
1613
+ }),
1614
+ items: [...this.items.values()].map((i) => this.itemState(i)),
1151
1615
  movers: [...this.movers.values()].map((m) => {
1152
- const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held };
1616
+ const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, origin: "master", ...this.offShift(m) ? { offShift: true } : {} };
1153
1617
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
1154
1618
  if (t && t.status === "in-progress" && t.intent !== "process") s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
1155
1619
  return s;
1156
1620
  }),
1157
- tasks: [...this.tasks.values()].map((t) => ({ id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc], fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? void 0, orderId: t.orderId, progress: t.status === "in-progress" ? this.progressOf(t) : void 0 })),
1621
+ assets: [...this.assets.values()].map((a) => {
1622
+ const st = { id: a.id, assetClass: a.assetClass, location: a.location, status: a.status, taskId: a.taskId ?? void 0 };
1623
+ if (a.carrying) st.carrying = a.carrying;
1624
+ return st;
1625
+ }),
1626
+ persons: [...this.persons.values()].map((p) => {
1627
+ const st = { id: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0 };
1628
+ if (this.personOffShift(p)) st.offShift = true;
1629
+ return st;
1630
+ }),
1631
+ /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
1632
+ * 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
1633
+ * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
1634
+ tasks: [...this.tasks.values()].map((t) => ({
1635
+ id: t.id,
1636
+ kind: t.kind,
1637
+ status: t.status,
1638
+ itemRefs: [t.itemEpc],
1639
+ fromNode: t.fromNode,
1640
+ toNode: t.toNode,
1641
+ resourceRef: t.resource ?? void 0,
1642
+ orderId: t.orderId,
1643
+ ...t.intent ? { intent: t.intent } : {},
1644
+ ...t.durationMs ? { durationMs: t.durationMs } : {},
1645
+ ...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
1646
+ ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
1647
+ ...t.assets?.length ? { assets: t.assets.slice() } : {}
1648
+ })),
1158
1649
  orders: [...this.orders.values()].map((o) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.requested ? o.fulfilled / o.requested : 0, held: o.held })),
1159
1650
  attentions: this.computeAttentions()
1160
1651
  };
@@ -1180,6 +1671,7 @@ var FlowEngine = class {
1180
1671
  * rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
1181
1672
  */
1182
1673
  fork(tenantId = this.tenantId) {
1674
+ this.settleObserved();
1183
1675
  const Ctor = this.constructor;
1184
1676
  const clone = new Ctor(tenantId, this.policy);
1185
1677
  const skip = /* @__PURE__ */ new Set(["policy", "scenario", "tenantId", "handlers", "durationEstimator"]);
@@ -1191,6 +1683,8 @@ var FlowEngine = class {
1191
1683
  }
1192
1684
  clone.rng.state = this.rng.state;
1193
1685
  clone.durationEstimator = this.durationEstimator;
1686
+ clone.operationSpecs = this.operationSpecs;
1687
+ clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
1194
1688
  return clone;
1195
1689
  }
1196
1690
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
@@ -1200,9 +1694,161 @@ var FlowEngine = class {
1200
1694
  randInt(min, max) {
1201
1695
  return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
1202
1696
  }
1203
- /** task 소요 산출 — estimator 주입 시 그 값, 미주입/undefined 시 도메인 상수(fallback). "얼마"만 소비, "경로"는 씬. */
1697
+ /**
1698
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
1699
+ *
1700
+ * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
1701
+ * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
1702
+ * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
1703
+ *
1704
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
1705
+ * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
1706
+ * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
1707
+ *
1708
+ * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
1709
+ * reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
1710
+ *
1711
+ * `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
1712
+ */
1713
+ apply(envelope) {
1714
+ if (!this.observer) {
1715
+ this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
1716
+ this.observeMode = true;
1717
+ }
1718
+ this.observer.apply(envelope);
1719
+ for (const h of this.observedHandlers()) h(envelope);
1720
+ this.observedDirty = true;
1721
+ this.revision++;
1722
+ }
1723
+ /** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
1724
+ settleObserved() {
1725
+ if (!this.observedDirty || !this.observer) return;
1726
+ this.observedDirty = false;
1727
+ this.hydrateObserved(this.observer.snapshot());
1728
+ }
1729
+ /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
1730
+ observedHandlers() {
1731
+ return this.handlersRef();
1732
+ }
1733
+ /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
1734
+ get observing() {
1735
+ return this.observeMode;
1736
+ }
1737
+ /**
1738
+ * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
1739
+ * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
1740
+ */
1741
+ loadOperations(ops = []) {
1742
+ for (const o of ops) if (o?.key) this.operationSpecs.set(o.key, o);
1743
+ }
1744
+ /**
1745
+ * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
1746
+ *
1747
+ * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 박아 둔 상수를 이긴다.
1748
+ * 셋 중 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
1749
+ * "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
1750
+ */
1204
1751
  durationOf(ctx, fallbackMs) {
1205
- return this.durationEstimator?.estimate(ctx) ?? fallbackMs;
1752
+ const estimated = this.durationEstimator?.estimate(ctx);
1753
+ if (typeof estimated === "number") {
1754
+ this.noteSpecUse(ctx.kind, "measured");
1755
+ return estimated;
1756
+ }
1757
+ if (estimated) {
1758
+ this.noteSpecUse(ctx.kind, "measured", estimated.spread?.distribution);
1759
+ return this.sampleSpread(estimated.meanMs, estimated.spread);
1760
+ }
1761
+ const spec = this.operationSpecs.get(ctx.kind);
1762
+ const declared = parseIsoDuration(spec?.duration);
1763
+ if (declared === void 0) {
1764
+ this.noteSpecUse(ctx.kind, "default");
1765
+ return fallbackMs;
1766
+ }
1767
+ this.noteSpecUse(ctx.kind, "declared");
1768
+ return this.applyVariability(declared, spec?.variability);
1769
+ }
1770
+ /**
1771
+ * 소요시간 변동 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 표준 밖 확장이므로 미지정이면 상수.
1772
+ * 모수가 모자라면(uniform 에 min/max 없음 등) 변동을 발명하지 않고 평균을 그대로 쓴다.
1773
+ */
1774
+ applyVariability(meanMs, v) {
1775
+ if (!v || v.distribution === "constant") return meanMs;
1776
+ if (v.distribution === "exponential") return -Math.log(1 - this.rng()) * meanMs;
1777
+ const minMs = parseIsoDuration(v.min);
1778
+ const maxMs = parseIsoDuration(v.max);
1779
+ if (minMs === void 0 || maxMs === void 0) return meanMs;
1780
+ return this.sampleSpread(meanMs, { distribution: v.distribution, minMs, maxMs, modeMs: parseIsoDuration(v.mode) });
1781
+ }
1782
+ /**
1783
+ * 퍼짐 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 선언 명세(ISO 표기)와 실측 분포(ms)가
1784
+ * 같은 수식을 쓴다: 한쪽만 고치면 두 경로가 다른 답을 낸다.
1785
+ * 모수가 모자라거나 뒤집혀 있으면 **퍼짐을 발명하지 않고** 평균을 그대로 쓴다.
1786
+ */
1787
+ sampleSpread(meanMs, spread) {
1788
+ if (!spread) return meanMs;
1789
+ const { minMs, maxMs } = spread;
1790
+ if (!Number.isFinite(minMs) || !Number.isFinite(maxMs) || maxMs < minMs) return meanMs;
1791
+ if (spread.distribution === "uniform") return minMs + this.rng() * (maxMs - minMs);
1792
+ const mode = Math.min(maxMs, Math.max(minMs, spread.modeMs ?? meanMs));
1793
+ const u = this.rng();
1794
+ const span = maxMs - minMs;
1795
+ if (span <= 0) return minMs;
1796
+ const c = (mode - minMs) / span;
1797
+ return u < c ? minMs + Math.sqrt(u * span * (mode - minMs)) : maxMs - Math.sqrt((1 - u) * span * (maxMs - mode));
1798
+ }
1799
+ /** 명세 모수(숫자) — 선언 없으면 undefined(0 으로 꾸미지 않는다). 소비처가 기본값을 정한다. */
1800
+ paramNumber(opKey, id) {
1801
+ const p = this.operationSpecs.get(opKey)?.parameters?.find((x) => x.id === id);
1802
+ if (!p) return void 0;
1803
+ const n = Number(p.value);
1804
+ if (!Number.isFinite(n)) return void 0;
1805
+ this.noteParamUse(opKey, id);
1806
+ return n;
1807
+ }
1808
+ /** 명세 모수(기간) — ISO 8601 문자열을 밀리초로. 선언 없으면 undefined. */
1809
+ paramDuration(opKey, id) {
1810
+ const p = this.operationSpecs.get(opKey)?.parameters?.find((x) => x.id === id);
1811
+ const ms2 = parseIsoDuration(p?.value);
1812
+ if (ms2 !== void 0) this.noteParamUse(opKey, id);
1813
+ return ms2;
1814
+ }
1815
+ noteSpecUse(kind, duration, variability) {
1816
+ const cur = this.specUse.get(kind);
1817
+ if (cur) {
1818
+ cur.duration = duration;
1819
+ if (variability) cur.variability = variability;
1820
+ return;
1821
+ }
1822
+ this.specUse.set(kind, { duration, ...variability ? { variability } : {}, params: /* @__PURE__ */ new Set() });
1823
+ }
1824
+ noteParamUse(kind, id) {
1825
+ const cur = this.specUse.get(kind);
1826
+ if (cur) cur.params.add(id);
1827
+ else this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]) });
1828
+ }
1829
+ /**
1830
+ * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 박아 둔 상수인가.**
1831
+ *
1832
+ * 시뮬레이션 결과를 받는 쪽이 이걸 봐야 한다: 소요시간이 전부 기본값이면 그 예측으로 말할 수 있는 것은
1833
+ * "같은 조건에서의 상대 비교" 뿐이고 "몇 시에 끝난다" 는 근거가 없다. 그 구분을 숫자로 드러낸다.
1834
+ */
1835
+ specCoverage() {
1836
+ const operations = [...this.specUse.entries()].map(([kind, u]) => {
1837
+ const spec = this.operationSpecs.get(kind);
1838
+ const variability = u.variability ?? spec?.variability?.distribution;
1839
+ return {
1840
+ kind,
1841
+ duration: u.duration,
1842
+ ...variability ? { variability } : {},
1843
+ parameters: [...u.params].sort()
1844
+ };
1845
+ });
1846
+ return {
1847
+ operations,
1848
+ measuredDurations: operations.filter((o) => o.duration === "measured").length,
1849
+ declaredDurations: operations.filter((o) => o.duration === "declared").length,
1850
+ defaultDurations: operations.filter((o) => o.duration === "default").length
1851
+ };
1206
1852
  }
1207
1853
  /** skuMix 에서 weight 로 gtin 선택(rng) — 도착/오더 자극의 품목 결정. */
1208
1854
  pickGtin(mix) {
@@ -1250,6 +1896,53 @@ var FlowEngine = class {
1250
1896
  bizTransactionList: opts.bizTransactionList
1251
1897
  }));
1252
1898
  }
1899
+ /**
1900
+ * 할당(예약) — **처분 변화를 이벤트로 낸다.**
1901
+ *
1902
+ * 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
1903
+ * 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
1904
+ * 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
1905
+ *
1906
+ * 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
1907
+ * 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
1908
+ *
1909
+ * `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
1910
+ * 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
1911
+ */
1912
+ reserve(epcs, bizStep) {
1913
+ this.observeDisposition(epcs, DISP.reserved, bizStep);
1914
+ }
1915
+ /**
1916
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
1917
+ *
1918
+ * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
1919
+ * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
1920
+ *
1921
+ * 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
1922
+ * 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
1923
+ */
1924
+ observeDisposition(epcs, disposition, bizStep, at) {
1925
+ const byLocation = /* @__PURE__ */ new Map();
1926
+ for (const epc of epcs) {
1927
+ const it = this.items.get(epc);
1928
+ if (!it || it.disposition === disposition) continue;
1929
+ it.disposition = disposition;
1930
+ const where = at ?? it.location ?? "";
1931
+ const bin = byLocation.get(where);
1932
+ if (bin) bin.push(epc);
1933
+ else byLocation.set(where, [epc]);
1934
+ }
1935
+ for (const [where, list] of byLocation) {
1936
+ this.emit(objectEvent({
1937
+ eventTime: this.now(),
1938
+ action: "OBSERVE",
1939
+ bizStep,
1940
+ disposition,
1941
+ epcList: list,
1942
+ ...where ? { readPoint: where, bizLocation: where } : {}
1943
+ }));
1944
+ }
1945
+ }
1253
1946
  /**
1254
1947
  * containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
1255
1948
  * consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
@@ -1267,6 +1960,11 @@ var FlowEngine = class {
1267
1960
  this.items.delete(c);
1268
1961
  }
1269
1962
  }
1963
+ return;
1964
+ }
1965
+ for (const c of children) {
1966
+ const it = this.items.get(c);
1967
+ if (it) it.parent = parent;
1270
1968
  }
1271
1969
  }
1272
1970
  /**
@@ -1276,6 +1974,10 @@ var FlowEngine = class {
1276
1974
  */
1277
1975
  disaggregate(parent, children, opts) {
1278
1976
  this.emit(aggregationEvent({ eventTime: this.now(), action: "DELETE", bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
1977
+ for (const c of children) {
1978
+ const it = this.items.get(c);
1979
+ if (it) it.parent = void 0;
1980
+ }
1279
1981
  if (opts.materialize) {
1280
1982
  const m = opts.materialize;
1281
1983
  for (const c of children) {
@@ -1316,19 +2018,239 @@ var FlowEngine = class {
1316
2018
  const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
1317
2019
  for (const h of this.handlers) h(e);
1318
2020
  }
2021
+ /**
2022
+ * 작업 전이 방출 — **커널이 아는 것을 미러도 알게** 한다.
2023
+ *
2024
+ * 예전에는 진척·남은 시간·의도를 싣지 않아, 미러 상태를 씨앗으로 한 예측이 "진행 중인 일이 없는
2025
+ * 현장" 에서 출발했고, 소비처는 무자원 체류를 기록 누락으로 오해할 수밖에 없었다.
2026
+ * 진척은 진행 중일 때만 뜻이 있으므로 그때만 싣는다(생성·완료 시점의 0/1 은 노이즈).
2027
+ */
1319
2028
  emitTask(t) {
1320
- this.emitOp(OP_EVENT.task, { taskId: t.id, orderId: t.orderId, kind: t.kind, status: t.status, fromNode: t.fromNode, toNode: t.toNode, itemRefs: [t.itemEpc], resourceRef: t.resource ?? void 0 });
2029
+ const inProgress = t.status === "in-progress";
2030
+ const done = Math.max(0, (t.durationMs ?? 0) - (t.remainingMs ?? 0));
2031
+ this.emitOp(OP_EVENT.task, {
2032
+ taskId: t.id,
2033
+ orderId: t.orderId,
2034
+ kind: t.kind,
2035
+ status: t.status,
2036
+ fromNode: t.fromNode,
2037
+ toNode: t.toNode,
2038
+ itemRefs: [t.itemEpc],
2039
+ resourceRef: t.resource ?? void 0,
2040
+ intent: t.intent,
2041
+ ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
2042
+ ...t.assets?.length ? { assets: t.assets.slice() } : {},
2043
+ ...t.durationMs ? { durationMs: t.durationMs } : {},
2044
+ ...inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
2045
+ });
2046
+ }
2047
+ /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
2048
+ emitAsset(a) {
2049
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClass: a.assetClass, status: a.status, location: a.location, taskId: a.taskId ?? void 0, carrying: a.carrying });
1321
2050
  }
2051
+ emitPerson(p) {
2052
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0, ...this.personOffShift(p) ? { offShift: true } : {} });
2053
+ }
2054
+ /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
2055
+ * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
1322
2056
  emitMover(m, motion) {
1323
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion });
2057
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? void 0, motion });
1324
2058
  }
1325
2059
  emitOrder(o) {
1326
2060
  this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held });
1327
2061
  }
1328
2062
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
2063
+ /**
2064
+ * 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
2065
+ *
2066
+ * 예전에는 `poisson` 만 구현하고 나머지는 전부 상수로 떨어졌다. 계약이 `uniform`·`profile` 을
2067
+ * 선언하고 있었으므로, 그것을 지정한 사람은 자기가 요청한 분포로 도는 줄 알았다 — **조용한 거짓**이다.
2068
+ *
2069
+ * constant 간격이 일정(평균 그대로)
2070
+ * poisson 무기억 도착(지수 간격) — 평균 유지
2071
+ * uniform 0..2×평균 균등 — 평균을 유지하면서 흔들린다(교과서적 U(0,2μ))
2072
+ * profile 시간대별 배율(`profile[시]`)로 도착률을 조절 — 하루 안의 수요 곡선
2073
+ */
1329
2074
  intervalMs(spec) {
1330
- const base = 36e5 / spec.rate.meanPerHour;
1331
- return spec.rate.distribution === "poisson" ? -Math.log(1 - this.rng()) * base : base;
2075
+ const perHour = spec.rate.meanPerHour * this.profileFactor(spec.rate);
2076
+ if (!(perHour > 0)) return Number.POSITIVE_INFINITY;
2077
+ const base = 36e5 / perHour;
2078
+ switch (spec.rate.distribution) {
2079
+ case "poisson":
2080
+ return -Math.log(1 - this.rng()) * base;
2081
+ case "uniform":
2082
+ return this.rng() * 2 * base;
2083
+ case "profile":
2084
+ case "constant":
2085
+ default:
2086
+ return base;
2087
+ }
2088
+ }
2089
+ /**
2090
+ * 시간대 배율 — `profile[시]`. `profile` 분포일 때만 적용하며, 배열이 짧으면 **순환**한다
2091
+ * (24개면 하루, 8개면 8시간 주기). 미지정·다른 분포면 1(무영향).
2092
+ * 시(hour)는 **시뮬 시각 자신의 프레임**(BASE_EPOCH 기준 UTC)이다 — 계약에 표준시가 없으므로
2093
+ * 현지 시간대 해석은 아직 하지 않는다(꾸미지 않는다).
2094
+ */
2095
+ /**
2096
+ * 다음 발화 시각 — **발화가 없는 시간대를 영원한 침묵으로 만들지 않는다.**
2097
+ *
2098
+ * 배율 0(그 시간대 도착 없음)이면 간격이 무한이 된다. 그것을 그대로 예약하면 이후 어떤 시간대가
2099
+ * 와도 깨어나지 않는다 — 그래서 **다음 정시로 미뤄 다시 판정**한다(시간대가 바뀌면 배율도 바뀐다).
2100
+ * 시나리오 시작과 구동 루프가 같은 규칙을 쓰도록 한 곳에 둔다(예전에는 시작 경로만 따로였다).
2101
+ */
2102
+ nextFireMs(spec, from) {
2103
+ const interval = this.intervalMs(spec);
2104
+ if (Number.isFinite(interval)) return from + interval;
2105
+ const hourMs = 36e5;
2106
+ return Math.floor(from / hourMs) * hourMs + hourMs;
2107
+ }
2108
+ profileFactor(rate) {
2109
+ if (rate.distribution !== "profile") return 1;
2110
+ const p = rate.profile;
2111
+ if (!p?.length) return 1;
2112
+ const f = p[this.hourOfDay() % p.length];
2113
+ return Number.isFinite(f) && f >= 0 ? f : 1;
2114
+ }
2115
+ /**
2116
+ * 필요 물리 자산을 확보한다 — 인원과 **같은 규칙**(등급으로 요구, 부분 투입 없음, 확정은 나중).
2117
+ * 자산은 사람과 달리 교대가 없고 **자리**가 있다(빈 팔레트가 어디 있는지가 다음 문제이지만,
2118
+ * 지금은 자리를 따지지 않는다 — 따지려면 자산 이송 작업이 먼저 있어야 한다).
2119
+ */
2120
+ claimAssets(t) {
2121
+ const need = this.operationSpecs.get(t.kind)?.physicalAssetSpecification;
2122
+ if (!need?.length) return [];
2123
+ const picked = [];
2124
+ for (const req of need) {
2125
+ const want = Math.max(0, Math.floor(req.quantity ?? 0));
2126
+ if (!want) continue;
2127
+ const avail = [...this.assets.values()].filter(
2128
+ (a) => a.status === "idle" && !picked.includes(a.id) && (req.assetClass === void 0 || a.assetClass === req.assetClass)
2129
+ );
2130
+ if (avail.length < want) return null;
2131
+ for (let i = 0; i < want; i++) picked.push(avail[i].id);
2132
+ }
2133
+ return picked;
2134
+ }
2135
+ /** 확보한 자산을 작업에 묶는다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
2136
+ assignAssets(t, gear) {
2137
+ if (!gear.length) return;
2138
+ t.assets = gear;
2139
+ for (const id of gear) {
2140
+ const a = this.assets.get(id);
2141
+ if (!a) continue;
2142
+ a.status = "in-use";
2143
+ a.taskId = t.id;
2144
+ if (t.itemEpc) {
2145
+ a.carrying = t.itemEpc;
2146
+ const it = this.items.get(t.itemEpc);
2147
+ if (it) it.carriedBy = a.id;
2148
+ }
2149
+ this.emitAsset(a);
2150
+ }
2151
+ }
2152
+ /**
2153
+ * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
2154
+ * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
2155
+ */
2156
+ releaseAssets(t) {
2157
+ for (const id of t.assets ?? []) {
2158
+ const a = this.assets.get(id);
2159
+ if (!a) continue;
2160
+ a.status = "idle";
2161
+ a.taskId = null;
2162
+ a.location = t.toNode || a.location;
2163
+ if (a.carrying) {
2164
+ const it = this.items.get(a.carrying);
2165
+ if (it) it.carriedBy = void 0;
2166
+ }
2167
+ a.carrying = void 0;
2168
+ this.emitAsset(a);
2169
+ }
2170
+ }
2171
+ /**
2172
+ * 필요 인원을 확보한다 — **등급으로 요구하고 등급으로 고른다**(특정인 지목이 아니다).
2173
+ * 요구가 없으면 빈 배열, 모자라면 `null`(작업은 기다린다 — **부분 투입으로 시작하지 않는다**).
2174
+ * 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
2175
+ * (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
2176
+ */
2177
+ claimPersonnel(t) {
2178
+ const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
2179
+ if (!need?.length) return [];
2180
+ const picked = [];
2181
+ for (const req of need) {
2182
+ const want = Math.max(0, Math.floor(req.quantity ?? 0));
2183
+ if (!want) continue;
2184
+ const avail = [...this.persons.values()].filter(
2185
+ (p) => p.status === "idle" && !picked.includes(p.id) && !this.personOffShift(p) && (req.personnelClass === void 0 || p.personnelClass === req.personnelClass)
2186
+ );
2187
+ if (avail.length < want) return null;
2188
+ for (let i = 0; i < want; i++) picked.push(avail[i].id);
2189
+ }
2190
+ return picked;
2191
+ }
2192
+ /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
2193
+ assignCrew(t, crew) {
2194
+ if (!crew.length) return;
2195
+ t.personnel = crew;
2196
+ for (const id of crew) {
2197
+ const p = this.persons.get(id);
2198
+ if (!p) continue;
2199
+ p.status = "busy";
2200
+ p.taskId = t.id;
2201
+ this.emitPerson(p);
2202
+ }
2203
+ }
2204
+ /** 작업이 끝나면 사람을 놓아 준다 — 설비 해제와 별개 경로. */
2205
+ releaseCrew(t) {
2206
+ for (const id of t.personnel ?? []) {
2207
+ const p = this.persons.get(id);
2208
+ if (!p) continue;
2209
+ p.status = "idle";
2210
+ p.taskId = null;
2211
+ this.emitPerson(p);
2212
+ }
2213
+ }
2214
+ /** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
2215
+ personOffShift(p) {
2216
+ const w = p.window;
2217
+ if (!w) return false;
2218
+ const h = this.hourOfDay();
2219
+ return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
2220
+ }
2221
+ /**
2222
+ * 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
2223
+ * 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
2224
+ */
2225
+ stationFull(nodeId) {
2226
+ if (!nodeId) return false;
2227
+ const limit = this.nodes.get(nodeId)?.parallelism;
2228
+ if (!(typeof limit === "number" && limit > 0)) return false;
2229
+ let running = 0;
2230
+ for (const t of this.tasks.values()) if (t.status === "in-progress" && t.toNode === nodeId) running++;
2231
+ return running >= limit;
2232
+ }
2233
+ /** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
2234
+ offShift(m) {
2235
+ const w = m.window;
2236
+ if (!w) return false;
2237
+ const h = this.hourOfDay();
2238
+ return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
2239
+ }
2240
+ /** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
2241
+ hourOfDay() {
2242
+ return new Date(BASE_EPOCH + this.clockMs).getUTCHours();
2243
+ }
2244
+ /**
2245
+ * 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
2246
+ * `startHour <= endHour` 면 같은 날 구간, 넘어가면 자정을 가로지르는 구간(야간 교대: 22→6).
2247
+ * 미지정이면 언제나 참(24시간 가동).
2248
+ */
2249
+ inWindow(spec) {
2250
+ const w = spec.window;
2251
+ if (!w) return true;
2252
+ const h = this.hourOfDay();
2253
+ return w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour;
1332
2254
  }
1333
2255
  /** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
1334
2256
  sampleExp(meanMs) {
@@ -1363,32 +2285,89 @@ var FlowEngine = class {
1363
2285
  }
1364
2286
  }
1365
2287
  }
2288
+ /**
2289
+ * 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
2290
+ *
2291
+ * 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
2292
+ * 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
2293
+ * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
2294
+ */
2295
+ itemState(i) {
2296
+ const parsedClass = i.gtin ? parseEpc(i.gtin) : void 0;
2297
+ const parsedSelf = parseEpc(i.epc);
2298
+ const lot = parsedClass?.lot ?? parsedSelf.lot ?? (typeof i.ilmd?.[ILMD_ATTR.lot] === "string" ? i.ilmd[ILMD_ATTR.lot] : void 0);
2299
+ const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
2300
+ return {
2301
+ epc: i.epc,
2302
+ ...i.gtin ? { gtin: i.gtin } : {},
2303
+ ...gtinKey ? { gtinKey } : {},
2304
+ ...lot ? { lot } : {},
2305
+ location: i.location,
2306
+ ...i.disposition ? { disposition: i.disposition } : {},
2307
+ ...i.parent ? { parent: i.parent } : {},
2308
+ ...i.carriedBy ? { carriedBy: i.carriedBy } : {},
2309
+ ...i.qty !== void 0 ? { qty: i.qty } : {},
2310
+ ...i.uom ? { uom: i.uom } : {},
2311
+ ...i.expiry !== void 0 ? { expiry: i.expiry } : {},
2312
+ ...i.ilmd ? { ilmd: i.ilmd } : {}
2313
+ };
2314
+ }
1366
2315
  progressOf(t) {
1367
2316
  return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
1368
2317
  }
1369
2318
  generate() {
1370
2319
  for (const g of this.gens) {
1371
2320
  while (this.clockMs >= g.nextMs) {
2321
+ const next = this.nextFireMs(g.spec, g.nextMs);
2322
+ if (this.intervalMs(g.spec) === Number.POSITIVE_INFINITY) {
2323
+ g.nextMs = next;
2324
+ continue;
2325
+ }
2326
+ if (!this.inWindow(g.spec)) {
2327
+ g.nextMs = next;
2328
+ continue;
2329
+ }
1372
2330
  const stimulus = g.spec.stimulus ?? (g.spec.kind === "outbound-order" ? "order" : "arrival");
1373
2331
  if (stimulus === "order") this.onOrder(g.spec);
1374
2332
  else this.onArrival(g.spec);
1375
- g.nextMs += this.intervalMs(g.spec);
2333
+ g.nextMs = next;
1376
2334
  }
1377
2335
  }
1378
2336
  }
1379
2337
  processOrders() {
1380
2338
  for (const o of this.orders.values()) if (o.status === "created" && !o.held) this.allocate(o);
1381
2339
  }
2340
+ /**
2341
+ * 작업 진행 — **진행을 먼저, 배정을 나중에.**
2342
+ *
2343
+ * 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
2344
+ * 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
2345
+ * tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
2346
+ */
1382
2347
  processTasks(dt) {
2348
+ this.advanceTasks(dt);
2349
+ this.assignTasks();
2350
+ }
2351
+ assignTasks() {
1383
2352
  for (const t of this.tasks.values()) {
1384
2353
  if (t.status !== "created") continue;
2354
+ const crew = this.claimPersonnel(t);
2355
+ if (crew === null) continue;
2356
+ const gear = this.claimAssets(t);
2357
+ if (gear === null) continue;
1385
2358
  if (t.intent === "dwell") {
1386
2359
  t.status = "in-progress";
1387
2360
  t.remainingMs = t.durationMs;
2361
+ t.startedAtSimMs = this.clockMs;
2362
+ this.assignCrew(t, crew);
2363
+ this.assignAssets(t, gear);
1388
2364
  this.emitTask(t);
1389
2365
  continue;
1390
2366
  }
1391
- const mover = [...this.movers.values()].find((m) => m.status === "idle" && !m.held && (t.resourceType === void 0 || m.kind === t.resourceType));
2367
+ if (this.stationFull(t.toNode)) continue;
2368
+ const mover = [...this.movers.values()].find(
2369
+ (m) => m.status === "idle" && !m.held && !this.offShift(m) && (t.resourceType === void 0 || m.kind === t.resourceType)
2370
+ );
1392
2371
  if (!mover) continue;
1393
2372
  if (t.setupMs && t.changeoverKey !== void 0 && mover.lastChangeoverKey !== void 0 && mover.lastChangeoverKey !== t.changeoverKey) {
1394
2373
  t.appliedSetupMs = t.setupMs;
@@ -1400,10 +2379,16 @@ var FlowEngine = class {
1400
2379
  t.status = "in-progress";
1401
2380
  t.resource = mover.id;
1402
2381
  t.remainingMs = t.durationMs;
2382
+ t.startedAtSimMs = this.clockMs;
2383
+ this.assignCrew(t, crew);
2384
+ this.assignAssets(t, gear);
1403
2385
  this.emitTask(t);
1404
2386
  if (t.intent === "process") this.emitMover(mover);
1405
2387
  else this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
1406
2388
  }
2389
+ }
2390
+ /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
2391
+ advanceTasks(dt) {
1407
2392
  for (const t of this.tasks.values()) {
1408
2393
  if (t.status !== "in-progress") continue;
1409
2394
  if (t.resource && this.movers.get(t.resource)?.status === "down") continue;
@@ -1411,6 +2396,8 @@ var FlowEngine = class {
1411
2396
  if (t.remainingMs > 0) continue;
1412
2397
  this.onTaskComplete(t);
1413
2398
  t.status = "completed";
2399
+ this.releaseCrew(t);
2400
+ this.releaseAssets(t);
1414
2401
  if (!t.resource) {
1415
2402
  this.emitTask(t);
1416
2403
  continue;
@@ -1450,11 +2437,23 @@ var WmsKernel = class extends FlowEngine {
1450
2437
  const qtyList = [{ epcClass: gtin, quantity: qty }];
1451
2438
  const poTxn = [{ type: BTT.po, bizTransaction: po }];
1452
2439
  const expiry = this.clockMs + SHELF_MS - this.epcSeq % 5 * SHELF_JITTER_MS;
1453
- this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry });
2440
+ const ilmd = { [ILMD_ATTR.expiry]: expiry };
2441
+ this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
1454
2442
  dock.occupancy++;
1455
2443
  this.emit(transactionEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
1456
2444
  this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
1457
- this.emit(objectEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, disposition: DISP.in_progress, epcList: [epc], quantityList: qtyList, readPoint: dock.id, bizLocation: dock.id, bizTransactionList: poTxn }));
2445
+ this.emit(objectEvent({
2446
+ eventTime,
2447
+ action: "ADD",
2448
+ bizStep: BIZSTEP.receiving,
2449
+ disposition: DISP.in_progress,
2450
+ epcList: [epc],
2451
+ quantityList: qtyList,
2452
+ readPoint: dock.id,
2453
+ bizLocation: dock.id,
2454
+ bizTransactionList: poTxn,
2455
+ ilmd
2456
+ }));
1458
2457
  const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews("storage") });
1459
2458
  if (!binId) return;
1460
2459
  const id = `task-${++this.taskSeq}`;
@@ -1528,12 +2527,12 @@ var WmsKernel = class extends FlowEngine {
1528
2527
  const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === "storage").map((i) => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
1529
2528
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
1530
2529
  for (const epc of chosen) {
1531
- this.items.get(epc).disposition = DISP.reserved;
1532
2530
  o.allocated.push(epc);
1533
2531
  chosenAll.push(epc);
1534
2532
  }
1535
2533
  }
1536
2534
  if (chosenAll.length === 0) return;
2535
+ this.reserve(chosenAll, BIZSTEP.storing);
1537
2536
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
1538
2537
  for (const epc of chosenAll) {
1539
2538
  const it = this.items.get(epc);
@@ -1677,7 +2676,7 @@ var YmsKernel = class extends FlowEngine {
1677
2676
  if (!staging || avail.length < CARGO_PER_TRAILER) return;
1678
2677
  this.trailerCargo.set(trailer.epc, avail.slice(0, CARGO_PER_TRAILER).map((i) => i.epc));
1679
2678
  }
1680
- trailer.disposition = DISP.reserved;
2679
+ this.reserve([trailer.epc], bizStep);
1681
2680
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep, bizTransactionList: [{ type: BTT_DELIVERY, bizTransaction: o.bizTransaction }], epcList: [trailer.epc], readPoint: door.id }));
1682
2681
  const dockKind = mode === "drop" ? "pull" : "spot-live";
1683
2682
  const task = { id: `task-${++this.taskSeq}`, kind: dockKind, status: "created", itemEpc: trailer.epc, fromNode: trailer.location, toNode: door.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: dockKind, fromNode: trailer.location, toNode: door.id }, TRAVEL_MS2), orderId: o.id };
@@ -1711,7 +2710,7 @@ var YmsKernel = class extends FlowEngine {
1711
2710
  const outbound = order2?.kind === "appointment-out";
1712
2711
  const staging = this.nodeByType("staging");
1713
2712
  const cargo = this.trailerCargo.get(trailer.epc) ?? [];
1714
- this.emit(objectEvent({ eventTime: this.now(), action: "OBSERVE", bizStep: outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, disposition: DISP.in_progress, epcList: [trailer.epc], readPoint: to.id, bizLocation: to.id }));
2713
+ this.observeDisposition([trailer.epc], DISP.in_progress, outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, to.id);
1715
2714
  if (outbound) {
1716
2715
  if (cargo.length && staging) {
1717
2716
  this.aggregate(trailer.epc, cargo, { bizStep: YARD_BIZSTEP.loading, readPoint: to.id, consume: { readPoint: staging.id, disposition: DISP.in_transit } });
@@ -1744,13 +2743,13 @@ var YmsKernel = class extends FlowEngine {
1744
2743
  };
1745
2744
 
1746
2745
  // src/mes-kernel.ts
1747
- var CYCLE_MS = 4e4;
1748
- var SETUP_MS = 15e3;
2746
+ var DEFAULT_CYCLE_MS = 4e4;
2747
+ var DEFAULT_SETUP_MS = 15e3;
1749
2748
  var MES_CMD = { changeover: "mes.changeover" };
1750
2749
  var CP2 = "0614141";
1751
2750
  var WIP_ITEMREF = "066666";
1752
2751
  var WIP_GTIN = sgtinClass(CP2, WIP_ITEMREF);
1753
- var YIELD = 0.8;
2752
+ var DEFAULT_YIELD = 0.8;
1754
2753
  var PART_A = { itemRef: "055551", gtin: sgtinClass(CP2, "055551") };
1755
2754
  var PART_B = { itemRef: "055552", gtin: sgtinClass(CP2, "055552") };
1756
2755
  var PRODUCTS = [
@@ -1774,6 +2773,7 @@ var MesKernel = class extends FlowEngine {
1774
2773
  constructor(tenantId, policy = firstFitPolicy, mesSpec) {
1775
2774
  super(tenantId, policy);
1776
2775
  this.mesSpec = mesSpec;
2776
+ if (mesSpec?.definition?.operations) this.loadOperations(mesSpec.definition.operations);
1777
2777
  }
1778
2778
  productOf(gtin) {
1779
2779
  return PRODUCTS.find((p) => p.gtin === gtin);
@@ -1781,7 +2781,7 @@ var MesKernel = class extends FlowEngine {
1781
2781
  /**
1782
2782
  * MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
1783
2783
  * 자동 체인지오버(task.changeoverKey 상이 시 셋업)의 수동 버전 — 운영자가 사전 전환(툴링 교체) 지시.
1784
- * 이미 그 제품이면 no-op, 아니면 셋업(SETUP_MS, OEE 가용성 손실) + lastChangeoverKey 각인
2784
+ * 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
1785
2785
  * (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
1786
2786
  */
1787
2787
  handleCommand(cmd) {
@@ -1791,7 +2791,7 @@ var MesKernel = class extends FlowEngine {
1791
2791
  const m = this.movers.get(a.resourceId);
1792
2792
  if (!m) return { commandId: cmd.commandId, accepted: false, errorCode: "resource-not-found", errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
1793
2793
  if (m.lastChangeoverKey !== a.gtin) {
1794
- m.setupMs += SETUP_MS;
2794
+ m.setupMs += DEFAULT_SETUP_MS;
1795
2795
  m.lastChangeoverKey = a.gtin;
1796
2796
  this.emitMover(m);
1797
2797
  }
@@ -1836,10 +2836,8 @@ var MesKernel = class extends FlowEngine {
1836
2836
  if (chosen.length < line.qty) return;
1837
2837
  picks.push(...chosen);
1838
2838
  }
1839
- for (const epc of picks) {
1840
- this.items.get(epc).disposition = DISP.reserved;
1841
- o.allocated.push(epc);
1842
- }
2839
+ for (const epc of picks) o.allocated.push(epc);
2840
+ this.reserve(picks, MES_BIZSTEP.producing);
1843
2841
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
1844
2842
  this.emitStation(o, s0, o.allocated[0], product.gtin);
1845
2843
  o.status = "op-" + s0.kind;
@@ -1848,7 +2846,7 @@ var MesKernel = class extends FlowEngine {
1848
2846
  /** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
1849
2847
  emitStation(o, stage, itemEpc, changeoverKey) {
1850
2848
  const node = this.nodeByType(stage.node);
1851
- const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: "created", itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: node.id, toNode: node.id, resourceKind: stage.resource }, CYCLE_MS), orderId: o.id, resourceType: stage.resource, changeoverKey, setupMs: SETUP_MS, intent: "process" };
2849
+ const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: "created", itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: node.id, toNode: node.id, resourceKind: stage.resource }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: stage.resource, changeoverKey, setupMs: this.paramDuration(stage.kind, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: "process" };
1852
2850
  this.tasks.set(task.id, task);
1853
2851
  this.emitTask(task);
1854
2852
  }
@@ -1873,7 +2871,7 @@ var MesKernel = class extends FlowEngine {
1873
2871
  }
1874
2872
  const fgStore = this.nodeByType("fg-store");
1875
2873
  const wip = order.allocated[0];
1876
- const good = this.rng() < YIELD;
2874
+ const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
1877
2875
  this.recordOutput(t.resource, good);
1878
2876
  const disp = good ? DISP.sellable : DISP.non_sellable;
1879
2877
  const outputEpc = sgtinUri(CP2, product.ref, ++this.prodSeq);
@@ -1936,10 +2934,8 @@ var MesKernel = class extends FlowEngine {
1936
2934
  if (chosen.length < line.qty) return;
1937
2935
  picks.push(...chosen);
1938
2936
  }
1939
- for (const epc of picks) {
1940
- this.items.get(epc).disposition = DISP.reserved;
1941
- o.allocated.push(epc);
1942
- }
2937
+ for (const epc of picks) o.allocated.push(epc);
2938
+ this.reserve(picks, MES_BIZSTEP.producing);
1943
2939
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
1944
2940
  this.emitStationDef(o, ops[0], o.allocated[0]);
1945
2941
  o.status = "op-" + ops[0].key;
@@ -1947,7 +2943,7 @@ var MesKernel = class extends FlowEngine {
1947
2943
  }
1948
2944
  emitStationDef(o, op, itemEpc) {
1949
2945
  const node = this.nodeByType(op.nodeType);
1950
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: node.id, toNode: node.id, resourceKind: op.resourceType }, CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: SETUP_MS, intent: op.intent };
2946
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: node.id, toNode: node.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
1951
2947
  this.tasks.set(task.id, task);
1952
2948
  this.emitTask(task);
1953
2949
  }
@@ -1974,7 +2970,7 @@ var MesKernel = class extends FlowEngine {
1974
2970
  }
1975
2971
  const fgStore = this.nodeByType("fg-store");
1976
2972
  const wip = order.allocated[0];
1977
- const good = this.rng() < YIELD;
2973
+ const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
1978
2974
  this.recordOutput(t.resource, good);
1979
2975
  const disp = good ? DISP.sellable : DISP.non_sellable;
1980
2976
  const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
@@ -2002,6 +2998,7 @@ var MesKernel = class extends FlowEngine {
2002
2998
  EPCIS_CONTEXT,
2003
2999
  EventJournal,
2004
3000
  FlowEngine,
3001
+ ILMD_ATTR,
2005
3002
  MES_BIZSTEP,
2006
3003
  MES_NODE_TYPES,
2007
3004
  MES_PART_GTINS,
@@ -2009,7 +3006,10 @@ var MesKernel = class extends FlowEngine {
2009
3006
  MES_PRODUCT_GTINS,
2010
3007
  MES_TYPES,
2011
3008
  MesKernel,
3009
+ NODE_SATURATION_NEAR,
2012
3010
  OP_EVENT,
3011
+ OP_PARAM,
3012
+ ObservedReducer,
2013
3013
  StateProjector,
2014
3014
  TwinHistory,
2015
3015
  TwinObserver,
@@ -2035,9 +3035,13 @@ var MesKernel = class extends FlowEngine {
2035
3035
  gdtiUri,
2036
3036
  graiUri,
2037
3037
  ingest,
3038
+ lgtinClass,
2038
3039
  mapRecord,
2039
3040
  monteCarloForecast,
3041
+ nodeStatusOf,
2040
3042
  objectEvent,
3043
+ parseEpc,
3044
+ parseIsoDuration,
2041
3045
  partialFitPolicy,
2042
3046
  replay,
2043
3047
  sgtinClass,