@operato/twin-kernel 0.7.46 → 0.7.48

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");
@@ -3401,6 +3433,22 @@ function energyFieldsOf(m) {
3401
3433
  var ItemStore = class _ItemStore {
3402
3434
  map = /* @__PURE__ */ new Map();
3403
3435
  byLocation = /* @__PURE__ */ new Map();
3436
+ /**
3437
+ * 품목별 색인 — **보관처를 선언하지 않은 현장을 위해.**
3438
+ *
3439
+ * ── 왜 필요한가 (2026-08-22) ──────────────────────────────────────────────
3440
+ * 자재를 확보할 때 예전에는 「그 자재의 보관처 타입」을 반드시 선언해야 했다(`MaterialDef.locationType`).
3441
+ * 그런데 **재고로 위치를 말하는 시스템**에는 그 선언이 없다 — 자재에 고정된 보관처를 두지 않는 것이
3442
+ * WMS 계열의 정상이다. 실측: 첫 실 연동에서 원자재 986건 중 보관처가 선언된 것이 **36건**이었고, 그
3443
+ * 때문에 레시피 937/1,408 건이 아예 실리지 못했다.
3444
+ *
3445
+ * 선언이 없으면 **재고가 있는 곳에서 찾는다.** 그때 전 로케이션을 훑으면 규모 기준선(품목 100만)에서
3446
+ * 감당되지 않으므로 품목 색인이 답한다.
3447
+ *
3448
+ * 색인을 밖에 따로 두지 않는다 — 갱신하는 자리가 흩어지면 한 자리라도 빠뜨렸을 때 **재고가 조용히
3449
+ * 사라진다**(있는데 없다고 판정된다). 자리 색인과 같은 규율이다.
3450
+ */
3451
+ byGtin = /* @__PURE__ */ new Map();
3404
3452
  get size() {
3405
3453
  return this.map.size;
3406
3454
  }
@@ -3427,19 +3475,20 @@ var ItemStore = class _ItemStore {
3427
3475
  }
3428
3476
  set(key, item) {
3429
3477
  const prev = this.map.get(key);
3430
- if (prev) this.unindex(key, prev.location);
3478
+ if (prev) this.unindex(key, prev.location, prev.gtin);
3431
3479
  this.map.set(key, item);
3432
- this.index(key, item.location);
3480
+ this.index(key, item.location, item.gtin);
3433
3481
  return this;
3434
3482
  }
3435
3483
  delete(key) {
3436
3484
  const prev = this.map.get(key);
3437
- if (prev) this.unindex(key, prev.location);
3485
+ if (prev) this.unindex(key, prev.location, prev.gtin);
3438
3486
  return this.map.delete(key);
3439
3487
  }
3440
3488
  clear() {
3441
3489
  this.map.clear();
3442
3490
  this.byLocation.clear();
3491
+ this.byGtin.clear();
3443
3492
  }
3444
3493
  /**
3445
3494
  * 물품을 다른 자리로 옮긴다 — **색인이 함께 움직이는 유일한 통로.**
@@ -3451,9 +3500,9 @@ var ItemStore = class _ItemStore {
3451
3500
  const it = this.map.get(key);
3452
3501
  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
3502
  if (it.location !== to) {
3454
- this.unindex(key, it.location);
3503
+ this.unindexLocation(key, it.location);
3455
3504
  it.location = to;
3456
- this.index(key, to);
3505
+ this.indexLocation(key, to);
3457
3506
  }
3458
3507
  return it;
3459
3508
  }
@@ -3485,6 +3534,17 @@ var ItemStore = class _ItemStore {
3485
3534
  }
3486
3535
  return out;
3487
3536
  }
3537
+ /** 그 품목인 물품들 — 색인이 답한다(자리를 모를 때 쓴다). */
3538
+ ofGtin(gtin) {
3539
+ const keys = this.byGtin.get(gtin);
3540
+ if (!keys) return [];
3541
+ const out = [];
3542
+ for (const k of keys) {
3543
+ const it = this.map.get(k);
3544
+ if (it) out.push(it);
3545
+ }
3546
+ return out;
3547
+ }
3488
3548
  /**
3489
3549
  * 색인이 맵과 어긋난 자리 — **시험이 쓰는 확인 통로**(전체를 다시 세므로 비싸다).
3490
3550
  *
@@ -3503,14 +3563,41 @@ var ItemStore = class _ItemStore {
3503
3563
  else if (it.location !== loc) drift.push(`${k} \uC740 '${it.location}' \uC5D0 \uC788\uB294\uB370 '${loc}' \uC0C9\uC778\uC5D0 \uC788\uB2E4`);
3504
3564
  }
3505
3565
  }
3566
+ for (const [key, it] of this.map) {
3567
+ if (it.gtin && !this.byGtin.get(it.gtin)?.has(key)) drift.push(`${key} \uC774 \uD488\uBAA9 '${it.gtin}' \uC0C9\uC778\uC5D0 \uC5C6\uB2E4`);
3568
+ }
3569
+ for (const [g, keys] of this.byGtin) {
3570
+ for (const k of keys) {
3571
+ const it = this.map.get(k);
3572
+ if (!it) drift.push(`${k} \uC774 \uC9C0\uC6CC\uC84C\uB294\uB370 \uD488\uBAA9 '${g}' \uC0C9\uC778\uC5D0 \uB0A8\uC544 \uC788\uB2E4`);
3573
+ else if (it.gtin !== g) drift.push(`${k} \uC740 \uD488\uBAA9 '${it.gtin}' \uC778\uB370 '${g}' \uC0C9\uC778\uC5D0 \uC788\uB2E4`);
3574
+ }
3575
+ }
3506
3576
  return drift;
3507
3577
  }
3508
- index(key, location) {
3578
+ index(key, location, gtin) {
3579
+ this.indexLocation(key, location);
3580
+ if (gtin) {
3581
+ const set = this.byGtin.get(gtin) ?? /* @__PURE__ */ new Set();
3582
+ set.add(key);
3583
+ this.byGtin.set(gtin, set);
3584
+ }
3585
+ }
3586
+ indexLocation(key, location) {
3509
3587
  const set = this.byLocation.get(location) ?? /* @__PURE__ */ new Set();
3510
3588
  set.add(key);
3511
3589
  this.byLocation.set(location, set);
3512
3590
  }
