@operato/twin-kernel 0.4.0 → 0.4.2

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.
@@ -118,6 +118,7 @@ __export(index_exports, {
118
118
  transformationEvent: () => transformationEvent,
119
119
  validateDomainDefinition: () => validateDomainDefinition,
120
120
  validateEpcisEvent: () => validateEpcisEvent,
121
+ validateScenario: () => validateScenario,
121
122
  weekdayAt: () => weekdayAt,
122
123
  workingTimeOfWeek: () => workingTimeOfWeek
123
124
  });
@@ -390,8 +391,21 @@ var OP_EVENT = {
390
391
  /** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
391
392
  asset: "asset.status",
392
393
  order: "order.status",
393
- quality: "quality.output"
394
+ quality: "quality.output",
394
395
  // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
396
+ /**
397
+ * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
398
+ *
399
+ * 다른 파생 상태는 상태에서 다시 계산된다(주목 신호 자체가 그렇다). 그런데 "누가 이것을 봤다" 는
400
+ * 계산으로 되살릴 수 없다. 저널에 남기지 않으면 재기동하면 확인해 둔 신호가 다시 빨개지고,
401
+ * 과거를 되짚어도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
402
+ */
403
+ /*
404
+ * 값이 커맨드(`CMD.attentionAck='attention.ack'`)와 겹치지 않게 **과거형**으로 둔다 — 커맨드는
405
+ * "확인해라"(요청)이고 이벤트는 "확인했다"(사실)다. 같은 문자열을 쓰면 저널에서 요청과 사실이
406
+ * 구별되지 않는다.
407
+ */
408
+ attentionAck: "attention.acked"
395
409
  };
