@operato/twin-kernel 0.7.47 → 0.7.49

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.
@@ -111,6 +111,7 @@ __export(index_exports, {
111
111
  gdtiUri: () => gdtiUri,
112
112
  generationFractionAt: () => generationFractionAt,
113
113
  graiUri: () => graiUri,
114
+ gs1KeyDigitViolation: () => gs1KeyDigitViolation,
114
115
  hierarchyOf: () => hierarchyOf,
115
116
  identityGroundingOf: () => identityGroundingOf,
116
117
  inWorkCalendar: () => inWorkCalendar,
@@ -721,7 +722,6 @@ function validateDomainDefinition(def) {
721
722
  if (!matKeys.has(p.material)) v.push(`recipe '${rc.key}' material '${p.material}' \uBBF8\uC815\uC758`);
722
723
  if (typeof p.qty !== "number" || p.qty <= 0) v.push(`recipe '${rc.key}' material '${p.material}' qty \uBD80\uC815`);
723
724
  const mat = matByKey.get(p.material);
724
- if (mat && !mat.locationType) v.push(`recipe '${rc.key}' material '${p.material}' locationType \uBBF8\uC120\uC5B8`);
725
725
  if (mat?.locationType && !locationKeys2.has(mat.locationType)) v.push(`recipe '${rc.key}' material '${p.material}' locationType '${mat.locationType}' \uBBF8\uC815\uC758`);
726
726
  }
727
727
  }
@@ -1039,8 +1039,28 @@ var EPC_CLASS_PREFIXES = ["urn:epc:idpat:", "urn:epc:class:"];
1039
1039
  var DL_CANONICAL = "https://id.gs1.org/";
1040
1040
  var CBV_URL_CLASS = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*class\/[^/\s]+$/;
1041
1041
  var CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
1042
+ var GS1_KEY_DIGITS = { sgtin: 13, sscc: 17, grai: 12, gdti: 12 };
1043
+ function gs1KeyDigitViolation(uri) {
1044
+ if (!uri) return void 0;
1045
+ const m = /^urn:epc:(?:id|idpat|class):([a-z]+):([^:]+)$/.exec(uri);
1046
+ if (!m) return void 0;
1047
+ const want = GS1_KEY_DIGITS[m[1]];
1048
+ if (!want) return void 0;
1049
+ const parts = m[2].split(".");
1050
+ if (parts.length < 2) return `${m[1]} \uD615\uC2DD \uC624\uB958: ${uri} \u2014 \uD68C\uC0AC \uD504\uB9AC\uD53D\uC2A4\uC640 \uCC38\uC870\uAC00 '.' \uB85C \uAC08\uB824\uC57C \uD55C\uB2E4`;
1051
+ const [prefix, ref] = parts;
1052
+ if (ref === "*") return void 0;
1053
+ if (!/^\d+$/.test(prefix) || !/^\d+$/.test(ref)) {
1054
+ return `${m[1]} \uD615\uC2DD \uC624\uB958: ${uri} \u2014 \uD68C\uC0AC \uD504\uB9AC\uD53D\uC2A4\uC640 \uCC38\uC870\uB294 \uC22B\uC790\uB2E4`;
1055
+ }
1056
+ const got = prefix.length + ref.length;
1057
+ if (got === want) return void 0;
1058
+ return `${m[1]} \uC790\uB9AC \uC218 \uC624\uB958: ${uri} \u2014 \uD68C\uC0AC \uD504\uB9AC\uD53D\uC2A4(${prefix.length}) + \uCC38\uC870(${ref.length}) = ${got} \uC774\uC9C0\uB9CC ${want} \uC5EC\uC57C \uD55C\uB2E4(GS1 TDS). \uD504\uB9AC\uD53D\uC2A4\uB97C \uBC14\uAFB8\uBA74 \uCC38\uC870 \uC790\uB9AC \uC218\uB97C \uD568\uAED8 \uB9DE\uCDB0\uC57C \uD55C\uB2E4.`;
1059
+ }
1042
1060
  function classIdentifierViolation(epcClass) {
1043
1061
  if (!epcClass) return `quantity epcClass \uBD80\uC815: ${epcClass}`;
1062
+ const digits = gs1KeyDigitViolation(epcClass);
1063
+ if (digits) return `quantity epcClass \uBD80\uC815: ${digits}`;
1044
1064
  if (EPC_CLASS_PREFIXES.some((p) => epcClass.startsWith(p))) return void 0;
1045
1065
  if (epcClass.startsWith(DL_CANONICAL)) return void 0;
1046
1066
  if (CBV_URL_CLASS.test(epcClass) || CBV_URN_CLASS.test(epcClass)) return void 0;
@@ -1048,6 +1068,18 @@ function classIdentifierViolation(epcClass) {
1048
1068
  }
1049
1069
  function validateEpcisEvent(e) {
1050
1070
  const v = [];
1071
+ for (const [field, list] of [
1072
+ ["epcList", e.epcList],
1073
+ ["childEPCs", e.childEPCs],
1074
+ ["inputEPCList", e.inputEPCList],
1075
+ ["outputEPCList", e.outputEPCList],
1076
+ ["parentID", e.parentID ? [e.parentID] : void 0]
1077
+ ]) {
1078
+ for (const epc of list ?? []) {
1079
+ const bad2 = gs1KeyDigitViolation(epc);
1080
+ if (bad2) v.push(`${field}: ${bad2}`);
1081
+ }
1082
+ }
1051
1083
  if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
1052
1084
  if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
1053
1085
  if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
@@ -2517,6 +2549,34 @@ var TWIN_AXES = [
2517
2549
  standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" },
2518
2550
  systems: LOGISTICS
2519
2551
  },
2552
+ /*
2553
+ * ── 물품이 축이 아니었다 (2026-08-22) ──────────────────────────────────────
2554
+ * 트윈에서 **수가 가장 많은 것**이 물품인데(실측: 엔티티 3,611 중 2,400 · hatio-us 는 2,805) 그것을
2555
+ * 가리켜 걸어 들어갈 자리가 없었다. 지도와 집약 태그에는 이미 보이는데 「모델 살펴보기」에는 문이
2556
+ * 없었다 — 사용자가 트윈에 가장 자주 묻는 것이 「내 물건이 어디 있나」이므로 그것은 접근 장벽이다.
2557
+ *
2558
+ * 성격은 `orders`·`tasks` 와 같다: 상태에 살고, 저널에 이력이 있고, 원본이 낸 것을 트윈이 관측한다.
2559
+ * 그래서 같은 조합(`instance` · `state` · `historical`)이다.
2560
+ *
2561
+ * ── 표준 대응 ─────────────────────────────────────────────────────────────
2562
+ * ISA-95 는 `MaterialLot` 이다 — 물품은 「무슨 품목인가」(정의)가 아니라 「그 품목의 이 덩어리」이고,
2563
+ * 위치·수량·부분(`MaterialSubLot`)을 그 자리가 든다. EPCIS 는 `ObjectEvent` 다: 개체가 생기고
2564
+ * 관측되고 사라지는 것을 그 사건이 말한다.
2565
+ *
2566
+ * ── 무엇이 이 항목을 가리키나 ─────────────────────────────────────────────
2567
+ * `subLotId ?? epc` 다. 짐작에 맡기면 `gtin` 으로 떨어지고, 그러면 **같은 품목의 물품 전부가 한
2568
+ * 식별자로 뭉친다** — 화면이 2,400개를 몇 개로 보인다.
2569
+ */
2570
+ {
2571
+ axis: "items",
2572
+ label: "twin.axis.items",
2573
+ kind: "instance",
2574
+ source: "state",
2575
+ historical: true,
2576
+ idField: ["subLotId", "epc"],
2577
+ standardClass: { isa95: "MaterialLot", epcis: "ObjectEvent" },
2578
+ systems: LOGISTICS
2579
+ },
2520
2580
  /*
2521
2581
  * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
2522
2582
  *
@@ -2591,6 +2651,24 @@ var TWIN_RELATIONS = [
2591
2651
  { from: "tasks", field: "toNode", target: { kind: "axis", axis: "locations" }, via: "twin.rel.at", optional: true },
2592
2652
  { from: "tasks", field: "resourceRef", target: { kind: "axis", axis: "equipment" }, via: "twin.rel.by", optional: true },
2593
2653
  { from: "tasks", field: "personnel[]", target: { kind: "axis", axis: "persons" }, via: "twin.rel.crew", optional: true },
2654
+ /*
2655
+ * 물품의 관계 (2026-08-22) — 없으면 축이 **걸어 들어갈 수 없는 목록**이 된다.
2656
+ *
2657
+ * `location` 은 필수다 — 물품은 언제나 어딘가에 있다(그것이 물품의 뜻이다). 나머지는 선택이다:
2658
+ * 물류단위에 담기지 않은 물품, 자산에 실리지 않은 팔레트가 정상이다.
2659
+ *
2660
+ * `parent` 는 **물품 축을 자기 자신으로** 가리킨다(팔레트에 담긴 상자 — EPCIS `AggregationEvent`).
2661
+ * `carriedBy` 는 다른 축이다 — 반복사용 자산(GRAI)이 물류단위를 실어 나른다(§`FlowItem.carriedBy`).
2662
+ *
2663
+ * 관계 이름은 **소문자 한 낱말**이다(`twin.rel.<name>`) — 기존 열여덟 개가 그 규율이고 시험이 지킨다.
2664
+ *
2665
+ * 품목(`gtin`)은 오더와 **같은 규율**이다: 자재 키가 아니라 GS1 품목 참조이므로 축을 직접 가리키지
2666
+ * 않고 `external` 로 둔다. 축을 가리키게 적으면 없는 필드를 가리키는 선언이 된다.
2667
+ */
2668
+ { from: "items", field: "location", target: { kind: "axis", axis: "locations" }, via: "twin.rel.at" },
2669
+ { from: "items", field: "parent", target: { kind: "axis", axis: "items" }, via: "twin.rel.parent", optional: true },
2670
+ { from: "items", field: "carriedBy", target: { kind: "axis", axis: "assets" }, via: "twin.rel.asset", optional: true },
2671
+ { from: "items", field: "gtin", target: { kind: "external", entity: "gs1.itemRef" }, via: "twin.rel.item", optional: true },
2594
2672
  /*
2595
2673
  * 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
2596
2674
  * 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
@@ -3401,6 +3479,22 @@ function energyFieldsOf(m) {
3401
3479
  var ItemStore = class _ItemStore {
3402
3480
  map = /* @__PURE__ */ new Map();
3403
3481
  byLocation = /* @__PURE__ */ new Map();
3482
+ /**
3483
+ * 품목별 색인 — **보관처를 선언하지 않은 현장을 위해.**
3484
+ *
3485
+ * ── 왜 필요한가 (2026-08-22) ──────────────────────────────────────────────
3486
+ * 자재를 확보할 때 예전에는 「그 자재의 보관처 타입」을 반드시 선언해야 했다(`MaterialDef.locationType`).
3487
+ * 그런데 **재고로 위치를 말하는 시스템**에는 그 선언이 없다 — 자재에 고정된 보관처를 두지 않는 것이
3488
+ * WMS 계열의 정상이다. 실측: 첫 실 연동에서 원자재 986건 중 보관처가 선언된 것이 **36건**이었고, 그
3489
+ * 때문에 레시피 937/1,408 건이 아예 실리지 못했다.
3490
+ *
3491
+ * 선언이 없으면 **재고가 있는 곳에서 찾는다.** 그때 전 로케이션을 훑으면 규모 기준선(품목 100만)에서
3492
+ * 감당되지 않으므로 품목 색인이 답한다.
3493
+ *
3494
+ * 색인을 밖에 따로 두지 않는다 — 갱신하는 자리가 흩어지면 한 자리라도 빠뜨렸을 때 **재고가 조용히
3495
+ * 사라진다**(있는데 없다고 판정된다). 자리 색인과 같은 규율이다.
3496
+ */
3497
+ byGtin = /* @__PURE__ */ new Map();
3404
3498
  get size() {
3405
3499
  return this.map.size;
3406
3500
  }
@@ -3427,19 +3521,20 @@ var ItemStore = class _ItemStore {
3427
3521
  }
3428
3522
  set(key, item) {
3429
3523
  const prev = this.map.get(key);
3430
- if (prev) this.unindex(key, prev.location);
3524
+ if (prev) this.unindex(key, prev.location, prev.gtin);
3431
3525
  this.map.set(key, item);
3432
- this.index(key, item.location);
3526
+ this.index(key, item.location, item.gtin);
3433
3527
  return this;
3434
3528
  }
3435
3529
  delete(key) {
3436
3530
  const prev = this.map.get(key);
3437
- if (prev) this.unindex(key, prev.location);
3531
+ if (prev) this.unindex(key, prev.location, prev.gtin);
3438
3532
  return this.map.delete(key);
3439
3533
  }
3440
3534
  clear() {
3441
3535
  this.map.clear();
3442
3536
  this.byLocation.clear();
3537
+ this.byGtin.clear();
3443
3538
  }
3444
3539
  /**
3445
3540
  * 물품을 다른 자리로 옮긴다 — **색인이 함께 움직이는 유일한 통로.**
@@ -3451,9 +3546,9 @@ var ItemStore = class _ItemStore {
3451
3546
  const it = this.map.get(key);
3452
3547
  if (!it) throw new Error(`relocate: \uBB3C\uD488 '${key}' \uC774 \uC0C1\uD0DC\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4 \u2014 \uC5C6\uB294 \uAC83\uC744 \uC62E\uAE38 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`);
3453
3548
  if (it.location !== to) {
3454
- this.unindex(key, it.location);
3549
+ this.unindexLocation(key, it.location);
3455
3550
  it.location = to;
3456
- this.index(key, to);
3551
+ this.indexLocation(key, to);
3457
3552
  }
3458
3553
  return it;
3459
3554
  }
@@ -3466,7 +3561,9 @@ var ItemStore = class _ItemStore {
3466
3561
  */
3467
3562
  clone() {
3468
3563
  const out = new _ItemStore();
3469
- for (const [k, it] of this.map) out.set(k, structuredClone(it));
3564
+ for (const [k, it] of this.map) out.map.set(k, structuredClone(it));
3565
+ for (const [loc, keys] of this.byLocation) out.byLocation.set(loc, new Set(keys));
3566
+ for (const [g, keys] of this.byGtin) out.byGtin.set(g, new Set(keys));
3470
3567
  return out;
3471
3568
  }
3472
3569
  /*
@@ -3485,6 +3582,17 @@ var ItemStore = class _ItemStore {
3485
3582
  }
3486
3583
  return out;
3487
3584
  }
3585
+ /** 그 품목인 물품들 — 색인이 답한다(자리를 모를 때 쓴다). */
3586
+ ofGtin(gtin) {
3587
+ const keys = this.byGtin.get(gtin);
3588
+ if (!keys) return [];
3589
+ const out = [];
3590
+ for (const k of keys) {
3591
+ const it = this.map.get(k);
3592
+ if (it) out.push(it);
3593
+ }
3594
+ return out;
3595
+ }
3488
3596
  /**
3489
3597
  * 색인이 맵과 어긋난 자리 — **시험이 쓰는 확인 통로**(전체를 다시 세므로 비싸다).
3490
3598
  *
@@ -3503,14 +3611,41 @@ var ItemStore = class _ItemStore {
3503
3611
  else if (it.location !== loc) drift.push(`${k} \uC740 '${it.location}' \uC5D0 \uC788\uB294\uB370 '${loc}' \uC0C9\uC778\uC5D0 \uC788\uB2E4`);
3504
3612
  }
3505
3613
  }
3614
+ for (const [key, it] of this.map) {
3615
+ if (it.gtin && !this.byGtin.get(it.gtin)?.has(key)) drift.push(`${key} \uC774 \uD488\uBAA9 '${it.gtin}' \uC0C9\uC778\uC5D0 \uC5C6\uB2E4`);
3616
+ }
3617
+ for (const [g, keys] of this.byGtin) {
3618
+ for (const k of keys) {
3619
+ const it = this.map.get(k);
3620
+ if (!it) drift.push(`${k} \uC774 \uC9C0\uC6CC\uC84C\uB294\uB370 \uD488\uBAA9 '${g}' \uC0C9\uC778\uC5D0 \uB0A8\uC544 \uC788\uB2E4`);
3621
+ else if (it.gtin !== g) drift.push(`${k} \uC740 \uD488\uBAA9 '${it.gtin}' \uC778\uB370 '${g}' \uC0C9\uC778\uC5D0 \uC788\uB2E4`);
3622
+ }
3623
+ }
3506
3624
  return drift;
3507
3625
  }
3508
- index(key, location) {
3626
+ index(key, location, gtin) {
3627
+ this.indexLocation(key, location);
3628
+ if (gtin) {
3629
+ const set = this.byGtin.get(gtin) ?? /* @__PURE__ */ new Set();
3630
+ set.add(key);
3631
+ this.byGtin.set(gtin, set);
3632
+ }
3633
+ }
3634
+ indexLocation(key, location) {
3509
3635
  const set = this.byLocation.get(location) ?? /* @__PURE__ */ new Set();
3510
3636
  set.add(key);
3511
3637
  this.byLocation.set(location, set);
3512
3638
  }
3513
- unindex(key, location) {
3639
+ unindex(key, location, gtin) {
3640
+ this.unindexLocation(key, location);
3641
+ if (gtin) {
3642
+ const set = this.byGtin.get(gtin);
3643
+ if (!set) return;
3644
+ set.delete(key);
3645
+ if (!set.size) this.byGtin.delete(gtin);
3646
+ }
3647
+ }
3648
+ unindexLocation(key, location) {
3514
3649
  const set = this.byLocation.get(location);
3515
3650
  if (!set) return;
3516
3651
  set.delete(key);
@@ -3599,6 +3734,14 @@ var FlowEngine = class {
3599
3734
  */
3600
3735
  seedDanglingRefs = 0;
3601
3736
  transformInputsAbsent = 0;
3737
+ /**
3738
+ * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
3739
+ *
3740
+ * 같은 키끼리는 구체가 상회한다(§`mergeMaterialNeeds`). 교차는 뜻으로는 상회일 수 있으나 판정에 등급
3741
+ * 소속이 필요하고, 잘못 겹치면 자재가 조용히 사라지거나 두 배가 된다. 그래서 **둘 다 요구하고 센다** —
3742
+ * 이 값이 크면 그 숫자가 다음 작업을 정한다.
3743
+ */
3744
+ materialSpecCrossKeyOverlaps = 0;
3602
3745
  observedDirty = false;
3603
3746
  observeMode = false;
3604
3747
  /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
@@ -4238,9 +4381,10 @@ var FlowEngine = class {
4238
4381
  // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
4239
4382
  identityGrounding: this.identityGroundingView(),
4240
4383
  /* 원본과 어긋난 사실 — 0 이면 싣지 않는다(어긋난 적 없는 트윈에 빈 칸을 만들지 않는다). */
4241
- ...this.transformInputsAbsent || this.seedDanglingRefs ? { conformance: {
4384
+ ...this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps ? { conformance: {
4242
4385
  ...this.transformInputsAbsent ? { transformInputsAbsent: this.transformInputsAbsent } : {},
4243
- ...this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {}
4386
+ ...this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {},
4387
+ ...this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {}
4244
4388
  } } : {},
4245
4389
  /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
4246
4390
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
@@ -5376,7 +5520,8 @@ var FlowEngine = class {
5376
5520
  * 작업이 같은 부품을 또 잡는다). 산출(`produced`)은 여기서 다루지 않는다(완료 시점의 일이다).
5377
5521
  */
5378
5522
  claimMaterials(t) {
5379
- const need = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter((m) => m.use === "consumed");
5523
+ const general = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter((m) => m.use === "consumed");
5524
+ const need = this.mergeMaterialNeeds(general, this.recipeInputsAt(t), t.kind);
5380
5525
  if (!need.length) return [];
5381
5526
  const at = t.toNode;
5382
5527
  const picked = [];
@@ -5387,6 +5532,7 @@ var FlowEngine = class {
5387
5532
  for (const it of this.items.at(at)) {
5388
5533
  if (remaining <= 0) break;
5389
5534
  if (!this.materialMatches(it, req)) continue;
5535
+ if (it.disposition === DISP.in_progress) continue;
5390
5536
  const already = takenSoFar.get(it.epc) ?? 0;
5391
5537
  const avail = Math.max(0, (it.qty ?? 1) - already);
5392
5538
  if (avail <= 0) continue;
@@ -5486,7 +5632,7 @@ var FlowEngine = class {
5486
5632
  requireObjectId(id) {
5487
5633
  const uri = this.declaredObjectId(id);
5488
5634
  if (uri) return uri;
5489
- throw new Error(this.identityMissing("object"));
5635
+ throw new Error(this.identityMissing("an object"));
5490
5636
  }
5491
5637
  /** 거래 문서 식별자 — 선언된 이름공간 또는 선언된 GDTI 문서 타입. 둘 다 없으면 오류를 낸다. */
5492
5638
  requireBizTransactionId(id, docKind) {
@@ -5496,11 +5642,17 @@ var FlowEngine = class {
5496
5642
  const docType = decl?.documentTypes?.[docKind];
5497
5643
  const prefix = decl?.companyPrefix;
5498
5644
  if (docType && prefix) return gdtiUri(prefix, docType, Number(id) || 0);
5499
- throw new Error(this.identityMissing(`business transaction '${docKind}'`));
5645
+ throw new Error(this.identityMissing(`the business transaction '${docKind}'`));
5500
5646
  }
5501
- /** 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다. */
5647
+ /**
5648
+ * 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다.
5649
+ *
5650
+ * `what` 은 **관사까지 갖춘 구**를 받는다(`'an object'`). 예전에는 여기서 `a ${what}` 로 관사를
5651
+ * 붙였고, 화면에 「cannot name a object」·「cannot name a business transaction 'purchase'」가 그대로
5652
+ * 나왔다. 사람이 읽는 문장이므로 부르는 자리가 관사를 정한다.
5653
+ */
5502
5654
  identityMissing(what) {
5503
- return `this twin has no identity declaration, so the kernel cannot name a ${what}. Declare \`identity.namespaces\` on the model (CBV 2.0 \xA78.2.4 \`.../obj/<id>\` \xB7 \xA78.5.5 \`.../bt/<id>\` \u2014 assigned by the owner of that internet domain), or \`identity.documentTypes\` with \`identity.companyPrefix\` for GS1 keys. The kernel does not invent a company prefix: GS1 assigns it to a company, and a value the kernel picked would sit in the journal forever with nothing saying it was invented.`;
5655
+ return `this twin has no identity declaration, so the kernel cannot name ${what}. Declare \`identity.namespaces\` on the model (CBV 2.0 \xA78.2.4 \`.../obj/<id>\` \xB7 \xA78.5.5 \`.../bt/<id>\` \u2014 assigned by the owner of that internet domain), or \`identity.documentTypes\` with \`identity.companyPrefix\` for GS1 keys. The kernel does not invent a company prefix: GS1 assigns it to a company, and a value the kernel picked would sit in the journal forever with nothing saying it was invented.`;
5504
5656
  }
5505
5657
  /** 선언된 이름공간 아래의 **거래 문서 식별자**(발주·주문·어포인트먼트) — 없으면 답하지 않는다. */
5506
5658
  declaredBizTransactionId(id) {
@@ -5640,14 +5792,97 @@ var FlowEngine = class {
5640
5792
  this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom);
5641
5793
  whole.push(epc);
5642
5794
  }
5643
- if (whole.length) {
5644
- this.transform(whole, [], {
5795
+ if (!whole.length) return;
5796
+ if (this.adoptConsumed(t, whole)) {
5797
+ this.observeDisposition(whole, DISP.in_progress, CBV_BIZSTEP.consuming);
5798
+ return;
5799
+ }
5800
+ const at = this.items.get(whole[0])?.location ?? t.toNode;
5801
+ for (const epc of whole) {
5802
+ const it = this.items.get(epc);
5803
+ if (!it) continue;
5804
+ const n = this.locations.get(it.location);
5805
+ if (n) n.occupancy--;
5806
+ this.items.delete(epc);
5807
+ }
5808
+ this.emit(
5809
+ objectEvent({
5810
+ eventTime: this.now(),
5811
+ action: "DELETE",
5645
5812
  bizStep: CBV_BIZSTEP.consuming,
5646
5813
  disposition: DISP.in_progress,
5647
- transformationId: t.id,
5648
- readPoint: this.items.get(whole[0])?.location ?? t.toNode
5649
- });
5814
+ epcList: whole.slice(),
5815
+ readPoint: at,
5816
+ bizLocation: at
5817
+ })
5818
+ );
5819
+ }
5820
+ /**
5821
+ * **소비된 자재를 도메인이 계보로 가져가는가** — 가져가면 코어는 없애지 않고 예약만 한다.
5822
+ *
5823
+ * 공정이 먹은 자재는 **그 단계가 만드는 것의 입력**이다. 그것을 별도 사건으로 없애면 제품의 계보에서
5824
+ * 그 자재가 빠지고, 회수 범위를 되짚을 때 조용히 좁아진다 — 식품이라면 그것이 사고다.
5825
+ *
5826
+ * 기본은 「가져가지 않는다」다: 만드는 것이 없는 소비(소모품·유통가공)는 개체가 사라진 것이 맞다.
5827
+ */
5828
+ adoptConsumed(_t, _epcs) {
5829
+ return false;
5830
+ }
5831
+ /** 이 트윈이 사는 동안 한 번만 알린 상회 — 같은 말을 틱마다 반복하지 않는다. */
5832
+ announcedOverrides = /* @__PURE__ */ new Set();
5833
+ /**
5834
+ * **구체가 일반을 이긴다 — 다만 상회하는 단위는 자재 한 줄이다.**
5835
+ *
5836
+ * ── 왜 합집합이 아닌가 (2026-08-22) ───────────────────────────────────────
5837
+ * 두 원천이 같은 자재를 말할 수 있다. 일반은 「이 공정이 늘 쓰는 것」이고(품목과 무관 — 포장 필름·
5838
+ * 세척수), 구체는 「이 품목을 이 공정에서 만들 때」다. 같은 자재를 둘이 말하면 **구체가 현장의 사실**
5839
+ * 이므로 이긴다. 합집합이면 요구가 더해져 재고가 거짓이 된다.
5840
+ *
5841
+ * ── 왜 명세 전체를 덮지 않는가 ────────────────────────────────────────────
5842
+ * 덮으면 반대로 틀린다. 레시피가 「무말랭이 90kg」만 말했다고 그 공정의 세척수 50L 이 사라지면, 품목과
5843
+ * 무관하게 늘 들어가는 것이 빠진다 — 그것이 일반 자리의 존재 이유다. 요구의 단위가 자재 한 줄이므로
5844
+ * 상회도 그 단위에서 일어난다.
5845
+ *
5846
+ * ── 등급 ↔ 품목이 교차하면 둘 다 요구한다 ─────────────────────────────────
5847
+ * 명세는 품목(`materialDefinition`)으로도 등급(`materialClass`)으로도 요구한다. 일반이 등급을, 구체가
5848
+ * 품목을 말하고 그 품목이 그 등급에 속하면 뜻으로는 상회지만, 그 판정에는 등급 소속이 필요하고 잘못
5849
+ * 겹치면 자재가 **조용히 사라지거나 두 배**가 된다. 그래서 **같은 키끼리만** 상회시키고, 교차하는
5850
+ * 경우는 세어 남기고 둘 다 요구한다 — 조용히 한쪽을 버리는 것이 가장 나쁘다.
5851
+ */
5852
+ mergeMaterialNeeds(general, specific, opKey) {
5853
+ if (!specific.length) return general;
5854
+ if (!general.length) return specific;
5855
+ const keyOf = (m) => m.materialDefinition ? `def:${m.materialDefinition}` : m.materialClass ? `cls:${m.materialClass}` : "";
5856
+ const beaten = new Set(specific.map(keyOf).filter(Boolean));
5857
+ const out = [...specific];
5858
+ for (const g of general) {
5859
+ const k = keyOf(g);
5860
+ if (k && beaten.has(k)) {
5861
+ const note = `${opKey}|${k}`;
5862
+ if (!this.announcedOverrides.has(note)) {
5863
+ this.announcedOverrides.add(note);
5864
+ console.warn(
5865
+ `[twin] operation '${opKey}': the recipe's own requirement for ${k} overrides this operation's general requirement (the specific declaration wins). The operation's other lines still apply.`
5866
+ );
5867
+ }
5868
+ continue;
5869
+ }
5870
+ const crosses = g.materialClass ? specific.some((sp) => !!sp.materialDefinition) : g.materialDefinition ? specific.some((sp) => !!sp.materialClass) : false;
5871
+ if (crosses) this.materialSpecCrossKeyOverlaps++;
5872
+ out.push(g);
5650
5873
  }
5874
+ return out;
5875
+ }
5876
+ /**
5877
+ * **그 작업이 만드는 품목의, 그 공정 몫** — 품목 범위를 아는 것은 도메인이다.
5878
+ *
5879
+ * 코어는 레시피를 모른다(창고·야드에는 레시피가 없다). 그래서 시임으로 둔다 — MES 가 오더의
5880
+ * 레시피에서 그 공정에 태그된 투입을 돌려준다(§`RecipePart.operation`).
5881
+ *
5882
+ * 기본은 빈 목록이다: 품목 범위가 없는 트윈에서는 공정 명세만이 요구다.
5883
+ */
5884
+ recipeInputsAt(_t) {
5885
+ return [];
5651
5886
  }
5652
5887
  /**
5653
5888
  * 이 사람이 **속한 등급들이 요구하는 시험**을 만족하나.
@@ -6655,6 +6890,7 @@ var MesKernel = class extends FlowEngine {
6655
6890
  if (productionSpec?.definition?.operations) {
6656
6891
  this.assertNoDoubleProduction(productionSpec.definition.operations);
6657
6892
  }
6893
+ this.assertRecipeOperationTags();
6658
6894
  }
6659
6895
  /**
6660
6896
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
@@ -6681,6 +6917,53 @@ var MesKernel = class extends FlowEngine {
6681
6917
  `MES recipe already produces its output \u2014 operations [${bad2.join(", ")}] must not also declare that same material as materialSpecification use:'produced' (that would create the same output twice). Declare intermediate outputs freely; the final output belongs to the recipe.`
6682
6918
  );
6683
6919
  }
6920
+ /**
6921
+ * **레시피 투입의 공정 태그를 기동에서 검사한다** — 어긋나면 그 자재는 영원히 확보되지 않는다.
6922
+ *
6923
+ * ── 두 가지를 본다 (2026-08-22) ───────────────────────────────────────────
6924
+ * ① **태그의 공정이 그 레시피의 라우트 단계에 있어야 한다.** 없으면 그 투입은 확보되는 시점이 오지
6925
+ * 않고, 오더는 영원히 그 단계에서 멈춘다 — 화면에는 이유가 없다. 실 마스터에서 BOM 이 말하는
6926
+ * 공정과 품목의 경로가 어긋나는 일이 실제로 있다(BOM 은 「조림」인데 경로에 조림이 없는 경우).
6927
+ * ② **한 레시피 안에서** 같은 자재가 태그 있는 줄과 없는 줄에 동시에 있으면 거부한다. 태그 없는 줄은
6928
+ * 오더 착수에 확보되고 태그 붙은 줄은 그 공정에서 확보되므로, 그 자재를 **두 번 먹는다.**
6929
+ *
6930
+ * 레시피와 **공정 명세**가 같은 자재를 말하는 것은 거부하지 않는다 — 그것은 상회이고 정상이다
6931
+ * (구체가 일반을 이긴다, §`mergeMaterialNeeds`).
6932
+ *
6933
+ * 런타임에 조용히 어긋나는 것보다 기동이 실패하는 편이 낫다.
6934
+ */
6935
+ assertRecipeOperationTags() {
6936
+ const def = this.productionSpec?.definition;
6937
+ if (!def?.recipes?.length) return;
6938
+ const stepsOf = new Map((def.routes ?? []).map((r) => [r.key, new Set(r.steps ?? [])]));
6939
+ const bad2 = [];
6940
+ for (const rc of def.recipes) {
6941
+ const tagged = rc.inputs.filter((i) => i.operation);
6942
+ if (tagged.length) {
6943
+ const steps = rc.route ? stepsOf.get(rc.route) : void 0;
6944
+ if (!steps) {
6945
+ bad2.push(`recipe '${rc.key}' tags inputs with an operation but declares no route \u2014 the tag has nothing to match`);
6946
+ } else {
6947
+ for (const line of tagged) {
6948
+ if (!steps.has(line.operation)) {
6949
+ bad2.push(
6950
+ `recipe '${rc.key}' input '${line.material}' is tagged for operation '${line.operation}', which is not a step of its route '${rc.route}' (steps: ${[...steps].join(" > ") || "(none)"}) \u2014 that material would never be claimed and the order would wait forever with no reason on screen`
6951
+ );
6952
+ }
6953
+ }
6954
+ }
6955
+ }
6956
+ const withTag = new Set(tagged.map((i) => i.material));
6957
+ for (const line of rc.inputs) {
6958
+ if (!line.operation && withTag.has(line.material)) {
6959
+ bad2.push(
6960
+ `recipe '${rc.key}' declares material '${line.material}' both with and without an operation tag \u2014 the untagged line is claimed at order start and the tagged one at its step, so it would be consumed twice`
6961
+ );
6962
+ }
6963
+ }
6964
+ }
6965
+ if (bad2.length) throw new Error(bad2.join("; "));
6966
+ }
6684
6967
  /**
6685
6968
  * **레시피 모드에서는 MES 가 산출을 소유한다** — 코어는 비켜선다(§`producesOwnOutputs`).
6686
6969
  *
@@ -6838,11 +7121,75 @@ var MesKernel = class extends FlowEngine {
6838
7121
  onTaskComplete(t) {
6839
7122
  return this.onTaskCompleteDef(t);
6840
7123
  }
7124
+ /**
7125
+ * **공정이 먹은 자재는 오더의 계보에 합류한다** — 그 단계가 만드는 것의 입력이 된다.
7126
+ *
7127
+ * ── 무엇이 빠져 있었나 (2026-08-22) ───────────────────────────────────────
7128
+ * 공정별 자재(`OperationDef.materialSpecification` `use:'consumed'`, ISA-95
7129
+ * `OperationsSegment.MaterialSpecification`)는 커널이 이미 확보하고 소비했다. 그런데 그 자재가
7130
+ * **오더가 들고 있는 것에 들어가지 않았다.** 단계의 변환 입력은 `order.allocated` 뿐이라, 뒤 공정에서
7131
+ * 먹은 자재가 제품의 계보에서 빠졌다 — 회수 범위를 되짚으면 그 자재가 조용히 없다.
7132
+ *
7133
+ * 레시피가 없는 모드(유통가공)는 산출을 코어가 만들므로 가져가지 않는다(§`producesOwnOutputs`).
7134
+ */
7135
+ /**
7136
+ * **그 오더의 레시피에서, 이 공정에 태그된 투입** — 품목 범위를 아는 것은 여기다.
7137
+ *
7138
+ * 오더가 없는 작업에는 답하지 않는다(창고 입고 등 — 그때는 공정 명세만이 요구다).
7139
+ */
7140
+ recipeInputsAt(t) {
7141
+ if (!t.orderId) return [];
7142
+ const order = this.orders.get(t.orderId);
7143
+ if (!order) return [];
7144
+ if (!this.productionSpec?.definition?.recipes?.length) return [];
7145
+ const rc = this.recipeDef(order);
7146
+ const out = [];
7147
+ for (const line of rc.inputs) {
7148
+ if (line.operation !== t.kind) continue;
7149
+ out.push({ use: "consumed", materialDefinition: this.classOf(line.material), quantity: line.qty });
7150
+ }
7151
+ return out;
7152
+ }
7153
+ adoptConsumed(t, epcs) {
7154
+ if (!epcs.length) return false;
7155
+ if (!this.producesOwnOutputs(t.kind)) return false;
7156
+ const order = t.orderId ? this.orders.get(t.orderId) : void 0;
7157
+ if (!order) return false;
7158
+ for (const epc of epcs) order.allocated.push(epc);
7159
+ return true;
7160
+ }
6841
7161
  // ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
6842
7162
  /** 선언된 레시피 전부 — 오더가 자기 것을 고르고, 수령이 전부의 소요를 본다. */
6843
7163
  recipesDef() {
6844
7164
  return this.productionSpec.definition.recipes ?? [];
6845
7165
  }
7166
+ /**
7167
+ * **소비되는 자재 전부** — 선언이 그것을 말하는 자리는 둘이고, 둘 다 본다.
7168
+ *
7169
+ * ── 왜 둘인가 (2026-08-22) ────────────────────────────────────────────────
7170
+ * ① `RecipeDef.inputs` — 레시피가 쓰는 자재. 오더가 시작될 때 확보한다.
7171
+ * ② `OperationDef.materialSpecification` `use:'consumed'` — **그 공정에서만** 들어가는 자재
7172
+ * (ISA-95 `OperationsSegment.MaterialSpecification`). 같은 부품이라도 공정마다 소요가 다르고,
7173
+ * 표준은 「몇 개」를 공정의 사실로 둔다.
7174
+ *
7175
+ * 예전에는 수령이 ①만 봤다. 그래서 ②에만 선언된 자재는 **한 번도 입고되지 않았고**, 그 공정은 영원히
7176
+ * 기다렸다 — 화면에는 이유가 없었다. 첫 실 연동의 BOM 이 (품목, 공정) 단위라 이 자리가 바로 막혔다.
7177
+ *
7178
+ * `binding` 을 지나지 않는 이름은 여기서 세지 않는다 — 커널이 그 자재의 정체성을 만들 수 없으므로
7179
+ * **입고를 만들 수 없다**(밖에서 들어온 물품은 `claimMaterials` 가 클래스 문자열로 알아본다).
7180
+ */
7181
+ consumedMaterialKeys() {
7182
+ const keys = new Set(this.recipesDef().flatMap((r) => r.inputs.map((i) => i.material)));
7183
+ const binding = this.productionSpec?.binding ?? {};
7184
+ for (const op of this.productionSpec?.definition?.operations ?? []) {
7185
+ for (const m of op.materialSpecification ?? []) {
7186
+ if (m.use !== "consumed" || !m.materialDefinition) continue;
7187
+ const k = Object.keys(binding).find((key) => this.classOf(key) === m.materialDefinition);
7188
+ if (k) keys.add(k);
7189
+ }
7190
+ }
7191
+ return [...keys];
7192
+ }
6846
7193
  /**
6847
7194
  * 이 오더의 레시피 — **오더가 들면 그것, 없으면 선언 수준의 기본**(§`FlowOrder.recipeKey`).
6848
7195
  *
@@ -6961,6 +7308,12 @@ var MesKernel = class extends FlowEngine {
6961
7308
  * 레거시(선언 없는 내장 프로파일) 경로는 이 함수를 쓰지 않는다 — 그쪽에는 대조할 선언이 없으므로
6962
7309
  * 커널 어휘 자체가 계약이다.
6963
7310
  */
7311
+ /** 자리를 선언하지 않아 입고를 만들지 못한 자재 — 같은 말을 틱마다 반복하지 않는다. */
7312
+ arrivalsWithoutPlace = /* @__PURE__ */ new Set();
7313
+ /** 그 자재가 선언한 보관처 타입 — **없으면 undefined**(정책이 없다는 사실이다). */
7314
+ declaredLocationTypeOfMaterial(materialKey) {
7315
+ return this.productionSpec.definition.materials?.find((m) => m.key === materialKey)?.locationType;
7316
+ }
6964
7317
  locationTypeOfMaterial(materialKey) {
6965
7318
  const type = this.productionSpec.definition.materials?.find((m) => m.key === materialKey)?.locationType;
6966
7319
  if (!type) throw new Error(`material '${materialKey}': MaterialDef.locationType \uBBF8\uC120\uC5B8 \u2014 \uC790\uC7AC\uAC00 \uB193\uC774\uB294 \uC790\uB9AC\uB97C \uCEE4\uB110\uC774 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4`);
@@ -7006,8 +7359,17 @@ var MesKernel = class extends FlowEngine {
7006
7359
  onArrivalDef(spec) {
7007
7360
  const gtin = this.pickGtin(spec.content.skuMix);
7008
7361
  if (!gtin) return;
7009
- const inputKey = this.recipesDef().flatMap((r) => r.inputs.map((i) => i.material)).find((k) => this.classOf(k) === gtin);
7362
+ const inputKey = this.consumedMaterialKeys().find((k) => this.classOf(k) === gtin);
7010
7363
  if (!inputKey) return;
7364
+ if (!this.declaredLocationTypeOfMaterial(inputKey)) {
7365
+ if (!this.arrivalsWithoutPlace.has(inputKey)) {
7366
+ this.arrivalsWithoutPlace.add(inputKey);
7367
+ console.warn(
7368
+ `[twin] material '${inputKey}' declares no locationType, so simulated arrivals cannot be created for it (the kernel does not invent a place for material to sit). Stock for it must come from the source, or declare \`MaterialDef.locationType\`. Allocation still works \u2014 it looks wherever the stock is.`
7369
+ );
7370
+ }
7371
+ return;
7372
+ }
7011
7373
  const store = this.locationOfMaterial(inputKey);
7012
7374
  const epc = this.serialOf(inputKey, ++this.epcSeq);
7013
7375
  this.items.set(epc, { epc, gtin, qty: 1, location: store.id, disposition: DISP.sellable });
@@ -7079,12 +7441,19 @@ var MesKernel = class extends FlowEngine {
7079
7441
  else locsOfType.set(n.type, [n]);
7080
7442
  }
7081
7443
  for (const line of rc.inputs) {
7444
+ if (line.operation) continue;
7082
7445
  const g = this.classOf(line.material);
7083
- const fromType = this.locationTypeOfMaterial(line.material);
7446
+ const fromType = this.declaredLocationTypeOfMaterial(line.material);
7084
7447
  const available = [];
7085
- for (const n of locsOfType.get(fromType) ?? []) {
7086
- for (const i of this.items.at(n.id)) {
7087
- if (i.gtin === g && i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
7448
+ if (fromType) {
7449
+ for (const n of locsOfType.get(fromType) ?? []) {
7450
+ for (const i of this.items.at(n.id)) {
7451
+ if (i.gtin === g && i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
7452
+ }
7453
+ }
7454
+ } else {
7455
+ for (const i of this.items.ofGtin(g)) {
7456
+ if (i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
7088
7457
  }
7089
7458
  }
7090
7459
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
@@ -7143,7 +7512,8 @@ var MesKernel = class extends FlowEngine {
7143
7512
  this.emitOrder(order);
7144
7513
  return;
7145
7514
  }
7146
- const fgStore = this.locationOfMaterial(rc.outputs[0].material);
7515
+ const declaredFg = this.declaredLocationTypeOfMaterial(rc.outputs[0].material);
7516
+ const fgStore = declaredFg ? this.locationOfMaterial(rc.outputs[0].material) : loc;
7147
7517
  const consumed = order.allocated.slice();
7148
7518
  if (!consumed.length) {
7149
7519
  if (order.seedIncomplete) {
@@ -8608,6 +8978,7 @@ function retiredVocabularyIn(line) {
8608
8978
  gdtiUri,
8609
8979
  generationFractionAt,
8610
8980
  graiUri,
8981
+ gs1KeyDigitViolation,
8611
8982
  hierarchyOf,
8612
8983
  identityGroundingOf,
8613
8984
  inWorkCalendar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.47",
3
+ "version": "0.7.49",
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": {