3513
- unindex(key, location) {
3591
+ unindex(key, location, gtin) {
3592
+ this.unindexLocation(key, location);
3593
+ if (gtin) {
3594
+ const set = this.byGtin.get(gtin);
3595
+ if (!set) return;
3596
+ set.delete(key);
3597
+ if (!set.size) this.byGtin.delete(gtin);
3598
+ }
3599
+ }
3600
+ unindexLocation(key, location) {
3514
3601
  const set = this.byLocation.get(location);
3515
3602
  if (!set) return;
3516
3603
  set.delete(key);
@@ -3599,6 +3686,14 @@ var FlowEngine = class {
3599
3686
  */
3600
3687
  seedDanglingRefs = 0;
3601
3688
  transformInputsAbsent = 0;
3689
+ /**
3690
+ * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
3691
+ *
3692
+ * 같은 키끼리는 구체가 상회한다(§`mergeMaterialNeeds`). 교차는 뜻으로는 상회일 수 있으나 판정에 등급
3693
+ * 소속이 필요하고, 잘못 겹치면 자재가 조용히 사라지거나 두 배가 된다. 그래서 **둘 다 요구하고 센다** —
3694
+ * 이 값이 크면 그 숫자가 다음 작업을 정한다.
3695
+ */
3696
+ materialSpecCrossKeyOverlaps = 0;
3602
3697
  observedDirty = false;
3603
3698
  observeMode = false;
3604
3699
  /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
@@ -4238,9 +4333,10 @@ var FlowEngine = class {
4238
4333
  // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
4239
4334
  identityGrounding: this.identityGroundingView(),
4240
4335
  /* 원본과 어긋난 사실 — 0 이면 싣지 않는다(어긋난 적 없는 트윈에 빈 칸을 만들지 않는다). */
4241
- ...this.transformInputsAbsent || this.seedDanglingRefs ? { conformance: {
4336
+ ...this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps ? { conformance: {
4242
4337
  ...this.transformInputsAbsent ? { transformInputsAbsent: this.transformInputsAbsent } : {},
4243
- ...this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {}
4338
+ ...this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {},
4339
+ ...this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {}
4244
4340
  } } : {},
4245
4341
  /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
4246
4342
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
@@ -5376,7 +5472,8 @@ var FlowEngine = class {
5376
5472
  * 작업이 같은 부품을 또 잡는다). 산출(`produced`)은 여기서 다루지 않는다(완료 시점의 일이다).
5377
5473
  */
5378
5474
  claimMaterials(t) {
5379
- const need = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter((m) => m.use === "consumed");
5475
+ const general = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter((m) => m.use === "consumed");
5476
+ const need = this.mergeMaterialNeeds(general, this.recipeInputsAt(t), t.kind);
5380
5477
  if (!need.length) return [];
5381
5478
  const at = t.toNode;
5382
5479
  const picked = [];
@@ -5387,6 +5484,7 @@ var FlowEngine = class {
5387
5484
  for (const it of this.items.at(at)) {
5388
5485
  if (remaining <= 0) break;
5389
5486
  if (!this.materialMatches(it, req)) continue;
5487
+ if (it.disposition === DISP.in_progress) continue;
5390
5488
  const already = takenSoFar.get(it.epc) ?? 0;
5391
5489
  const avail = Math.max(0, (it.qty ?? 1) - already);
5392
5490
  if (avail <= 0) continue;
@@ -5486,7 +5584,7 @@ var FlowEngine = class {
5486
5584
  requireObjectId(id) {
5487
5585
  const uri = this.declaredObjectId(id);
5488
5586
  if (uri) return uri;
5489
- throw new Error(this.identityMissing("object"));
5587
+ throw new Error(this.identityMissing("an object"));
5490
5588
  }
5491
5589
  /** 거래 문서 식별자 — 선언된 이름공간 또는 선언된 GDTI 문서 타입. 둘 다 없으면 오류를 낸다. */
5492
5590
  requireBizTransactionId(id, docKind) {
@@ -5496,11 +5594,17 @@ var FlowEngine = class {
5496
5594
  const docType = decl?.documentTypes?.[docKind];
5497
5595
  const prefix = decl?.companyPrefix;
5498
5596
  if (docType && prefix) return gdtiUri(prefix, docType, Number(id) || 0);
5499
- throw new Error(this.identityMissing(`business transaction '${docKind}'`));
5597
+ throw new Error(this.identityMissing(`the business transaction '${docKind}'`));
5500
5598
  }
5501
- /** 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다. */
5599
+ /**
5600
+ * 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다.
5601
+ *
5602
+ * `what` 은 **관사까지 갖춘 구**를 받는다(`'an object'`). 예전에는 여기서 `a ${what}` 로 관사를
5603
+ * 붙였고, 화면에 「cannot name a object」·「cannot name a business transaction 'purchase'」가 그대로
5604
+ * 나왔다. 사람이 읽는 문장이므로 부르는 자리가 관사를 정한다.
5605
+ */
5502
5606
  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.`;
5607
+ 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
5608
  }
5505
5609
  /** 선언된 이름공간 아래의 **거래 문서 식별자**(발주·주문·어포인트먼트) — 없으면 답하지 않는다. */
5506
5610
  declaredBizTransactionId(id) {
@@ -5640,14 +5744,97 @@ var FlowEngine = class {
5640
5744
  this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom);
5641
5745
  whole.push(epc);
5642
5746
  }
5643
- if (whole.length) {
5644
- this.transform(whole, [], {
5747
+ if (!whole.length) return;
5748
+ if (this.adoptConsumed(t, whole)) {
5749
+ this.observeDisposition(whole, DISP.in_progress, CBV_BIZSTEP.consuming);
5750
+ return;
5751
+ }
5752
+ const at = this.items.get(whole[0])?.location ?? t.toNode;
5753
+ for (const epc of whole) {
5754
+ const it = this.items.get(epc);
5755
+ if (!it) continue;
5756
+ const n = this.locations.get(it.location);
5757
+ if (n) n.occupancy--;
5758
+ this.items.delete(epc);
5759
+ }
5760
+ this.emit(
5761
+ objectEvent({
5762
+ eventTime: this.now(),
5763
+ action: "DELETE",
5645
5764
  bizStep: CBV_BIZSTEP.consuming,
5646
5765
  disposition: DISP.in_progress,
5647
- transformationId: t.id,
5648
- readPoint: this.items.get(whole[0])?.location ?? t.toNode
5649
- });
5766
+ epcList: whole.slice(),
5767
+ readPoint: at,
5768
+ bizLocation: at
5769
+ })
5770
+ );
5771
+ }
5772
+ /**
5773
+ * **소비된 자재를 도메인이 계보로 가져가는가** — 가져가면 코어는 없애지 않고 예약만 한다.
5774
+ *
5775
+ * 공정이 먹은 자재는 **그 단계가 만드는 것의 입력**이다. 그것을 별도 사건으로 없애면 제품의 계보에서
5776
+ * 그 자재가 빠지고, 회수 범위를 되짚을 때 조용히 좁아진다 — 식품이라면 그것이 사고다.
5777
+ *
5778
+ * 기본은 「가져가지 않는다」다: 만드는 것이 없는 소비(소모품·유통가공)는 개체가 사라진 것이 맞다.
5779
+ */
5780
+ adoptConsumed(_t, _epcs) {
5781
+ return false;
5782
+ }
5783
+ /** 이 트윈이 사는 동안 한 번만 알린 상회 — 같은 말을 틱마다 반복하지 않는다. */
5784
+ announcedOverrides = /* @__PURE__ */ new Set();
5785
+ /**
5786
+ * **구체가 일반을 이긴다 — 다만 상회하는 단위는 자재 한 줄이다.**
5787
+ *
5788
+ * ── 왜 합집합이 아닌가 (2026-08-22) ───────────────────────────────────────
5789
+ * 두 원천이 같은 자재를 말할 수 있다. 일반은 「이 공정이 늘 쓰는 것」이고(품목과 무관 — 포장 필름·
5790
+ * 세척수), 구체는 「이 품목을 이 공정에서 만들 때」다. 같은 자재를 둘이 말하면 **구체가 현장의 사실**
5791
+ * 이므로 이긴다. 합집합이면 요구가 더해져 재고가 거짓이 된다.
5792
+ *
5793
+ * ── 왜 명세 전체를 덮지 않는가 ────────────────────────────────────────────
5794
+ * 덮으면 반대로 틀린다. 레시피가 「무말랭이 90kg」만 말했다고 그 공정의 세척수 50L 이 사라지면, 품목과
5795
+ * 무관하게 늘 들어가는 것이 빠진다 — 그것이 일반 자리의 존재 이유다. 요구의 단위가 자재 한 줄이므로
5796
+ * 상회도 그 단위에서 일어난다.
5797
+ *
5798
+ * ── 등급 ↔ 품목이 교차하면 둘 다 요구한다 ─────────────────────────────────
5799
+ * 명세는 품목(`materialDefinition`)으로도 등급(`materialClass`)으로도 요구한다. 일반이 등급을, 구체가
5800
+ * 품목을 말하고 그 품목이 그 등급에 속하면 뜻으로는 상회지만, 그 판정에는 등급 소속이 필요하고 잘못
5801
+ * 겹치면 자재가 **조용히 사라지거나 두 배**가 된다. 그래서 **같은 키끼리만** 상회시키고, 교차하는
5802
+ * 경우는 세어 남기고 둘 다 요구한다 — 조용히 한쪽을 버리는 것이 가장 나쁘다.
5803
+ */
5804
+ mergeMaterialNeeds(general, specific, opKey) {
5805
+ if (!specific.length) return general;
5806
+ if (!general.length) return specific;
5807
+ const keyOf = (m) => m.materialDefinition ? `def:${m.materialDefinition}` : m.materialClass ? `cls:${m.materialClass}` : "";
5808
+ const beaten = new Set(specific.map(keyOf).filter(Boolean));
5809
+ const out = [...specific];
5810
+ for (const g of general) {
5811
+ const k = keyOf(g);
5812
+ if (k && beaten.has(k)) {
5813
+ const note = `${opKey}|${k}`;
5814
+ if (!this.announcedOverrides.has(note)) {
5815
+ this.announcedOverrides.add(note);
5816
+ console.warn(
5817
+ `[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.`
5818
+ );
5819
+ }
5820
+ continue;
5821
+ }
5822
+ const crosses = g.materialClass ? specific.some((sp) => !!sp.materialDefinition) : g.materialDefinition ? specific.some((sp) => !!sp.materialClass) : false;
5823
+ if (crosses) this.materialSpecCrossKeyOverlaps++;
5824
+ out.push(g);
5650
5825
  }
5826
+ return out;
5827
+ }
5828
+ /**
5829
+ * **그 작업이 만드는 품목의, 그 공정 몫** — 품목 범위를 아는 것은 도메인이다.
5830
+ *
5831
+ * 코어는 레시피를 모른다(창고·야드에는 레시피가 없다). 그래서 시임으로 둔다 — MES 가 오더의
5832
+ * 레시피에서 그 공정에 태그된 투입을 돌려준다(§`RecipePart.operation`).
5833
+ *
5834
+ * 기본은 빈 목록이다: 품목 범위가 없는 트윈에서는 공정 명세만이 요구다.
5835
+ */
5836
+ recipeInputsAt(_t) {
5837
+ return [];
5651
5838
  }
5652
5839
  /**
5653
5840
  * 이 사람이 **속한 등급들이 요구하는 시험**을 만족하나.
@@ -6655,6 +6842,7 @@ var MesKernel = class extends FlowEngine {
6655
6842
  if (productionSpec?.definition?.operations) {
6656
6843
  this.assertNoDoubleProduction(productionSpec.definition.operations);
6657
6844
  }
6845
+ this.assertRecipeOperationTags();
6658
6846
  }
6659
6847
  /**
6660
6848
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
@@ -6669,13 +6857,65 @@ var MesKernel = class extends FlowEngine {
6669
6857
  * 모든 계산이 거짓이 된다).
6670
6858
  */
6671
6859
  assertNoDoubleProduction(ops) {
6672
- const finals = new Set((this.productionSpec?.definition?.recipes ?? []).flatMap((r) => (r.outputs ?? []).map((o) => this.classOf(o.material))));
6673
- const bad2 = ops.filter((o) => (o.materialSpecification ?? []).some((m) => m.use === "produced" && m.materialDefinition && finals.has(m.materialDefinition))).map((o) => o.key);
6860
+ const finals = new Set((this.productionSpec?.definition?.recipes ?? []).flatMap((r) => (r.outputs ?? []).map((o) => o.material)));
6861
+ if (!finals.size) return;
6862
+ const bad2 = ops.filter(
6863
+ (o) => (o.materialSpecification ?? []).some(
6864
+ (m) => m.use === "produced" && m.materialDefinition && finals.has(this.materialKeyOfClass(m.materialDefinition, o.key))
6865
+ )
6866
+ ).map((o) => o.key);
6674
6867
  if (!bad2.length) return;
6675
6868
  throw new Error(
6676
6869
  `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.`
6677
6870
  );
6678
6871
  }
6872
+ /**
6873
+ * **레시피 투입의 공정 태그를 기동에서 검사한다** — 어긋나면 그 자재는 영원히 확보되지 않는다.
6874
+ *
6875
+ * ── 두 가지를 본다 (2026-08-22) ───────────────────────────────────────────
6876
+ * ① **태그의 공정이 그 레시피의 라우트 단계에 있어야 한다.** 없으면 그 투입은 확보되는 시점이 오지
6877
+ * 않고, 오더는 영원히 그 단계에서 멈춘다 — 화면에는 이유가 없다. 실 마스터에서 BOM 이 말하는
6878
+ * 공정과 품목의 경로가 어긋나는 일이 실제로 있다(BOM 은 「조림」인데 경로에 조림이 없는 경우).
6879
+ * ② **한 레시피 안에서** 같은 자재가 태그 있는 줄과 없는 줄에 동시에 있으면 거부한다. 태그 없는 줄은
6880
+ * 오더 착수에 확보되고 태그 붙은 줄은 그 공정에서 확보되므로, 그 자재를 **두 번 먹는다.**
6881
+ *
6882
+ * 레시피와 **공정 명세**가 같은 자재를 말하는 것은 거부하지 않는다 — 그것은 상회이고 정상이다
6883
+ * (구체가 일반을 이긴다, §`mergeMaterialNeeds`).
6884
+ *
6885
+ * 런타임에 조용히 어긋나는 것보다 기동이 실패하는 편이 낫다.
6886
+ */
6887
+ assertRecipeOperationTags() {
6888
+ const def = this.productionSpec?.definition;
6889
+ if (!def?.recipes?.length) return;
6890
+ const stepsOf = new Map((def.routes ?? []).map((r) => [r.key, new Set(r.steps ?? [])]));
6891
+ const bad2 = [];
6892
+ for (const rc of def.recipes) {
6893
+ const tagged = rc.inputs.filter((i) => i.operation);
6894
+ if (tagged.length) {
6895
+ const steps = rc.route ? stepsOf.get(rc.route) : void 0;
6896
+ if (!steps) {
6897
+ bad2.push(`recipe '${rc.key}' tags inputs with an operation but declares no route \u2014 the tag has nothing to match`);
6898
+ } else {
6899
+ for (const line of tagged) {
6900
+ if (!steps.has(line.operation)) {
6901
+ bad2.push(
6902
+ `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`
6903
+ );
6904
+ }
6905
+ }
6906
+ }
6907
+ }
6908
+ const withTag = new Set(tagged.map((i) => i.material));
6909
+ for (const line of rc.inputs) {
6910
+ if (!line.operation && withTag.has(line.material)) {
6911
+ bad2.push(
6912
+ `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`
6913
+ );
6914
+ }
6915
+ }
6916
+ }
6917
+ if (bad2.length) throw new Error(bad2.join("; "));
6918
+ }
6679
6919
  /**
6680
6920
  * **레시피 모드에서는 MES 가 산출을 소유한다** — 코어는 비켜선다(§`producesOwnOutputs`).
6681
6921
  *
@@ -6833,11 +7073,75 @@ var MesKernel = class extends FlowEngine {
6833
7073
  onTaskComplete(t) {
6834
7074
  return this.onTaskCompleteDef(t);
6835
7075
  }
7076
+ /**
7077
+ * **공정이 먹은 자재는 오더의 계보에 합류한다** — 그 단계가 만드는 것의 입력이 된다.
7078
+ *
7079
+ * ── 무엇이 빠져 있었나 (2026-08-22) ───────────────────────────────────────
7080
+ * 공정별 자재(`OperationDef.materialSpecification` `use:'consumed'`, ISA-95
7081
+ * `OperationsSegment.MaterialSpecification`)는 커널이 이미 확보하고 소비했다. 그런데 그 자재가
7082
+ * **오더가 들고 있는 것에 들어가지 않았다.** 단계의 변환 입력은 `order.allocated` 뿐이라, 뒤 공정에서
7083
+ * 먹은 자재가 제품의 계보에서 빠졌다 — 회수 범위를 되짚으면 그 자재가 조용히 없다.
7084
+ *
7085
+ * 레시피가 없는 모드(유통가공)는 산출을 코어가 만들므로 가져가지 않는다(§`producesOwnOutputs`).
7086
+ */
7087
+ /**
7088
+ * **그 오더의 레시피에서, 이 공정에 태그된 투입** — 품목 범위를 아는 것은 여기다.
7089
+ *
7090
+ * 오더가 없는 작업에는 답하지 않는다(창고 입고 등 — 그때는 공정 명세만이 요구다).
7091
+ */
7092
+ recipeInputsAt(t) {
7093
+ if (!t.orderId) return [];
7094
+ const order = this.orders.get(t.orderId);
7095
+ if (!order) return [];
7096
+ if (!this.productionSpec?.definition?.recipes?.length) return [];
7097
+ const rc = this.recipeDef(order);
7098
+ const out = [];
7099
+ for (const line of rc.inputs) {
7100
+ if (line.operation !== t.kind) continue;
7101
+ out.push({ use: "consumed", materialDefinition: this.classOf(line.material), quantity: line.qty });
7102
+ }
7103
+ return out;
7104
+ }
7105
+ adoptConsumed(t, epcs) {
7106
+ if (!epcs.length) return false;
7107
+ if (!this.producesOwnOutputs(t.kind)) return false;
7108
+ const order = t.orderId ? this.orders.get(t.orderId) : void 0;
7109
+ if (!order) return false;
7110
+ for (const epc of epcs) order.allocated.push(epc);
7111
+ return true;
7112
+ }
6836
7113
  // ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
6837
7114
  /** 선언된 레시피 전부 — 오더가 자기 것을 고르고, 수령이 전부의 소요를 본다. */
6838
7115
  recipesDef() {
6839
7116
  return this.productionSpec.definition.recipes ?? [];
6840
7117
  }
7118
+ /**
7119
+ * **소비되는 자재 전부** — 선언이 그것을 말하는 자리는 둘이고, 둘 다 본다.
7120
+ *
7121
+ * ── 왜 둘인가 (2026-08-22) ────────────────────────────────────────────────
7122
+ * ① `RecipeDef.inputs` — 레시피가 쓰는 자재. 오더가 시작될 때 확보한다.
7123
+ * ② `OperationDef.materialSpecification` `use:'consumed'` — **그 공정에서만** 들어가는 자재
7124
+ * (ISA-95 `OperationsSegment.MaterialSpecification`). 같은 부품이라도 공정마다 소요가 다르고,
7125
+ * 표준은 「몇 개」를 공정의 사실로 둔다.
7126
+ *
7127
+ * 예전에는 수령이 ①만 봤다. 그래서 ②에만 선언된 자재는 **한 번도 입고되지 않았고**, 그 공정은 영원히
7128
+ * 기다렸다 — 화면에는 이유가 없었다. 첫 실 연동의 BOM 이 (품목, 공정) 단위라 이 자리가 바로 막혔다.
7129
+ *
7130
+ * `binding` 을 지나지 않는 이름은 여기서 세지 않는다 — 커널이 그 자재의 정체성을 만들 수 없으므로
7131
+ * **입고를 만들 수 없다**(밖에서 들어온 물품은 `claimMaterials` 가 클래스 문자열로 알아본다).
7132
+ */
7133
+ consumedMaterialKeys() {
7134
+ const keys = new Set(this.recipesDef().flatMap((r) => r.inputs.map((i) => i.material)));
7135
+ const binding = this.productionSpec?.binding ?? {};
7136
+ for (const op of this.productionSpec?.definition?.operations ?? []) {
7137
+ for (const m of op.materialSpecification ?? []) {
7138
+ if (m.use !== "consumed" || !m.materialDefinition) continue;
7139
+ const k = Object.keys(binding).find((key) => this.classOf(key) === m.materialDefinition);
7140
+ if (k) keys.add(k);
7141
+ }
7142
+ }
7143
+ return [...keys];
7144
+ }
6841
7145
  /**
6842
7146
  * 이 오더의 레시피 — **오더가 들면 그것, 없으면 선언 수준의 기본**(§`FlowOrder.recipeKey`).
6843
7147
  *
@@ -6956,6 +7260,12 @@ var MesKernel = class extends FlowEngine {
6956
7260
  * 레거시(선언 없는 내장 프로파일) 경로는 이 함수를 쓰지 않는다 — 그쪽에는 대조할 선언이 없으므로
6957
7261
  * 커널 어휘 자체가 계약이다.
6958
7262
  */
7263
+ /** 자리를 선언하지 않아 입고를 만들지 못한 자재 — 같은 말을 틱마다 반복하지 않는다. */
7264
+ arrivalsWithoutPlace = /* @__PURE__ */ new Set();
7265
+ /** 그 자재가 선언한 보관처 타입 — **없으면 undefined**(정책이 없다는 사실이다). */
7266
+ declaredLocationTypeOfMaterial(materialKey) {
7267
+ return this.productionSpec.definition.materials?.find((m) => m.key === materialKey)?.locationType;
7268
+ }
6959
7269
  locationTypeOfMaterial(materialKey) {
6960
7270
  const type = this.productionSpec.definition.materials?.find((m) => m.key === materialKey)?.locationType;
6961
7271
  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`);
@@ -7001,8 +7311,17 @@ var MesKernel = class extends FlowEngine {
7001
7311
  onArrivalDef(spec) {
7002
7312
  const gtin = this.pickGtin(spec.content.skuMix);
7003
7313
  if (!gtin) return;
7004
- const inputKey = this.recipesDef().flatMap((r) => r.inputs.map((i) => i.material)).find((k) => this.classOf(k) === gtin);
7314
+ const inputKey = this.consumedMaterialKeys().find((k) => this.classOf(k) === gtin);
7005
7315
  if (!inputKey) return;
7316
+ if (!this.declaredLocationTypeOfMaterial(inputKey)) {
7317
+ if (!this.arrivalsWithoutPlace.has(inputKey)) {
7318
+ this.arrivalsWithoutPlace.add(inputKey);
7319
+ console.warn(
7320
+ `[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.`
7321
+ );
7322
+ }
7323
+ return;
7324
+ }
7006
7325
  const store = this.locationOfMaterial(inputKey);
7007
7326
  const epc = this.serialOf(inputKey, ++this.epcSeq);
7008
7327
  this.items.set(epc, { epc, gtin, qty: 1, location: store.id, disposition: DISP.sellable });
@@ -7074,12 +7393,19 @@ var MesKernel = class extends FlowEngine {
7074
7393
  else locsOfType.set(n.type, [n]);
7075
7394
  }
7076
7395
  for (const line of rc.inputs) {
7396
+ if (line.operation) continue;
7077
7397
  const g = this.classOf(line.material);
7078
- const fromType = this.locationTypeOfMaterial(line.material);
7398
+ const fromType = this.declaredLocationTypeOfMaterial(line.material);
7079
7399
  const available = [];
7080
- for (const n of locsOfType.get(fromType) ?? []) {
7081
- for (const i of this.items.at(n.id)) {
7082
- if (i.gtin === g && i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
7400
+ if (fromType) {
7401
+ for (const n of locsOfType.get(fromType) ?? []) {
7402
+ for (const i of this.items.at(n.id)) {
7403
+ if (i.gtin === g && i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
7404
+ }
7405
+ }
7406
+ } else {
7407
+ for (const i of this.items.ofGtin(g)) {
7408
+ if (i.disposition === DISP.sellable) available.push({ epc: i.epc, location: i.location, qty: 1 });
7083
7409
  }
7084
7410
  }
7085
7411
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
@@ -7138,7 +7464,8 @@ var MesKernel = class extends FlowEngine {
7138
7464
  this.emitOrder(order);
7139
7465
  return;
7140
7466
  }
7141
- const fgStore = this.locationOfMaterial(rc.outputs[0].material);
7467
+ const declaredFg = this.declaredLocationTypeOfMaterial(rc.outputs[0].material);
7468
+ const fgStore = declaredFg ? this.locationOfMaterial(rc.outputs[0].material) : loc;
7142
7469
  const consumed = order.allocated.slice();
7143
7470
  if (!consumed.length) {
7144
7471
  if (order.seedIncomplete) {
@@ -8603,6 +8930,7 @@ function retiredVocabularyIn(line) {
8603
8930
  gdtiUri,
8604
8931
  generationFractionAt,
8605
8932
  graiUri,
8933
+ gs1KeyDigitViolation,
8606
8934
  hierarchyOf,
8607
8935
  identityGroundingOf,
8608
8936
  inWorkCalendar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.46",
3
+ "version": "0.7.48",
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": {