396
410
  var CMD = {
397
411
  orderHold: "order.hold",
@@ -419,7 +433,7 @@ function readBoardLocations(def) {
419
433
  }
420
434
  function readBoardEquipment(def) {
421
435
  const d = def;
422
- const list = d.equipment ?? d.equipmentList ?? [];
436
+ const list = d.equipment ?? d.equipmentList ?? d.movers ?? [];
423
437
  return list.map((e) => normalizeHomeLocation(e));
424
438
  }
425
439
  function normalizeHomeLocation(entry) {
@@ -433,6 +447,50 @@ function readBoardAssets(def) {
433
447
  return list.map((a) => normalizeHomeLocation(a));
434
448
  }
435
449
 
450
+ // src/scenario-validate.ts
451
+ var DISTRIBUTIONS = /* @__PURE__ */ new Set(["poisson", "uniform", "constant", "profile"]);
452
+ var isNum = (v) => typeof v === "number" && Number.isFinite(v);
453
+ var bad = (errorCode, errorParams) => ({ ok: false, errorCode, errorParams });
454
+ function validateGenerator(g, at) {
455
+ if (!g || typeof g !== "object") return bad("scenario-generator-invalid", { at });
456
+ if (!g.kind || typeof g.kind !== "string") return bad("scenario-generator-kind-required", { at });
457
+ const rate = g.rate;
458
+ if (!rate || typeof rate !== "object") return bad("scenario-rate-required", { at, kind: g.kind });
459
+ if (!isNum(rate.meanPerHour) || rate.meanPerHour < 0) return bad("scenario-rate-mean-invalid", { at, kind: g.kind });
460
+ if (!DISTRIBUTIONS.has(rate.distribution)) return bad("scenario-rate-distribution-invalid", { at, kind: g.kind, distribution: String(rate.distribution ?? "") });
461
+ if (rate.distribution === "profile" && !Array.isArray(rate.profile)) return bad("scenario-rate-profile-required", { at, kind: g.kind });
462
+ const content = g.content;
463
+ if (!content || typeof content !== "object") return bad("scenario-content-required", { at, kind: g.kind });
464
+ if (!Array.isArray(content.skuMix) || content.skuMix.length === 0) return bad("scenario-sku-mix-required", { at, kind: g.kind });
465
+ for (const s of content.skuMix) {
466
+ if (!s?.gtin || typeof s.gtin !== "string") return bad("scenario-sku-gtin-required", { at, kind: g.kind });
467
+ if (!isNum(s.weight) || s.weight <= 0) return bad("scenario-sku-weight-invalid", { at, kind: g.kind, gtin: String(s.gtin) });
468
+ }
469
+ const q = content.qtyPerLine;
470
+ if (!q || !isNum(q.min) || !isNum(q.max)) return bad("scenario-qty-required", { at, kind: g.kind });
471
+ if (q.min < 0 || q.max < q.min) return bad("scenario-qty-range-invalid", { at, kind: g.kind, min: q.min, max: q.max });
472
+ const l = content.linesPerOrder;
473
+ if (l && (!isNum(l.min) || !isNum(l.max) || l.min < 1 || l.max < l.min)) return bad("scenario-lines-range-invalid", { at, kind: g.kind });
474
+ if (g.stimulus !== void 0 && g.stimulus !== "arrival" && g.stimulus !== "order") {
475
+ return bad("scenario-stimulus-invalid", { at, kind: g.kind, stimulus: String(g.stimulus) });
476
+ }
477
+ return { ok: true };
478
+ }
479
+ function validateScenario(def) {
480
+ if (!def || typeof def !== "object") return bad("scenario-invalid");
481
+ if (def.seed !== void 0 && !isNum(def.seed)) return bad("scenario-seed-invalid");
482
+ if (def.speed !== void 0 && (!isNum(def.speed) || def.speed <= 0)) return bad("scenario-speed-invalid");
483
+ if (def.horizon !== void 0 && (!isNum(def.horizon) || def.horizon < 0)) return bad("scenario-horizon-invalid");
484
+ const gens = def.generators;
485
+ if (gens === void 0) return { ok: true };
486
+ if (!Array.isArray(gens)) return bad("scenario-generators-invalid");
487
+ for (let i = 0; i < gens.length; i++) {
488
+ const r = validateGenerator(gens[i], i);
489
+ if (!r.ok) return r;
490
+ }
491
+ return { ok: true };
492
+ }
493
+
436
494
  // src/domain-definition.ts
437
495
  var OP_PARAM = {
438
496
  /** 양품률(0..1, 무차원). 없으면 커널 기본값 — 기본값을 쓴 사실은 `specCoverage()` 가 밝힌다. */
@@ -858,6 +916,8 @@ var ObservedReducer = class {
858
916
  persons = /* @__PURE__ */ new Map();
859
917
  assets = /* @__PURE__ */ new Map();
860
918
  orders = /* @__PURE__ */ new Map();
919
+ /** 확인해 둔 주목 신호 id — `attention.acked` 이벤트로만 들어온다(계산으로 만들지 않는다). */
920
+ acked = /* @__PURE__ */ new Set();
861
921
  revision = 0;
862
922
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
863
923
  corrections = [];
@@ -1095,6 +1155,11 @@ var ObservedReducer = class {
1095
1155
  this.touchLocation(d.location);
1096
1156
  break;
1097
1157
  }
1158
+ case OP_EVENT.attentionAck: {
1159
+ const d = e.data;
1160
+ if (d?.id) this.acked.add(d.id);
1161
+ return;
1162
+ }
1098
1163
  case OP_EVENT.order: {
1099
1164
  const d = e.data;
1100
1165
  if (this.stale(`order:${d.orderId}`, e)) return;
@@ -1311,7 +1376,8 @@ var ObservedReducer = class {
1311
1376
  assets: [...this.assets.values()].map((a) => ({ ...a, ...this.effectivityPart(a) })),
1312
1377
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
1313
1378
  equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`) })),
1314
- orders: [...this.orders.values()].map((o) => ({ ...o }))
1379
+ orders: [...this.orders.values()].map((o) => ({ ...o })),
1380
+ acked: [...this.acked]
1315
1381
  };
1316
1382
  }
1317
1383
  };
@@ -1372,24 +1438,27 @@ var BTT = {
1372
1438
  po: "urn:epcglobal:cbv:btt:po",
1373
1439
  so: "urn:epcglobal:cbv:btt:so"
1374
1440
  };
1375
- var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship"];
1441
+ var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship", "vas-station"];
1376
1442
  var WMS_TYPES = [
1377
- ...WMS_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1378
- { key: "forklift", role: "equipment", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
1443
+ ...WMS_LOCATION_TYPES.filter((k) => k !== "vas-station").map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1444
+ { key: "vas-station", role: "location", label: "twin.type.vas-station", standardClass: { epcis: "bizLocation", isa95: "WorkCenter" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable", "processable"] },
1445
+ { key: "forklift", role: "equipment", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] },
1446
+ /** 유통가공 작업자·작업대 설비 — 가공을 수행하는 능동 자원(ISA-95 `Equipment`). */
1447
+ { key: "packer", role: "equipment", label: "twin.type.packer", standardClass: { epcis: "object", isa95: "Equipment" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
1379
1448
  ];
1380
1449
 
1381
1450
  // src/capability.ts
1382
1451
  var CAPABILITIES = {
1383
1452
  operable: {
1384
1453
  key: "operable",
1385
- label: "\uC6B4\uC601",
1454
+ label: "twin.capability.operable",
1386
1455
  semantics: "\uB2A5\uB3D9 \uC790\uC6D0\uC758 \uC6B4\uC601 \uC0C1\uD0DC(\uC720\uD734/\uAC00\uB3D9/\uACE0\uC7A5). status \uAD50\uCC28 \uAD00\uC2EC\uC0AC\uB97C \uC5EC\uAE30 \uD558\uB098\uB85C.",
1387
1456
  stateFields: ["status"],
1388
1457
  results: ["statusChanged"]
1389
1458
  },
1390
1459
  storable: {
1391
1460
  key: "storable",
1392
- label: "\uC800\uC7A5",
1461
+ label: "twin.capability.storable",
1393
1462
  semantics: "\uC544\uC774\uD15C\uC744 \uBCF4\uC720\uD558\uB294 \uC704\uCE58 \u2014 \uC810\uC720/\uC6A9\uB7C9. (\uC52C \uAE30\uC81C: Capacity)",
1394
1463
  stateFields: ["occupancy", "capacity"],
1395
1464
  invariants: ["0 <= occupancy <= capacity (capacity>0)"],
@@ -1397,7 +1466,7 @@ var CAPABILITIES = {
1397
1466
  },
1398
1467
  mobile: {
1399
1468
  key: "mobile",
1400
- label: "\uC774\uB3D9",
1469
+ label: "twin.capability.mobile",
1401
1470
  semantics: "\uC790\uC6D0 \uC790\uC2E0\uC774 \uC790\uB9AC \uAC04 \uC774\uB3D9. Transferable(\uC544\uC774\uD15C \uC774\uB3D9)\uACFC \uB2E4\uB984. (\uC52C \uAE30\uC81C: CarrierLine)",
1402
1471
  stateFields: ["location", "motion"],
1403
1472
  models: ["Motion"],
@@ -1405,14 +1474,14 @@ var CAPABILITIES = {
1405
1474
  },
1406
1475
  processable: {
1407
1476
  key: "processable",
1408
- label: "\uAC00\uACF5",
1477
+ label: "twin.capability.processable",
1409
1478
  semantics: "\uBCC0\uD658/\uAC00\uACF5 \uC218\uD589 \u2014 \uC0B0\uCD9C(\uC591\uD488/\uBD88\uB7C9). \uC6B4\uC601 status \uB294 Operable \uC870\uD569. progress \uBC29\uCD9C\uC740 \uD6C4\uC18D.",
1410
1479
  stateFields: ["output"],
1411
1480
  results: ["completed"]
1412
1481
  },
1413
1482
  trackable: {
1414
1483
  key: "trackable",
1415
- label: "\uCD94\uC801",
1484
+ label: "twin.capability.trackable",
1416
1485
  semantics: "\uC624\uB354/\uC544\uC774\uD15C \uC0DD\uC560 \uCD94\uC801 \u2014 \uC0DD\uC560\uB2E8\uACC4(\uB3C4\uBA54\uC778 \uB77C\uBCA8, \uBB34\uBC29\uC5B8)\xB7\uC9C4\uD589\xB7\uBCF4\uB958.",
1417
1486
  stateFields: ["lifecycle", "progress", "held"],
1418
1487
  results: ["lifecycleChanged"]
@@ -2023,6 +2092,7 @@ var FlowEngine = class {
2023
2092
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
2024
2093
  loadBoard(def) {
2025
2094
  this.boardDef = def;
2095
+ if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
2026
2096
  for (const n of readBoardLocations(def)) this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId });
2027
2097
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
2028
2098
  this.materialDefs = new Map((def.materialDefinitions ?? []).filter((d) => d?.id).map((d) => [d.id, d]));
@@ -2075,6 +2145,7 @@ var FlowEngine = class {
2075
2145
  this.revision = from;
2076
2146
  }
2077
2147
  hydrateObserved(snap, orders = []) {
2148
+ for (const id of snap.acked ?? []) this._acked.add(id);
2078
2149
  for (const n of snap.locations) {
2079
2150
  this.locations.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 });
2080
2151
  }
@@ -2296,7 +2367,10 @@ var FlowEngine = class {
2296
2367
  }
2297
2368
  case CMD.attentionAck: {
2298
2369
  const id = cmd.args?.id;
2299
- if (id) this._acked.add(id);
2370
+ if (id) {
2371
+ this._acked.add(id);
2372
+ this.emitOp(OP_EVENT.attentionAck, { id, at: this.now() });
2373
+ }
2300
2374
  return ok();
2301
2375
  }
2302
2376
  // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
@@ -2475,7 +2549,9 @@ var FlowEngine = class {
2475
2549
  ...o.endTime ? { endTime: o.endTime } : {},
2476
2550
  held: o.held
2477
2551
  })),
2478
- attentions: this.computeAttentions()
2552
+ attentions: this.computeAttentions(),
2553
+ /* 확인해 둔 신호 — 스냅샷으로 왕복해야 재기동 후에도 확인 상태가 유지된다. */
2554
+ acked: [...this._acked]
2479
2555
  };
2480
2556
  }
2481
2557
  /*
@@ -2614,6 +2690,15 @@ var FlowEngine = class {
2614
2690
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
2615
2691
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
2616
2692
  */
2693
+ /**
2694
+ * 선언된 오퍼레이션들 — 도메인 커널이 "무엇을 만들 수 있나" 를 물을 수 있게.
2695
+ *
2696
+ * `operationSpecs` 를 도메인이 직접 뒤지지 않게 읽기 창구를 둔다: 저장 형태(맵)가 바뀌어도
2697
+ * 도메인은 몰라야 하고, 도메인이 그 맵에 쓰는 일이 생기면 정의가 권위라는 규약이 깨진다.
2698
+ */
2699
+ declaredOperations() {
2700
+ return [...this.operationSpecs.values()];
2701
+ }
2617
2702
  loadOperations(ops = []) {
2618
2703
  for (const o of ops) if (o?.key) this.operationSpecs.set(o.key, o);
2619
2704
  }
@@ -3682,6 +3767,59 @@ var FlowEngine = class {
3682
3767
  }
3683
3768
  };
3684
3769
 
3770
+ // src/make-to-order.ts
3771
+ function producedByIndex(ops) {
3772
+ const idx = /* @__PURE__ */ new Map();
3773
+ for (const op of ops) {
3774
+ if (op.intent !== "process") continue;
3775
+ for (const m of op.materialSpecification ?? []) {
3776
+ if (m.use !== "produced") continue;
3777
+ if (!m.materialDefinition) continue;
3778
+ if (!idx.has(m.materialDefinition)) idx.set(m.materialDefinition, op);
3779
+ }
3780
+ }
3781
+ return idx;
3782
+ }
3783
+ var consumedOf = (op) => (op.materialSpecification ?? []).filter((m) => m.use === "consumed" && (m.quantity ?? 0) > 0);
3784
+ var outputPerRun = (op, gtin) => {
3785
+ const spec = (op.materialSpecification ?? []).find((m) => m.use === "produced" && m.materialDefinition === gtin);
3786
+ return Math.max(1, spec?.quantity ?? 1);
3787
+ };
3788
+ function planMakeToOrder(gtin, shortQty, ops, stock, locations) {
3789
+ const op = producedByIndex(ops).get(gtin);
3790
+ if (!op) return { steps: [], reason: "not-producible" };
3791
+ const station = op.locationType ? locations.find((l) => l.type === op.locationType) : void 0;
3792
+ if (!station) return { steps: [], reason: "no-station" };
3793
+ const need = consumedOf(op);
3794
+ if (!need.length) {
3795
+ return { steps: [{ step: "process", operation: op.key, at: station.id }] };
3796
+ }
3797
+ const runs = Math.max(1, Math.ceil(shortQty / outputPerRun(op, gtin)));
3798
+ const feeds = [];
3799
+ let short = false;
3800
+ for (const req of need) {
3801
+ const want = (req.quantity ?? 0) * runs;
3802
+ const atStation = stock.filter((s) => s.location === station.id && matches(s, req)).reduce((n, s) => n + s.qty, 0);
3803
+ let missing = want - atStation;
3804
+ if (missing <= 0) continue;
3805
+ for (const s of stock) {
3806
+ if (missing <= 0) break;
3807
+ if (s.location === station.id || !s.sellable || !matches(s, req)) continue;
3808
+ const take = Math.min(s.qty, missing);
3809
+ feeds.push({ step: "feed", gtin: s.gtin, from: s.location, to: station.id, qty: take });
3810
+ missing -= take;
3811
+ }
3812
+ if (missing > 0) short = true;
3813
+ }
3814
+ if (short) return { steps: [], reason: "short-materials" };
3815
+ if (feeds.length) return { steps: feeds, reason: "waiting-feed" };
3816
+ return { steps: [{ step: "process", operation: op.key, at: station.id }] };
3817
+ }
3818
+ function matches(s, req) {
3819
+ if (req.materialDefinition) return s.gtin === req.materialDefinition;
3820
+ return false;
3821
+ }
3822
+
3685
3823
  // src/kernel.ts
3686
3824
  var TRAVEL_MS = 3e4;
3687
3825
  var COMPANY_PREFIX = "0614141";
@@ -3809,7 +3947,7 @@ var WmsKernel = class extends FlowEngine {
3809
3947
  chosenAll.push(epc);
3810
3948
  }
3811
3949
  }
3812
- if (chosenAll.length === 0) return;
3950
+ if (chosenAll.length === 0) return this.makeShortLines(o);
3813
3951
  this.reserve(chosenAll, BIZSTEP.storing);
3814
3952
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
3815
3953
  for (const epc of chosenAll) {
@@ -3824,6 +3962,7 @@ var WmsKernel = class extends FlowEngine {
3824
3962
  }
3825
3963
  /** 태스크 완료 — 이동 반영 후 putaway=storing, pick=picking(+전량 시 pack→stage→ship). */
3826
3964
  onTaskComplete(t) {
3965
+ if (t.intent === "process") return this.onProcessComplete(t);
3827
3966
  const item = this.itemByRef(t.itemEpc);
3828
3967
  if (!item) throw new Error(`task ${t.id}: item "${t.itemEpc}" vanished between the core check and the domain hook`);
3829
3968
  const from = this.locations.get(t.fromNode);
@@ -3831,6 +3970,10 @@ var WmsKernel = class extends FlowEngine {
3831
3970
  from.occupancy--;
3832
3971
  to.occupancy++;
3833
3972
  item.location = to.id;
3973
+ if (t.kind === "feed") {
3974
+ this.emit(objectEvent({ eventTime: this.now(), action: "OBSERVE", bizStep: BIZSTEP.storing, disposition: item.disposition, epcList: [item.epc], quantityList: [{ epcClass: item.gtin, quantity: item.qty }], readPoint: to.id, bizLocation: to.id }));
3975
+ return;
3976
+ }
3834
3977
  if (t.kind === "putaway") {
3835
3978
  item.disposition = DISP.sellable;
3836
3979
  this.emit(objectEvent({ eventTime: this.now(), action: "OBSERVE", bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [item.epc], quantityList: [{ epcClass: item.gtin, quantity: item.qty }], readPoint: to.id, bizLocation: to.id }));
@@ -3843,6 +3986,112 @@ var WmsKernel = class extends FlowEngine {
3843
3986
  order.picked.push(item.epc);
3844
3987
  if (order.picked.length === order.allocated.length) this.finalizeOrder(order, to);
3845
3988
  }
3989
+ // ── 수요가 부르는 생산(유통가공) ────────────────────────────────────────────
3990
+ /**
3991
+ * 부족한 라인을 **만들어서** 채운다 — 부품 이송 → 가공 → 되돌리기 3단 사슬.
3992
+ *
3993
+ * 사슬인 이유는 커널의 두 규칙 때문이다(둘 다 의도된 규칙이라 우회하지 않는다):
3994
+ * 자재는 **작업이 일어나는 자리에** 있어야 소비되고, 산출물은 `in_progress` 로 태어나 **팔 수 있는
3995
+ * 재고가 아니다.** 그래서 부품을 작업대로 옮기고, 가공하고, 나온 것을 보관 자리로 되돌린다.
3996
+ *
3997
+ * **한 오더에 사슬 하나만** 굴린다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
3998
+ * 쌓이고, 그중 하나만 성공한 뒤 나머지는 영원히 재료를 기다린다.
3999
+ */
4000
+ makeShortLines(o) {
4001
+ if (!o.lines?.length) return;
4002
+ if (this.hasOpenMakeChain(o.id)) return;
4003
+ const stock = this.stockLines();
4004
+ const locations = [...this.locations.values()].map((l) => ({ id: l.id, type: l.type }));
4005
+ const ops = this.declaredOperations();
4006
+ for (const line of o.lines) {
4007
+ const have = stock.filter((s) => s.gtin === line.gtin && s.sellable && this.locations.get(s.location)?.type === "storage").reduce((n, s) => n + s.qty, 0);
4008
+ const short = line.requested - have;
4009
+ if (short <= 0) continue;
4010
+ const plan = planMakeToOrder(line.gtin, short, ops, stock, locations);
4011
+ for (const step of plan.steps) {
4012
+ if (step.step === "feed") this.issueFeed(o, step.gtin, step.from, step.to, step.qty);
4013
+ else this.issueProcess(o, step.operation, step.at);
4014
+ }
4015
+ if (plan.steps.length) return;
4016
+ }
4017
+ }
4018
+ /** 이 오더가 굴리고 있는 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
4019
+ hasOpenMakeChain(orderId) {
4020
+ for (const t of this.tasks.values()) {
4021
+ if (t.orderId !== orderId || t.status === "completed") continue;
4022
+ if (t.kind === "feed" || t.intent === "process") return true;
4023
+ if (t.kind === "putaway" && t.orderId === orderId) return true;
4024
+ }
4025
+ return false;
4026
+ }
4027
+ /** 지금 재고 — 판단 함수가 보는 형태로. */
4028
+ stockLines() {
4029
+ return [...this.items.values()].filter((i) => i.gtin).map((i) => ({ gtin: i.gtin, location: i.location, qty: i.qty ?? 1, sellable: i.disposition === DISP.sellable }));
4030
+ }
4031
+ /** 부품을 작업대로 — 팔레트 이동이므로 피킹과 같은 기제다(부분 소비는 코어가 한다). */
4032
+ issueFeed(o, gtin, from, to, qty) {
4033
+ const src = [...this.items.values()].find((i) => i.gtin === gtin && i.location === from && i.disposition === DISP.sellable);
4034
+ if (!src) return;
4035
+ src.disposition = DISP.reserved;
4036
+ this.pushTask(o, "feed", src.epc, from, to);
4037
+ }
4038
+ /** 가공 — 작업 종류를 **오퍼레이션 키**로 둔다(코어가 그 키로 명세를 찾는다). */
4039
+ issueProcess(o, operation, at) {
4040
+ this.pushTask(o, operation, "", at, at, "process");
4041
+ }
4042
+ pushTask(o, kind, itemEpc, from, to, intent) {
4043
+ const id = `task-${++this.taskSeq}`;
4044
+ const task = {
4045
+ id,
4046
+ kind,
4047
+ status: "created",
4048
+ itemEpc,
4049
+ fromNode: from,
4050
+ toNode: to,
4051
+ resource: null,
4052
+ remainingMs: 0,
4053
+ durationMs: this.durationOf({ kind, fromNode: from, toNode: to }, TRAVEL_MS),
4054
+ ...intent ? { intent } : {},
4055
+ ...o ? { orderId: o.id } : {}
4056
+ };
4057
+ this.tasks.set(id, task);
4058
+ this.emitTask(task);
4059
+ }
4060
+ /**
4061
+ * 가공 완료 — 코어가 이미 소비(시작)와 산출(완료 직전)을 실행했다. 남은 일은 만든 것을 **재고로
4062
+ * 들여놓는 것**이다: 산출물은 `in_progress` 로 태어나 팔 수 있는 재고가 아니다.
4063
+ *
4064
+ * ── 왜 팔레트로 묶나 ───────────────────────────────────────────────────────
4065
+ * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 박혀 있다(`품목@자리`). 창고의 출고 경로는
4066
+ * 직렬 물류단위(팔레트 SSCC)를 다루므로, 그 줄을 그대로 출고에 태우면 키를 EPC 로 착각해 조회가
4067
+ * 깨진다(실제로 그렇게 터졌다). 억지로 태우면 EPCIS 에도 **EPC 가 아닌 문자열**이 `epcList` 로
4068
+ * 나가는데, 그것은 코어가 산출에서 경계한 바로 그 일이다.
4069
+ *
4070
+ * 그래서 만든 물건을 **입고가 하는 것과 같은 방식**으로 들여놓는다: 팔레트(SSCC)를 만들고 그 안에
4071
+ * 세트 N개를 담는다(AggregationEvent). 현장에서도 가공물은 팔레트에 실려 보관으로 간다.
4072
+ * 그 뒤는 기존 경로 그대로다 — `putaway` 로 보관 자리에 넣으면 판매 가능이 된다.
4073
+ */
4074
+ onProcessComplete(t) {
4075
+ const made = (t.materialActual ?? []).filter((m) => m.use === "produced");
4076
+ const order = t.orderId ? this.orders.get(t.orderId) : void 0;
4077
+ const at = t.toNode;
4078
+ for (const m of made) {
4079
+ const key = subLotIdOf(m.definitionId, at);
4080
+ const row = this.items.get(key);
4081
+ if (!row) continue;
4082
+ const qty = row.qty ?? 0;
4083
+ if (qty <= 0) continue;
4084
+ this.items.delete(key);
4085
+ const pallet = ssccUri(COMPANY_PREFIX, ++this.epcSeq);
4086
+ const qtyList = [{ epcClass: m.definitionId, quantity: qty }];
4087
+ this.items.set(pallet, { epc: pallet, gtin: m.definitionId, qty, location: at, disposition: DISP.in_progress });
4088
+ const eventTime = this.now();
4089
+ this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.packing, parentID: pallet, childQuantityList: qtyList, readPoint: at }));
4090
+ this.emit(objectEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.packing, disposition: DISP.in_progress, epcList: [pallet], quantityList: qtyList, readPoint: at, bizLocation: at }));
4091
+ const storage = this.locationByType("storage");
4092
+ if (storage) this.pushTask(order, "putaway", pallet, at, storage.id);
4093
+ }
4094
+ }
3846
4095
  /** 전량 피킹 → packing(조립)·staging·shipping 마감. 화물 사이트 이탈, 백오더 잔량 재할당. */
3847
4096
  finalizeOrder(order, staging) {
3848
4097
  const shipDock = this.locationByType("dock-ship") ?? staging;
@@ -4067,13 +4316,18 @@ var MesKernel = class extends FlowEngine {
4067
4316
  wipSeq = 0;
4068
4317
  prodSeq = 0;
4069
4318
  /** 정의-구동 모드(선택). 미지정 시 레거시 하드코딩 경로 — byte-identical. */
4070
- mesSpec;
4071
- constructor(tenantId, policy = firstFitPolicy, mesSpec) {
4319
+ productionSpec;
4320
+ constructor(tenantId, policy = firstFitPolicy, productionSpec) {
4072
4321
  super(tenantId, policy);
4073
- this.mesSpec = mesSpec;
4074
- if (mesSpec?.definition?.operations) {
4075
- this.loadOperations(mesSpec.definition.operations);
4076
- this.assertNoDoubleProduction(mesSpec.definition.operations);
4322
+ this.productionSpec = productionSpec;
4323
+ if (productionSpec?.definition?.operations) {
4324
+ this.loadOperations(productionSpec.definition.operations);
4325
+ this.assertNoDoubleProduction(productionSpec.definition.operations);
4326
+ }
4327
+ if (productionSpec?.definition?.recipes?.length && (!productionSpec.binding || !productionSpec.companyPrefix)) {
4328
+ throw new Error(
4329
+ "recipe-driven production needs `binding` and `companyPrefix` \u2014 the definition does not know GTINs, so material keys cannot be resolved to GS1 identifiers"
4330
+ );
4077
4331
  }
4078
4332
  }
4079
4333
  /**
@@ -4089,10 +4343,10 @@ var MesKernel = class extends FlowEngine {
4089
4343
  * 모든 계산이 거짓이 된다).
4090
4344
  */
4091
4345
  assertNoDoubleProduction(ops) {
4092
- const bad = ops.filter((o) => (o.materialSpecification ?? []).some((m) => m.use === "produced")).map((o) => o.key);
4093
- if (!bad.length) return;
4346
+ const bad2 = ops.filter((o) => (o.materialSpecification ?? []).some((m) => m.use === "produced")).map((o) => o.key);
4347
+ if (!bad2.length) return;
4094
4348
  throw new Error(
4095
- `MES recipe already produces outputs \u2014 operations [${bad.join(", ")}] must not also declare materialSpecification use:'produced' (that would create the same output twice). Consumption specs are fine; declare production in the recipe.`
4349
+ `MES recipe already produces outputs \u2014 operations [${bad2.join(", ")}] must not also declare materialSpecification use:'produced' (that would create the same output twice). Consumption specs are fine; declare production in the recipe.`
4096
4350
  );
4097
4351
  }
4098
4352
  productOf(gtin) {
@@ -4121,7 +4375,7 @@ var MesKernel = class extends FlowEngine {
4121
4375
  }
4122
4376
  /** 부품 수령(다품종) — skuMix 의 gtin 으로 부품 종류 결정. */
4123
4377
  onArrival(spec) {
4124
- if (this.mesSpec) return this.onArrivalDef(spec);
4378
+ if (this.productionSpec) return this.onArrivalDef(spec);
4125
4379
  const rawStore = this.locationByType("raw-store");
4126
4380
  if (!rawStore) return;
4127
4381
  const gtin = this.pickGtin(spec.content.skuMix);
@@ -4134,7 +4388,7 @@ var MesKernel = class extends FlowEngine {
4134
4388
  }
4135
4389
  /** 작업지시 — 제품 2종 교대(체인지오버 유발). 제품 gtin 을 오더에 기록. */
4136
4390
  onOrder(_spec) {
4137
- if (this.mesSpec) return this.onOrderDef(_spec);
4391
+ if (this.productionSpec) return this.onOrderDef(_spec);
4138
4392
  const product = PRODUCTS[this.orderSeq % PRODUCTS.length];
4139
4393
  const id = `order-${++this.orderSeq}`;
4140
4394
  const wo = gdtiUri(CP2, "403", ++this.soSeq);
@@ -4144,7 +4398,7 @@ var MesKernel = class extends FlowEngine {
4144
4398
  }
4145
4399
  /** 할당 — 제품 BOM 각 라인의 부품 예약(하나라도 부족하면 대기) + 라우트 첫 스테이션(절단) 태스크. */
4146
4400
  allocate(o) {
4147
- if (this.mesSpec) return this.allocateDef(o);
4401
+ if (this.productionSpec) return this.allocateDef(o);
4148
4402
  const s0 = ROUTE[0];
4149
4403
  const first = this.locationByType(s0.locationType);
4150
4404
  const product = this.productOf(o.gtin);
@@ -4179,12 +4433,12 @@ var MesKernel = class extends FlowEngine {
4179
4433
  * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
4180
4434
  */
4181
4435
  canComplete(t) {
4182
- if (this.mesSpec) return !!(t.orderId && this.orders.get(t.orderId));
4436
+ if (this.productionSpec) return !!(t.orderId && this.orders.get(t.orderId));
4183
4437
  const order = t.orderId ? this.orders.get(t.orderId) : void 0;
4184
4438
  return !!order && !!this.productOf(order.gtin);
4185
4439
  }
4186
4440
  onTaskComplete(t) {
4187
- if (this.mesSpec) return this.onTaskCompleteDef(t);
4441
+ if (this.productionSpec) return this.onTaskCompleteDef(t);
4188
4442
  const order = this.orders.get(t.orderId);
4189
4443
  const product = order && this.productOf(order.gtin);
4190
4444
  if (!order || !product) throw new Error(`task ${t.id}: order/product vanished between the core check and the domain hook`);
@@ -4217,15 +4471,15 @@ var MesKernel = class extends FlowEngine {
4217
4471
  }
4218
4472
  // ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
4219
4473
  recipeDef() {
4220
- const d = this.mesSpec.definition;
4221
- return this.mesSpec.recipeKey ? d.recipes?.find((r) => r.key === this.mesSpec.recipeKey) : d.recipes?.[0];
4474
+ const d = this.productionSpec.definition;
4475
+ return this.productionSpec.recipeKey ? d.recipes?.find((r) => r.key === this.productionSpec.recipeKey) : d.recipes?.[0];
4222
4476
  }
4223
4477
  /** 자재 키 → 구체 gtin 클래스(idpat). 구체 식별은 바인딩+prefix 로 인스턴스가 주입. */
4224
4478
  classOf(materialKey) {
4225
- return sgtinClass(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey]);
4479
+ return sgtinClass(this.productionSpec.companyPrefix, this.productionSpec.binding[materialKey]);
4226
4480
  }
4227
4481
  serialOf(materialKey, serial) {
4228
- return sgtinUri(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey], serial);
4482
+ return sgtinUri(this.productionSpec.companyPrefix, this.productionSpec.binding[materialKey], serial);
4229
4483
  }
4230
4484
  /**
4231
4485
  * 라우트를 용량 계산에 알려 준다 — 수율을 거슬러 올릴 때 순서가 곧 계산이다.
@@ -4234,13 +4488,13 @@ var MesKernel = class extends FlowEngine {
4234
4488
  * 틀린다). 생산 정의를 가진 커널만 이 답을 안다.
4235
4489
  */
4236
4490
  routeKeys() {
4237
- if (!this.mesSpec) return void 0;
4238
- const d = this.mesSpec.definition;
4491
+ if (!this.productionSpec) return void 0;
4492
+ const d = this.productionSpec.definition;
4239
4493
  return d.routes?.find((r) => r.key === this.recipeDef().route)?.steps;
4240
4494
  }
4241
4495
  /** recipe.route → 오퍼레이션 시퀀스 해소. */
4242
4496
  routeOps() {
4243
- const d = this.mesSpec.definition;
4497
+ const d = this.productionSpec.definition;
4244
4498
  const route = d.routes?.find((r) => r.key === this.recipeDef().route);
4245
4499
  return (route?.steps ?? []).map((sk) => d.operations?.find((o) => o.key === sk)).filter((o) => !!o);
4246
4500
  }
@@ -4261,7 +4515,7 @@ var MesKernel = class extends FlowEngine {
4261
4515
  onOrderDef(_spec) {
4262
4516
  const rc = this.recipeDef();
4263
4517
  const id = `order-${++this.orderSeq}`;
4264
- const wo = gdtiUri(this.mesSpec.companyPrefix, "403", ++this.soSeq);
4518
+ const wo = gdtiUri(this.productionSpec.companyPrefix, "403", ++this.soSeq);
4265
4519
  const order = { id, kind: "workorder", status: "created", gtin: this.classOf(rc.outputs[0].material), requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
4266
4520
  this.orders.set(id, order);
4267
4521
  this.emitOrder(order);
@@ -4304,8 +4558,8 @@ var MesKernel = class extends FlowEngine {
4304
4558
  const isLast = i === ops.length - 1;
4305
4559
  if (!isLast) {
4306
4560
  const inputs = order.allocated.slice();
4307
- const wip2 = sgtinUri(this.mesSpec.companyPrefix, "WIP", ++this.wipSeq);
4308
- const wipGtin = sgtinClass(this.mesSpec.companyPrefix, "WIP");
4561
+ const wip2 = sgtinUri(this.productionSpec.companyPrefix, "WIP", ++this.wipSeq);
4562
+ const wipGtin = sgtinClass(this.productionSpec.companyPrefix, "WIP");
4309
4563
  this.transform(inputs, [{ epc: wip2, gtin: wipGtin, qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
4310
4564
  order.allocated = [wip2];
4311
4565
  const next = ops[i + 1];
@@ -4339,6 +4593,12 @@ var VOCABULARY_EXCEPTIONS = [
4339
4593
  { token: "moverId", why: "journal wire field \u2014 renaming would mix two keys for one fact across history" },
4340
4594
  { token: "fromNode", why: "journal wire field (task.status payload)" },
4341
4595
  { token: "toNode", why: "journal wire field (task.status payload)" },
4596
+ /* ── 보드의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
4597
+ * 설비 배열의 이름은 세 세대를 거쳤다(movers → equipmentList → equipment). 저장된 보드에는
4598
+ * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 보드는 **설비가 0인 공장**
4599
+ * 으로 조용히 읽힌다 — 화면의 설비 수가 0이 되고 용량 판정에서 자원이 사라진다. 쓰는 곳은
4600
+ * `readBoardEquipment` 한 곳뿐이고, 거기서 새 이름으로 정규화해 내보낸다. */
4601
+ { token: "movers", why: "legacy board key (movers \u2192 equipmentList \u2192 equipment); read-only normalization in readBoardEquipment, 13 stored boards still use it" },
4342
4602
  /* ── 씬 컴포넌트 타입 — 보드에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
4343
4603
  * 보드 7개가 이 타입으로 컴포넌트를 담고 있어 개명하면 그 컴포넌트가 조용히 안 그려진다.
4344
4604
  * 그리고 씬에서 이 이름은 자원이 아니라 **움직임 표현**을 가리킨다(표준과 충돌 아님). */
@@ -4467,6 +4727,7 @@ function retiredVocabularyIn(line) {
4467
4727
  transformationEvent,
4468
4728
  validateDomainDefinition,
4469
4729
  validateEpcisEvent,
4730
+ validateScenario,
4470
4731
  weekdayAt,
4471
4732
  workingTimeOfWeek
4472
4733
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {