@operato/twin-kernel 0.4.1 → 0.4.3

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",
@@ -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
  }
@@ -2282,6 +2353,14 @@ var FlowEngine = class {
2282
2353
  _attentionSince = /* @__PURE__ */ new Map();
2283
2354
  // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
2284
2355
  dispatch(cmd) {
2356
+ this._correlationId = cmd.correlationId ?? cmd.commandId;
2357
+ try {
2358
+ return this.dispatchInner(cmd);
2359
+ } finally {
2360
+ this._correlationId = void 0;
2361
+ }
2362
+ }
2363
+ dispatchInner(cmd) {
2285
2364
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
2286
2365
  const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
2287
2366
  switch (cmd.type) {
@@ -2296,7 +2375,10 @@ var FlowEngine = class {
2296
2375
  }
2297
2376
  case CMD.attentionAck: {
2298
2377
  const id = cmd.args?.id;
2299
- if (id) this._acked.add(id);
2378
+ if (id) {
2379
+ this._acked.add(id);
2380
+ this.emitOp(OP_EVENT.attentionAck, { id, at: this.now() });
2381
+ }
2300
2382
  return ok();
2301
2383
  }
2302
2384
  // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
@@ -2475,7 +2557,9 @@ var FlowEngine = class {
2475
2557
  ...o.endTime ? { endTime: o.endTime } : {},
2476
2558
  held: o.held
2477
2559
  })),
2478
- attentions: this.computeAttentions()
2560
+ attentions: this.computeAttentions(),
2561
+ /* 확인해 둔 신호 — 스냅샷으로 왕복해야 재기동 후에도 확인 상태가 유지된다. */
2562
+ acked: [...this._acked]
2479
2563
  };
2480
2564
  }
2481
2565
  /*
@@ -2614,6 +2698,15 @@ var FlowEngine = class {
2614
2698
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
2615
2699
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
2616
2700
  */
2701
+ /**
2702
+ * 선언된 오퍼레이션들 — 도메인 커널이 "무엇을 만들 수 있나" 를 물을 수 있게.
2703
+ *
2704
+ * `operationSpecs` 를 도메인이 직접 뒤지지 않게 읽기 창구를 둔다: 저장 형태(맵)가 바뀌어도
2705
+ * 도메인은 몰라야 하고, 도메인이 그 맵에 쓰는 일이 생기면 정의가 권위라는 규약이 깨진다.
2706
+ */
2707
+ declaredOperations() {
2708
+ return [...this.operationSpecs.values()];
2709
+ }
2617
2710
  loadOperations(ops = []) {
2618
2711
  for (const o of ops) if (o?.key) this.operationSpecs.set(o.key, o);
2619
2712
  }
@@ -2929,14 +3022,25 @@ var FlowEngine = class {
2929
3022
  for (const n of this.locations.values()) if (n.type === locationType) views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
2930
3023
  return views;
2931
3024
  }
3025
+ /**
3026
+ * 지금 처리 중인 커맨드의 상관값 — **디스패치 동안에만 있다.**
3027
+ *
3028
+ * 커맨드가 낳은 이벤트에 이 값을 실어야 "이 지시가 실제로 무엇을 일으켰나" 를 나중에 물을 수 있다.
3029
+ * 그게 없으면 승인 기록은 "허락했다" 까지이고, 그 뒤 공장이 어떻게 움직였는지와 이어지지 않는다.
3030
+ *
3031
+ * **한계를 밝힌다**: 여기서 잇는 것은 그 자리에서 방출된 이벤트뿐이다. 나중 틱에 일어나는 후속
3032
+ * (예: resourceDown 이 정한 수리 완료)은 시뮬 시간의 결과라 이어지지 않는다. 즉시 인과만 잇는다 —
3033
+ * 먼 인과까지 같은 값으로 묶으면 "이 승인 때문"이라는 말이 사실보다 넓어진다.
3034
+ */
3035
+ _correlationId;
2932
3036
  emit(event) {
2933
3037
  this.revision++;
2934
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, data: event };
3038
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data: event };
2935
3039
  for (const h of this.handlers) h(e);
2936
3040
  }
2937
3041
  emitOp(eventType, data) {
2938
3042
  this.revision++;
2939
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
3043
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data };
2940
3044
  for (const h of this.handlers) h(e);
2941
3045
  }
2942
3046
  /**
@@ -3682,6 +3786,59 @@ var FlowEngine = class {
3682
3786
  }
3683
3787
  };
3684
3788
 
3789
+ // src/make-to-order.ts
3790
+ function producedByIndex(ops) {
3791
+ const idx = /* @__PURE__ */ new Map();
3792
+ for (const op of ops) {
3793
+ if (op.intent !== "process") continue;
3794
+ for (const m of op.materialSpecification ?? []) {
3795
+ if (m.use !== "produced") continue;
3796
+ if (!m.materialDefinition) continue;
3797
+ if (!idx.has(m.materialDefinition)) idx.set(m.materialDefinition, op);
3798
+ }
3799
+ }
3800
+ return idx;
3801
+ }
3802
+ var consumedOf = (op) => (op.materialSpecification ?? []).filter((m) => m.use === "consumed" && (m.quantity ?? 0) > 0);
3803
+ var outputPerRun = (op, gtin) => {
3804
+ const spec = (op.materialSpecification ?? []).find((m) => m.use === "produced" && m.materialDefinition === gtin);
3805
+ return Math.max(1, spec?.quantity ?? 1);
3806
+ };
3807
+ function planMakeToOrder(gtin, shortQty, ops, stock, locations) {
3808
+ const op = producedByIndex(ops).get(gtin);
3809
+ if (!op) return { steps: [], reason: "not-producible" };
3810
+ const station = op.locationType ? locations.find((l) => l.type === op.locationType) : void 0;
3811
+ if (!station) return { steps: [], reason: "no-station" };
3812
+ const need = consumedOf(op);
3813
+ if (!need.length) {
3814
+ return { steps: [{ step: "process", operation: op.key, at: station.id }] };
3815
+ }
3816
+ const runs = Math.max(1, Math.ceil(shortQty / outputPerRun(op, gtin)));
3817
+ const feeds = [];
3818
+ let short = false;
3819
+ for (const req of need) {
3820
+ const want = (req.quantity ?? 0) * runs;
3821
+ const atStation = stock.filter((s) => s.location === station.id && matches(s, req)).reduce((n, s) => n + s.qty, 0);
3822
+ let missing = want - atStation;
3823
+ if (missing <= 0) continue;
3824
+ for (const s of stock) {
3825
+ if (missing <= 0) break;
3826
+ if (s.location === station.id || !s.sellable || !matches(s, req)) continue;
3827
+ const take = Math.min(s.qty, missing);
3828
+ feeds.push({ step: "feed", gtin: s.gtin, from: s.location, to: station.id, qty: take });
3829
+ missing -= take;
3830
+ }
3831
+ if (missing > 0) short = true;
3832
+ }
3833
+ if (short) return { steps: [], reason: "short-materials" };
3834
+ if (feeds.length) return { steps: feeds, reason: "waiting-feed" };
3835
+ return { steps: [{ step: "process", operation: op.key, at: station.id }] };
3836
+ }
3837
+ function matches(s, req) {
3838
+ if (req.materialDefinition) return s.gtin === req.materialDefinition;
3839
+ return false;
3840
+ }
3841
+
3685
3842
  // src/kernel.ts
3686
3843
  var TRAVEL_MS = 3e4;
3687
3844
  var COMPANY_PREFIX = "0614141";
@@ -3809,7 +3966,7 @@ var WmsKernel = class extends FlowEngine {
3809
3966
  chosenAll.push(epc);
3810
3967
  }
3811
3968
  }
3812
- if (chosenAll.length === 0) return;
3969
+ if (chosenAll.length === 0) return this.makeShortLines(o);
3813
3970
  this.reserve(chosenAll, BIZSTEP.storing);
