@operato/twin-kernel 0.11.20 → 0.11.22

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.
@@ -1,4 +1,4 @@
1
- import type { MaterialActual, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact, AllocatedQuantity, MaterialLotUse } from '@operato/ops-contract';
1
+ import type { MaterialActual, RecipeExecutionBasis, TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation, DispositionFact, AllocatedQuantity, MaterialLotUse } from '@operato/ops-contract';
2
2
  import { type RatedUsage, type UsedUsage } from '@operato/ops-contract';
3
3
  import type { VocabularyElement } from '@operato/ops-contract';
4
4
  import type { ReducerCheckpoint } from './observed-reducer.ts';
@@ -271,6 +271,7 @@ export declare function allocatedQty(rows: readonly AllocatedQuantity[] | undefi
271
271
  /** 잡아 둔 줄들의 식별자 — EPCIS 이벤트가 싣는 것은 열쇠뿐이다. */
272
272
  export declare function allocatedEpcs(rows: readonly AllocatedQuantity[] | undefined): string[];
273
273
  export interface FlowOrder {
274
+ recipeBasis?: RecipeExecutionBasis;
274
275
  id: string;
275
276
  kind: string;
276
277
  status: string;
@@ -1241,6 +1241,7 @@ export class FlowEngine {
1241
1241
  ...(o.allocated?.length ? { allocated: o.allocated } : {}),
1242
1242
  ...(o.gtin ? { gtin: o.gtin } : {}),
1243
1243
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1244
+ ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
1244
1245
  ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
1245
1246
  ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
1246
1247
  ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
@@ -1303,6 +1304,7 @@ export class FlowEngine {
1303
1304
  /* 레시피도 함께 — 품목만 이어받으면 되살아난 오더가 「무엇으로」를 모르고, 그 오더의
1304
1305
  남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
1305
1306
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1307
+ ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
1306
1308
  bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null,
1307
1309
  /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
1308
1310
  lines: observing ? (o.lines ?? []).map(l => ({ ...l })) : lines,
@@ -1820,6 +1822,7 @@ export class FlowEngine {
1820
1822
  ...(o.gtin ? { gtin: o.gtin } : {}),
1821
1823
  /* **어느 레시피로 만드나** — 품목만으로는 「무엇으로」가 안 남는다(같은 품목의 대체 레시피가 있다). */
1822
1824
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1825
+ ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
1823
1826
  /* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
1824
1827
  ...(o.seedIncomplete ? { seedIncomplete: true } : {}),
1825
1828
  /* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
@@ -2989,6 +2992,7 @@ export class FlowEngine {
2989
2992
  /* 어느 레시피로 만드는가 — 품목만 보내면 미러는 「무엇으로」를 알 수 없다(같은 품목에 대체
2990
2993
  레시피가 있다). 시뮬만 아는 사실이 남으면 두 스냅샷을 같은 규칙으로 읽을 수 없다. */
2991
2994
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
2995
+ ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
2992
2996
  ...(o.allocated?.length ? { allocated: o.allocated.slice() } : {}),
2993
2997
  ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
2994
2998
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
@@ -8,7 +8,7 @@
8
8
  * ③ 셋업/체인지오버 = work-center 가 제품 전환 시 셋업(changeoverKey=제품 gtin) → OEE Availability.
9
9
  * ④ OEE = base 가 설비(설비)별 계측(가동/셋업/기아/품질). cut=cutter·weld=welder 이종 자원.
10
10
  */
11
- import { identityGroundingOf, procedureViolations } from '@operato/ops-contract';
11
+ import { identityGroundingOf, procedureViolations, isRecipeExecutionBasis } from '@operato/ops-contract';
12
12
  import { firstFitPolicy } from "./allocation-policy.js";
13
13
  import { FlowEngine, allocatedEpcs, allocatedQty } from "./flow-engine.js";
14
14
  import { DISP, objectEvent, transactionEvent, gdtiUri, sgtinClass, bizTransactionUri } from '@operato/ops-contract';
@@ -472,6 +472,11 @@ export class MesKernel extends FlowEngine {
472
472
  * 그 뒤 계보·재고·수율이 전부 엉뚱한 품목에 붙는다.
473
473
  */
474
474
  recipeDef(order) {
475
+ if (order?.recipeBasis !== undefined) {
476
+ if (!isRecipeExecutionBasis(order.recipeBasis) || (order.recipeKey && order.recipeKey !== order.recipeBasis.recipe.key))
477
+ throw new Error('Invalid pinned recipe evidence; refusing to substitute the current master.');
478
+ return order.recipeBasis.recipe;
479
+ }
475
480
  const d = this.productionSpec.definition;
476
481
  const key = order?.recipeKey ?? this.productionSpec.recipeKey;
477
482
  if (key) {
@@ -658,7 +663,10 @@ export class MesKernel extends FlowEngine {
658
663
  /** recipe.route → 오퍼레이션 시퀀스 해소. 오더가 자기 레시피를 들면 그 라우트다. */
659
664
  routeOps(order) {
660
665
  const d = this.productionSpec.definition;
661
- const route = d.routes?.find(r => r.key === this.recipeDef(order).route);
666
+ const recipe = this.recipeDef(order);
667
+ const route = order?.recipeBasis ? order.recipeBasis.route : d.routes?.find(r => r.key === recipe.route);
668
+ if (order?.recipeBasis && route?.steps.some(key => !d.operations?.some(operation => operation.key === key)))
669
+ throw new Error('A captured recipe route references an unavailable operation; refusing a shortened route.');
662
670
  return (route?.steps ?? []).map(sk => d.operations?.find(o => o.key === sk)).filter((o) => !!o);
663
671
  }
664
672
  /** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면 **그 자재가 선언한 자리**에 생성. */
@@ -722,6 +722,7 @@ export class ObservedReducer {
722
722
  ...(d.gtin ? { gtin: d.gtin } : {}),
723
723
  /* 어느 레시피로 만드는가 — 품목만으로는 「무엇으로」가 남지 않는다(같은 품목에 대체 레시피). */
724
724
  ...(d.recipeKey ? { recipeKey: d.recipeKey } : {}),
725
+ ...(d.recipeBasis ? { recipeBasis: structuredClone(d.recipeBasis) } : {}),
725
726
  ...(d.allocated?.length ? { allocated: d.allocated.slice() } : {}),
726
727
  ...(d.bizTransaction ? { bizTransaction: d.bizTransaction } : {}),
727
728
  /* 이행하는 계획 — 「이 계획이 얼마나 됐나」를 이 값으로 묶어 답한다(§`OrderState.operationsRequestId`). */
@@ -328,8 +328,36 @@ function outsideLimit(criterion, observed) {
328
328
  return true;
329
329
  return false;
330
330
  }
331
+ function isTestSpecificationBasis(value) {
332
+ if (!value || typeof value !== "object" || Array.isArray(value))
333
+ return false;
334
+ const basis = value;
335
+ if (!["id", "revisionId", "planId", "contentHash", "businessTime"].every((key) => typeof basis[key] === "string" && basis[key].trim()))
336
+ return false;
337
+ if (!Number.isFinite(Date.parse(basis.businessTime)))
338
+ return false;
339
+ return Array.isArray(basis.criteria) && basis.criteria.every((c) => {
340
+ if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.id !== "string" || !c.id.trim())
341
+ return false;
342
+ if (["description", "expression", "evaluatedPropertyId"].some((key) => c[key] !== void 0 && typeof c[key] !== "string"))
343
+ return false;
344
+ if (c.limit === void 0)
345
+ return true;
346
+ const limit = c.limit;
347
+ if (!limit || typeof limit !== "object" || Array.isArray(limit))
348
+ return false;
349
+ if (["minimum", "maximum"].some((key) => limit[key] !== void 0 && (typeof limit[key] !== "number" || !Number.isFinite(limit[key]))))
350
+ return false;
351
+ if (limit.uom !== void 0 && typeof limit.uom !== "string")
352
+ return false;
353
+ return !(limit.minimum !== void 0 && limit.maximum !== void 0 && limit.minimum > limit.maximum);
354
+ });
355
+ }
331
356
  function judgeAgainstSpec(spec, result) {
332
- const criteria = (spec?.criteria ?? []).filter((c) => !criterionSaysNothing(c));
357
+ const basis = result?.specificationBasis;
358
+ if (basis !== void 0 && !isTestSpecificationBasis(basis))
359
+ return void 0;
360
+ const criteria = (basis ? basis.criteria : spec?.criteria ?? []).filter((c) => !criterionSaysNothing(c));
333
361
  if (!criteria.length)
334
362
  return void 0;
335
363
  const measurements = result?.propertyMeasurements ?? [];
@@ -607,6 +635,14 @@ function capabilityOf(r, ctx) {
607
635
  return { available: false, reason: "working" };
608
636
  return { available: true, reason: "available" };
609
637
  }
638
+ function isRecipeExecutionBasis(value) {
639
+ if (!value || typeof value !== "object")
640
+ return false;
641
+ const v = value;
642
+ const text = (x) => typeof x === "string" && x.trim().length > 0;
643
+ const lines = (x) => Array.isArray(x) && x.length > 0 && x.every((row) => row && text(row.material) && typeof row.qty === "number" && Number.isFinite(row.qty) && row.qty > 0 && (row.operation === void 0 || text(row.operation)));
644
+ return [v.basisId, v.recipeId, v.revisionId, v.planId, v.contentHash, v.businessTime].every(text) && /(?:Z|[+-]\d{2}:\d{2})$/.test(v.businessTime) && Number.isFinite(Date.parse(v.businessTime)) && !!v.recipe && text(v.recipe.key) && text(v.recipe.label) && lines(v.recipe.inputs) && lines(v.recipe.outputs) && (v.recipe.route === void 0 ? v.route === void 0 && v.recipe.inputs.every((line) => !line.operation) : text(v.recipe.route) && !!v.route && v.route.key === v.recipe.route && Array.isArray(v.route.steps) && v.route.steps.every(text) && v.recipe.inputs.every((line) => !line.operation || v.route.steps.includes(line.operation)));
645
+ }
610
646
  var OP_EVENT = {
611
647
  task: "task.status",
612
648
  equipment: "equipment.status",
@@ -1986,13 +2022,19 @@ var SPECS = {
1986
2022
  *
1987
2023
  * 새 어휘가 아니다 — 계약이 `recipeKey` 를 네 자리에서 이미 쓴다. 오더 관측에만 자리가 없었다.
1988
2024
  */
1989
- recipeKey: "string"
2025
+ recipeKey: "string",
2026
+ recipeBasis: "object"
1990
2027
  },
1991
2028
  /*
1992
2029
  * 확보분의 안쪽을 본다 — 열쇠와 수 둘이다(§`AllocatedQuantity`). 안 보면 틀린 이름이 조용히
1993
2030
  * 지나가고(`epcs`·`quantity` 따위), 미러는 잡힌 수를 0 으로 읽는다.
1994
2031
  */
1995
2032
  shapes: {
2033
+ recipeBasis: {
2034
+ required: ["basisId", "recipeId", "revisionId", "planId", "contentHash", "businessTime", "recipe"],
2035
+ fields: { basisId: "string", recipeId: "string", revisionId: "string", planId: "string", contentHash: "string", businessTime: "string" }
2036
+ // Owned composition and captured route are additionally checked by isRecipeExecutionBasis below.
2037
+ },
1996
2038
  allocated: {
1997
2039
  required: ["epc", "qty"],
1998
2040
  fields: { epc: "string", qty: "number" },
@@ -2156,8 +2198,15 @@ var SPECS = {
2156
2198
  expiresAt: "string",
2157
2199
  derived: "boolean",
2158
2200
  propertyMeasurements: "object[]",
2201
+ specificationBasis: "object",
2159
2202
  recordTime: "string"
2160
2203
  },
2204
+ shapes: {
2205
+ specificationBasis: {
2206
+ required: ["id", "revisionId", "planId", "contentHash", "businessTime", "criteria"],
2207
+ fields: { id: "string", revisionId: "string", planId: "string", contentHash: "string", businessTime: "string" }
2208
+ }
2209
+ },
2161
2210
  enums: { result: ["pass", "fail"] }
2162
2211
  },
2163
2212
  /*
@@ -3062,6 +3111,7 @@ var ObservedReducer = class {
3062
3111
  ...d.gtin ? { gtin: d.gtin } : {},
3063
3112
  /* 어느 레시피로 만드는가 — 품목만으로는 「무엇으로」가 남지 않는다(같은 품목에 대체 레시피). */
3064
3113
  ...d.recipeKey ? { recipeKey: d.recipeKey } : {},
3114
+ ...d.recipeBasis ? { recipeBasis: structuredClone(d.recipeBasis) } : {},
3065
3115
  ...d.allocated?.length ? { allocated: d.allocated.slice() } : {},
3066
3116
  ...d.bizTransaction ? { bizTransaction: d.bizTransaction } : {},
3067
3117
  /* 이행하는 계획 — 「이 계획이 얼마나 됐나」를 이 값으로 묶어 답한다(§`OrderState.operationsRequestId`). */
@@ -4637,6 +4687,7 @@ var FlowEngine = class {
4637
4687
  ...o.allocated?.length ? { allocated: o.allocated } : {},
4638
4688
  ...o.gtin ? { gtin: o.gtin } : {},
4639
4689
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
4690
+ ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
4640
4691
  ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
4641
4692
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
4642
4693
  ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
@@ -4661,6 +4712,7 @@ var FlowEngine = class {
4661
4712
  /* 레시피도 함께 — 품목만 이어받으면 되살아난 오더가 「무엇으로」를 모르고, 그 오더의
4662
4713
  남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
4663
4714
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
4715
+ ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
4664
4716
  bizTransaction: o.bizTransaction ?? "",
4665
4717
  allocated: resolved.kept,
4666
4718
  picked: [],
@@ -5094,6 +5146,7 @@ var FlowEngine = class {
5094
5146
  ...o.gtin ? { gtin: o.gtin } : {},
5095
5147
  /* **어느 레시피로 만드나** — 품목만으로는 「무엇으로」가 안 남는다(같은 품목의 대체 레시피가 있다). */
5096
5148
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
5149
+ ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
5097
5150
  /* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
5098
5151
  ...o.seedIncomplete ? { seedIncomplete: true } : {},
5099
5152
  /* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
@@ -6128,6 +6181,7 @@ var FlowEngine = class {
6128
6181
  /* 어느 레시피로 만드는가 — 품목만 보내면 미러는 「무엇으로」를 알 수 없다(같은 품목에 대체
6129
6182
  레시피가 있다). 시뮬만 아는 사실이 남으면 두 스냅샷을 같은 규칙으로 읽을 수 없다. */
6130
6183
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
6184
+ ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
6131
6185
  ...o.allocated?.length ? { allocated: o.allocated.slice() } : {},
6132
6186
  ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
6133
6187
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
@@ -8039,6 +8093,11 @@ The steps are the source of truth for execution order; the hierarchy only names
8039
8093
  * 그 뒤 계보·재고·수율이 전부 엉뚱한 품목에 붙는다.
8040
8094
  */
8041
8095
  recipeDef(order) {
8096
+ if (order?.recipeBasis !== void 0) {
8097
+ if (!isRecipeExecutionBasis(order.recipeBasis) || order.recipeKey && order.recipeKey !== order.recipeBasis.recipe.key)
8098
+ throw new Error("Invalid pinned recipe evidence; refusing to substitute the current master.");
8099
+ return order.recipeBasis.recipe;
8100
+ }
8042
8101
  const d = this.productionSpec.definition;
8043
8102
  const key = order?.recipeKey ?? this.productionSpec.recipeKey;
8044
8103
  if (key) {
@@ -8201,7 +8260,10 @@ The steps are the source of truth for execution order; the hierarchy only names
8201
8260
  /** recipe.route → 오퍼레이션 시퀀스 해소. 오더가 자기 레시피를 들면 그 라우트다. */
8202
8261
  routeOps(order) {
8203
8262
  const d = this.productionSpec.definition;
8204
- const route = d.routes?.find((r) => r.key === this.recipeDef(order).route);
8263
+ const recipe = this.recipeDef(order);
8264
+ const route = order?.recipeBasis ? order.recipeBasis.route : d.routes?.find((r) => r.key === recipe.route);
8265
+ if (order?.recipeBasis && route?.steps.some((key) => !d.operations?.some((operation) => operation.key === key)))
8266
+ throw new Error("A captured recipe route references an unavailable operation; refusing a shortened route.");
8205
8267
  return (route?.steps ?? []).map((sk) => d.operations?.find((o) => o.key === sk)).filter((o) => !!o);
8206
8268
  }
8207
8269
  /** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면 **그 자재가 선언한 자리**에 생성. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.11.20",
3
+ "version": "0.11.22",
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": {
@@ -28,6 +28,6 @@
28
28
  "test": "node --test test/*.test.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@operato/ops-contract": "^0.9.30"
31
+ "@operato/ops-contract": "^0.9.32"
32
32
  }
33
33
  }