3814
3971
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
3815
3972
  for (const epc of chosenAll) {
@@ -3824,6 +3981,7 @@ var WmsKernel = class extends FlowEngine {
3824
3981
  }
3825
3982
  /** 태스크 완료 — 이동 반영 후 putaway=storing, pick=picking(+전량 시 pack→stage→ship). */
3826
3983
  onTaskComplete(t) {
3984
+ if (t.intent === "process") return this.onProcessComplete(t);
3827
3985
  const item = this.itemByRef(t.itemEpc);
3828
3986
  if (!item) throw new Error(`task ${t.id}: item "${t.itemEpc}" vanished between the core check and the domain hook`);
3829
3987
  const from = this.locations.get(t.fromNode);
@@ -3831,6 +3989,10 @@ var WmsKernel = class extends FlowEngine {
3831
3989
  from.occupancy--;
3832
3990
  to.occupancy++;
3833
3991
  item.location = to.id;
3992
+ if (t.kind === "feed") {
3993
+ 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 }));
3994
+ return;
3995
+ }
3834
3996
  if (t.kind === "putaway") {
3835
3997
  item.disposition = DISP.sellable;
3836
3998
  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 +4005,112 @@ var WmsKernel = class extends FlowEngine {
3843
4005
  order.picked.push(item.epc);
3844
4006
  if (order.picked.length === order.allocated.length) this.finalizeOrder(order, to);
3845
4007
  }
4008
+ // ── 수요가 부르는 생산(유통가공) ────────────────────────────────────────────
4009
+ /**
4010
+ * 부족한 라인을 **만들어서** 채운다 — 부품 이송 → 가공 → 되돌리기 3단 사슬.
4011
+ *
4012
+ * 사슬인 이유는 커널의 두 규칙 때문이다(둘 다 의도된 규칙이라 우회하지 않는다):
4013
+ * 자재는 **작업이 일어나는 자리에** 있어야 소비되고, 산출물은 `in_progress` 로 태어나 **팔 수 있는
4014
+ * 재고가 아니다.** 그래서 부품을 작업대로 옮기고, 가공하고, 나온 것을 보관 자리로 되돌린다.
4015
+ *
4016
+ * **한 오더에 사슬 하나만** 굴린다. 매 tick 마다 다시 발행하면 같은 부품을 두 번 끌어오는 작업이
4017
+ * 쌓이고, 그중 하나만 성공한 뒤 나머지는 영원히 재료를 기다린다.
4018
+ */
4019
+ makeShortLines(o) {
4020
+ if (!o.lines?.length) return;
4021
+ if (this.hasOpenMakeChain(o.id)) return;
4022
+ const stock = this.stockLines();
4023
+ const locations = [...this.locations.values()].map((l) => ({ id: l.id, type: l.type }));
4024
+ const ops = this.declaredOperations();
4025
+ for (const line of o.lines) {
4026
+ 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);
4027
+ const short = line.requested - have;
4028
+ if (short <= 0) continue;
4029
+ const plan = planMakeToOrder(line.gtin, short, ops, stock, locations);
4030
+ for (const step of plan.steps) {
4031
+ if (step.step === "feed") this.issueFeed(o, step.gtin, step.from, step.to, step.qty);
4032
+ else this.issueProcess(o, step.operation, step.at);
4033
+ }
4034
+ if (plan.steps.length) return;
4035
+ }
4036
+ }
4037
+ /** 이 오더가 굴리고 있는 생산 사슬이 있나 — 이송·가공·되돌리기 중 하나라도 열려 있으면 그렇다. */
4038
+ hasOpenMakeChain(orderId) {
4039
+ for (const t of this.tasks.values()) {
4040
+ if (t.orderId !== orderId || t.status === "completed") continue;
4041
+ if (t.kind === "feed" || t.intent === "process") return true;
4042
+ if (t.kind === "putaway" && t.orderId === orderId) return true;
4043
+ }
4044
+ return false;
4045
+ }
4046
+ /** 지금 재고 — 판단 함수가 보는 형태로. */
4047
+ stockLines() {
4048
+ 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 }));
4049
+ }
4050
+ /** 부품을 작업대로 — 팔레트 이동이므로 피킹과 같은 기제다(부분 소비는 코어가 한다). */
4051
+ issueFeed(o, gtin, from, to, qty) {
4052
+ const src = [...this.items.values()].find((i) => i.gtin === gtin && i.location === from && i.disposition === DISP.sellable);
4053
+ if (!src) return;
4054
+ src.disposition = DISP.reserved;
4055
+ this.pushTask(o, "feed", src.epc, from, to);
4056
+ }
4057
+ /** 가공 — 작업 종류를 **오퍼레이션 키**로 둔다(코어가 그 키로 명세를 찾는다). */
4058
+ issueProcess(o, operation, at) {
4059
+ this.pushTask(o, operation, "", at, at, "process");
4060
+ }
4061
+ pushTask(o, kind, itemEpc, from, to, intent) {
4062
+ const id = `task-${++this.taskSeq}`;
4063
+ const task = {
4064
+ id,
4065
+ kind,
4066
+ status: "created",
4067
+ itemEpc,
4068
+ fromNode: from,
4069
+ toNode: to,
4070
+ resource: null,
4071
+ remainingMs: 0,
4072
+ durationMs: this.durationOf({ kind, fromNode: from, toNode: to }, TRAVEL_MS),
4073
+ ...intent ? { intent } : {},
4074
+ ...o ? { orderId: o.id } : {}
4075
+ };
4076
+ this.tasks.set(id, task);
4077
+ this.emitTask(task);
4078
+ }
4079
+ /**
4080
+ * 가공 완료 — 코어가 이미 소비(시작)와 산출(완료 직전)을 실행했다. 남은 일은 만든 것을 **재고로
4081
+ * 들여놓는 것**이다: 산출물은 `in_progress` 로 태어나 팔 수 있는 재고가 아니다.
4082
+ *
4083
+ * ── 왜 팔레트로 묶나 ───────────────────────────────────────────────────────
4084
+ * 코어의 산출은 **클래스+수량 줄**이고 그 키에는 자리가 박혀 있다(`품목@자리`). 창고의 출고 경로는
4085
+ * 직렬 물류단위(팔레트 SSCC)를 다루므로, 그 줄을 그대로 출고에 태우면 키를 EPC 로 착각해 조회가
4086
+ * 깨진다(실제로 그렇게 터졌다). 억지로 태우면 EPCIS 에도 **EPC 가 아닌 문자열**이 `epcList` 로
4087
+ * 나가는데, 그것은 코어가 산출에서 경계한 바로 그 일이다.
4088
+ *
4089
+ * 그래서 만든 물건을 **입고가 하는 것과 같은 방식**으로 들여놓는다: 팔레트(SSCC)를 만들고 그 안에
4090
+ * 세트 N개를 담는다(AggregationEvent). 현장에서도 가공물은 팔레트에 실려 보관으로 간다.
4091
+ * 그 뒤는 기존 경로 그대로다 — `putaway` 로 보관 자리에 넣으면 판매 가능이 된다.
4092
+ */
4093
+ onProcessComplete(t) {
4094
+ const made = (t.materialActual ?? []).filter((m) => m.use === "produced");
4095
+ const order = t.orderId ? this.orders.get(t.orderId) : void 0;
4096
+ const at = t.toNode;
4097
+ for (const m of made) {
4098
+ const key = subLotIdOf(m.definitionId, at);
4099
+ const row = this.items.get(key);
4100
+ if (!row) continue;
4101
+ const qty = row.qty ?? 0;
4102
+ if (qty <= 0) continue;
4103
+ this.items.delete(key);
4104
+ const pallet = ssccUri(COMPANY_PREFIX, ++this.epcSeq);
4105
+ const qtyList = [{ epcClass: m.definitionId, quantity: qty }];
4106
+ this.items.set(pallet, { epc: pallet, gtin: m.definitionId, qty, location: at, disposition: DISP.in_progress });
4107
+ const eventTime = this.now();
4108
+ this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.packing, parentID: pallet, childQuantityList: qtyList, readPoint: at }));
4109
+ this.emit(objectEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.packing, disposition: DISP.in_progress, epcList: [pallet], quantityList: qtyList, readPoint: at, bizLocation: at }));
4110
+ const storage = this.locationByType("storage");
4111
+ if (storage) this.pushTask(order, "putaway", pallet, at, storage.id);
4112
+ }
4113
+ }
3846
4114
  /** 전량 피킹 → packing(조립)·staging·shipping 마감. 화물 사이트 이탈, 백오더 잔량 재할당. */
3847
4115
  finalizeOrder(order, staging) {
3848
4116
  const shipDock = this.locationByType("dock-ship") ?? staging;
@@ -4067,13 +4335,18 @@ var MesKernel = class extends FlowEngine {
4067
4335
  wipSeq = 0;
4068
4336
  prodSeq = 0;
4069
4337
  /** 정의-구동 모드(선택). 미지정 시 레거시 하드코딩 경로 — byte-identical. */
4070
- mesSpec;
4071
- constructor(tenantId, policy = firstFitPolicy, mesSpec) {
4338
+ productionSpec;
4339
+ constructor(tenantId, policy = firstFitPolicy, productionSpec) {
4072
4340
  super(tenantId, policy);
4073
- this.mesSpec = mesSpec;
4074
- if (mesSpec?.definition?.operations) {
4075
- this.loadOperations(mesSpec.definition.operations);
4076
- this.assertNoDoubleProduction(mesSpec.definition.operations);
4341
+ this.productionSpec = productionSpec;
4342
+ if (productionSpec?.definition?.operations) {
4343
+ this.loadOperations(productionSpec.definition.operations);
4344
+ this.assertNoDoubleProduction(productionSpec.definition.operations);
4345
+ }
4346
+ if (productionSpec?.definition?.recipes?.length && (!productionSpec.binding || !productionSpec.companyPrefix)) {
4347
+ throw new Error(
4348
+ "recipe-driven production needs `binding` and `companyPrefix` \u2014 the definition does not know GTINs, so material keys cannot be resolved to GS1 identifiers"
4349
+ );
4077
4350
  }
4078
4351
  }
4079
4352
  /**
@@ -4089,10 +4362,10 @@ var MesKernel = class extends FlowEngine {
4089
4362
  * 모든 계산이 거짓이 된다).
4090
4363
  */
4091
4364
  assertNoDoubleProduction(ops) {
4092
- const bad = ops.filter((o) => (o.materialSpecification ?? []).some((m) => m.use === "produced")).map((o) => o.key);
4093
- if (!bad.length) return;
4365
+ const bad2 = ops.filter((o) => (o.materialSpecification ?? []).some((m) => m.use === "produced")).map((o) => o.key);
4366
+ if (!bad2.length) return;
4094
4367
  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.`
4368
+ `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
4369
  );
4097
4370
  }
4098
4371
  productOf(gtin) {
@@ -4121,7 +4394,7 @@ var MesKernel = class extends FlowEngine {
4121
4394
  }
4122
4395
  /** 부품 수령(다품종) — skuMix 의 gtin 으로 부품 종류 결정. */
4123
4396
  onArrival(spec) {
4124
- if (this.mesSpec) return this.onArrivalDef(spec);
4397
+ if (this.productionSpec) return this.onArrivalDef(spec);
4125
4398
  const rawStore = this.locationByType("raw-store");
4126
4399
  if (!rawStore) return;
4127
4400
  const gtin = this.pickGtin(spec.content.skuMix);
@@ -4134,7 +4407,7 @@ var MesKernel = class extends FlowEngine {
4134
4407
  }
4135
4408
  /** 작업지시 — 제품 2종 교대(체인지오버 유발). 제품 gtin 을 오더에 기록. */
4136
4409
  onOrder(_spec) {
4137
- if (this.mesSpec) return this.onOrderDef(_spec);
4410
+ if (this.productionSpec) return this.onOrderDef(_spec);
4138
4411
  const product = PRODUCTS[this.orderSeq % PRODUCTS.length];
4139
4412
  const id = `order-${++this.orderSeq}`;
4140
4413
  const wo = gdtiUri(CP2, "403", ++this.soSeq);
@@ -4144,7 +4417,7 @@ var MesKernel = class extends FlowEngine {
4144
4417
  }
4145
4418
  /** 할당 — 제품 BOM 각 라인의 부품 예약(하나라도 부족하면 대기) + 라우트 첫 스테이션(절단) 태스크. */
4146
4419
  allocate(o) {
4147
- if (this.mesSpec) return this.allocateDef(o);
4420
+ if (this.productionSpec) return this.allocateDef(o);
4148
4421
  const s0 = ROUTE[0];
4149
4422
  const first = this.locationByType(s0.locationType);
4150
4423
  const product = this.productOf(o.gtin);
@@ -4179,12 +4452,12 @@ var MesKernel = class extends FlowEngine {
4179
4452
  * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
4180
4453
  */
4181
4454
  canComplete(t) {
4182
- if (this.mesSpec) return !!(t.orderId && this.orders.get(t.orderId));
4455
+ if (this.productionSpec) return !!(t.orderId && this.orders.get(t.orderId));
4183
4456
  const order = t.orderId ? this.orders.get(t.orderId) : void 0;
4184
4457
  return !!order && !!this.productOf(order.gtin);
4185
4458
  }
4186
4459
  onTaskComplete(t) {
4187
- if (this.mesSpec) return this.onTaskCompleteDef(t);
4460
+ if (this.productionSpec) return this.onTaskCompleteDef(t);
4188
4461
  const order = this.orders.get(t.orderId);
4189
4462
  const product = order && this.productOf(order.gtin);
4190
4463
  if (!order || !product) throw new Error(`task ${t.id}: order/product vanished between the core check and the domain hook`);
@@ -4217,15 +4490,15 @@ var MesKernel = class extends FlowEngine {
4217
4490
  }
4218
4491
  // ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
4219
4492
  recipeDef() {
4220
- const d = this.mesSpec.definition;
4221
- return this.mesSpec.recipeKey ? d.recipes?.find((r) => r.key === this.mesSpec.recipeKey) : d.recipes?.[0];
4493
+ const d = this.productionSpec.definition;
4494
+ return this.productionSpec.recipeKey ? d.recipes?.find((r) => r.key === this.productionSpec.recipeKey) : d.recipes?.[0];
4222
4495
  }
4223
4496
  /** 자재 키 → 구체 gtin 클래스(idpat). 구체 식별은 바인딩+prefix 로 인스턴스가 주입. */
4224
4497
  classOf(materialKey) {
4225
- return sgtinClass(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey]);
4498
+ return sgtinClass(this.productionSpec.companyPrefix, this.productionSpec.binding[materialKey]);
4226
4499
  }
4227
4500
  serialOf(materialKey, serial) {
4228
- return sgtinUri(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey], serial);
4501
+ return sgtinUri(this.productionSpec.companyPrefix, this.productionSpec.binding[materialKey], serial);
4229
4502
  }
4230
4503
  /**
4231
4504
  * 라우트를 용량 계산에 알려 준다 — 수율을 거슬러 올릴 때 순서가 곧 계산이다.
@@ -4234,13 +4507,13 @@ var MesKernel = class extends FlowEngine {
4234
4507
  * 틀린다). 생산 정의를 가진 커널만 이 답을 안다.
4235
4508
  */
4236
4509
  routeKeys() {
4237
- if (!this.mesSpec) return void 0;
4238
- const d = this.mesSpec.definition;
4510
+ if (!this.productionSpec) return void 0;
4511
+ const d = this.productionSpec.definition;
4239
4512
  return d.routes?.find((r) => r.key === this.recipeDef().route)?.steps;
4240
4513
  }
4241
4514
  /** recipe.route → 오퍼레이션 시퀀스 해소. */
4242
4515
  routeOps() {
4243
- const d = this.mesSpec.definition;
4516
+ const d = this.productionSpec.definition;
4244
4517
  const route = d.routes?.find((r) => r.key === this.recipeDef().route);
4245
4518
  return (route?.steps ?? []).map((sk) => d.operations?.find((o) => o.key === sk)).filter((o) => !!o);
4246
4519
  }
@@ -4261,7 +4534,7 @@ var MesKernel = class extends FlowEngine {
4261
4534
  onOrderDef(_spec) {
4262
4535
  const rc = this.recipeDef();
4263
4536
  const id = `order-${++this.orderSeq}`;
4264
- const wo = gdtiUri(this.mesSpec.companyPrefix, "403", ++this.soSeq);
4537
+ const wo = gdtiUri(this.productionSpec.companyPrefix, "403", ++this.soSeq);
4265
4538
  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
4539
  this.orders.set(id, order);
4267
4540
  this.emitOrder(order);
@@ -4304,8 +4577,8 @@ var MesKernel = class extends FlowEngine {
4304
4577
  const isLast = i === ops.length - 1;
4305
4578
  if (!isLast) {
4306
4579
  const inputs = order.allocated.slice();
4307
- const wip2 = sgtinUri(this.mesSpec.companyPrefix, "WIP", ++this.wipSeq);
4308
- const wipGtin = sgtinClass(this.mesSpec.companyPrefix, "WIP");
4580
+ const wip2 = sgtinUri(this.productionSpec.companyPrefix, "WIP", ++this.wipSeq);
4581
+ const wipGtin = sgtinClass(this.productionSpec.companyPrefix, "WIP");
4309
4582
  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
4583
  order.allocated = [wip2];
4311
4584
  const next = ops[i + 1];
@@ -4473,6 +4746,7 @@ function retiredVocabularyIn(line) {
4473
4746
  transformationEvent,
4474
4747
  validateDomainDefinition,
4475
4748
  validateEpcisEvent,
4749
+ validateScenario,
4476
4750
  weekdayAt,
4477
4751
  workingTimeOfWeek
4478
4752
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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": {