@operato/twin-kernel 0.10.1 → 0.11.1

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 { 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 } from '@operato/ops-contract';
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 } from '@operato/ops-contract';
2
2
  import type { VocabularyElement } from '@operato/ops-contract';
3
3
  import type { ReducerCheckpoint } from './observed-reducer.ts';
4
4
  import type { EpcisEvent, BizTransactionElement } from '@operato/ops-contract';
@@ -159,12 +159,7 @@ export interface FlowTask {
159
159
  * 명세(계획)가 아니라 **일어난 일**이다. 투입 인원·설비가 이미 작업 델타에 타고 있으니 자재도 같은
160
160
  * 채널에 실어야 실적을 **한 곳에서** 읽는다(EPCIS 이벤트에서 다시 계산하려면 작업과 잇는 끈이 없다).
161
161
  */
162
- materialActual?: {
163
- definitionId: string;
164
- use: 'consumed' | 'produced';
165
- quantity: number;
166
- uom?: string;
167
- }[];
162
+ materialActual?: MaterialActual[];
168
163
  itemEpc: string;
169
164
  fromNode: string;
170
165
  toNode: string;
@@ -3237,7 +3237,8 @@ export class FlowEngine {
3237
3237
  if (n)
3238
3238
  n.occupancy++;
3239
3239
  }
3240
- this.recordMaterialActual(t, gtin, 'produced', qty, spec.uom);
3240
+ /* 산출 로트의 식별자 — 비직렬 로트는 클래스 식별자가 곧 로트의 이름이다. */
3241
+ this.recordMaterialActual(t, gtin, 'produced', qty, spec.uom, gtin);
3241
3242
  this.emit(objectEvent({
3242
3243
  eventTime: this.now(), action: 'ADD', bizStep: CBV_BIZSTEP.commissioning, disposition: DISP.in_progress,
3243
3244
  epcList: [], quantityList: [{ epcClass: gtin, quantity: total, ...(spec.uom ? { uom: spec.uom } : {}) }],
@@ -3327,15 +3328,26 @@ export class FlowEngine {
3327
3328
  * 실제 자재 이동을 작업에 적어 둔다 — ISA-95 `JobResponse.MaterialActual`.
3328
3329
  * 같은 품목·같은 쓰임은 **한 줄로 합친다**(줄을 늘리면 실적을 세는 쪽이 중복을 걷어내야 한다).
3329
3330
  */
3330
- recordMaterialActual(t, definitionId, use, quantity, uom) {
3331
+ recordMaterialActual(t, definitionId, use, quantity, uom,
3332
+ /**
3333
+ * 어느 로트인가 — 표준 `MaterialLotID`. **식별자**이고 로트 번호 문자열이 아니다.
3334
+ *
3335
+ * 이 값이 있어야 「이 로트로 무엇을 만들었나」가 이어진다. 불합격 로트의 이동 제한과 계보가 그 위에
3336
+ * 선다. 모르면 넣지 않는다 — 지어내면 다른 로트의 실적이 된다.
3337
+ */
3338
+ lotId) {
3331
3339
  if (!definitionId || !(quantity > 0))
3332
3340
  return;
3333
3341
  const rows = (t.materialActual ??= []);
3334
- const hit = rows.find(r => r.definitionId === definitionId && r.use === use && r.uom === uom);
3342
+ /*
3343
+ * **로트가 다르면 다른 줄이다.** 합치면 어느 로트를 얼마나 썼는지 사라지고, 그것이 이 필드를 만든
3344
+ * 이유 자체다. 같은 품목·같은 쓰임·같은 로트만 한 줄로 합친다.
3345
+ */
3346
+ const hit = rows.find(r => r.definitionId === definitionId && r.use === use && r.uom === uom && r.lotId === lotId);
3335
3347
  if (hit)
3336
3348
  hit.quantity += quantity;
3337
3349
  else
3338
- rows.push({ definitionId, use, quantity, ...(uom ? { uom } : {}) });
3350
+ rows.push({ definitionId, use, quantity, ...(uom ? { uom } : {}), ...(lotId ? { lotId } : {}) });
3339
3351
  }
3340
3352
  /** 확보한 자재를 **작업 시작 시점에 소비**한다 — 수량이 0 이 되면 물품 자체가 사라진다. */
3341
3353
  consumeMaterials(t, taken) {
@@ -3352,7 +3364,7 @@ export class FlowEngine {
3352
3364
  const left = (it.qty ?? 1) - take;
3353
3365
  if (left > 0) {
3354
3366
  it.qty = left;
3355
- this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom);
3367
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom, it.epc);
3356
3368
  /* 남은 수량을 그대로 알린다 — 관측 경로가 그 자리의 사실을 갱신한다(§quantityList). */
3357
3369
  this.emit(objectEvent({
3358
3370
  eventTime: this.now(), action: 'OBSERVE', bizStep: CBV_BIZSTEP.consuming, disposition: DISP.in_progress,
@@ -3361,7 +3373,7 @@ export class FlowEngine {
3361
3373
  }));
3362
3374
  continue;
3363
3375
  }
3364
- this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom);
3376
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom, it.epc);
3365
3377
  whole.push(epc);
3366
3378
  }
3367
3379
  if (!whole.length)
@@ -1,4 +1,4 @@
1
- import type { ISOTime } from '@operato/ops-contract';
1
+ import type { ISOTime, MaterialActual } from '@operato/ops-contract';
2
2
  import { type TaskDeltaRow } from './task-fold.ts';
3
3
  /** 표준 `JobState` — 우리 작업 상태를 표준 낱말로 옮긴다. */
4
4
  export type JobState = 'ready' | 'running' | 'completed';
@@ -9,15 +9,7 @@ export interface ResourceActual {
9
9
  /** 표준 `Quantity`. 우리는 개체를 지목하므로 언제나 1이다(등급 단위 집계는 소비처의 일). */
10
10
  quantity: number;
11
11
  }
12
- /** 자재 실적 한 줄 — 표준 `OpMaterialActualType` 의 우리 부분집합. */
13
- export interface MaterialActual {
14
- /** 표준 `MaterialDefinitionID`. */
15
- definitionId: string;
16
- /** 표준 `MaterialUse`. */
17
- use: 'consumed' | 'produced';
18
- quantity: number;
19
- uom?: string;
20
- }
12
+ export type { MaterialActual };
21
13
  export interface JobResponse {
22
14
  /** 표준 `ID` — 작업 식별자. */
23
15
  id: string;
@@ -56,4 +48,3 @@ type Row = TaskDeltaRow;
56
48
  * 자원·자재 실적은 전이가 실어 온 값에서 모은다. **마지막에 본 값**이 사실이다(재전송이면 나중 것).
57
49
  */
58
50
  export declare function foldJobResponses(rows: readonly Row[]): JobResponse[];
59
- export {};
@@ -37,6 +37,18 @@ export declare class MesKernel extends FlowEngine {
37
37
  *
38
38
  * 런타임에 조용히 어긋나는 것보다 기동이 실패하는 편이 낫다.
39
39
  */
40
+ /**
41
+ * 라우트의 **절차 계층**이 단계와 어긋나지 않는가 (ISA-88).
42
+ *
43
+ * ── 왜 이제야 부르나 (2026-08-31) ─────────────────────────────────────────
44
+ * 계약이 `procedureViolations` 를 갖고 있었는데 **아무도 부르지 않았다** — 계약의 자기 시험만
45
+ * 불렀다. 그래서 절차를 선언해도 어긋난 것이 그냥 지나갔고, 화면은 잘못된 트리를 오류 없이 보였다.
46
+ *
47
+ * 선언을 읽는 곳이 있는지 보는 하네스가 이것을 잡았다(§`declaration-is-read.test.ts`).
48
+ *
49
+ * 판정은 계약이 한다 — 여기서 다시 만들면 규칙이 두 벌이 되고, 화면도 같은 판정을 해야 한다.
50
+ */
51
+ private assertRouteProcedures;
40
52
  private assertRecipeOperationTags;
41
53
  /**
42
54
  * **레시피 모드에서는 MES 가 산출을 소유한다** — 코어는 비켜선다(§`producesOwnOutputs`).
@@ -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 } from '@operato/ops-contract';
11
+ import { identityGroundingOf, procedureViolations } from '@operato/ops-contract';
12
12
  import { firstFitPolicy } from "./allocation-policy.js";
13
13
  import { FlowEngine } from "./flow-engine.js";
14
14
  import { DISP, objectEvent, transactionEvent, gdtiUri, sgtinClass, bizTransactionUri } from '@operato/ops-contract';
@@ -76,6 +76,7 @@ export class MesKernel extends FlowEngine {
76
76
  this.assertNoDoubleProduction(productionSpec.definition.operations);
77
77
  }
78
78
  this.assertRecipeOperationTags();
79
+ this.assertRouteProcedures();
79
80
  }
80
81
  /**
81
82
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
@@ -131,6 +132,26 @@ export class MesKernel extends FlowEngine {
131
132
  *
132
133
  * 런타임에 조용히 어긋나는 것보다 기동이 실패하는 편이 낫다.
133
134
  */
135
+ /**
136
+ * 라우트의 **절차 계층**이 단계와 어긋나지 않는가 (ISA-88).
137
+ *
138
+ * ── 왜 이제야 부르나 (2026-08-31) ─────────────────────────────────────────
139
+ * 계약이 `procedureViolations` 를 갖고 있었는데 **아무도 부르지 않았다** — 계약의 자기 시험만
140
+ * 불렀다. 그래서 절차를 선언해도 어긋난 것이 그냥 지나갔고, 화면은 잘못된 트리를 오류 없이 보였다.
141
+ *
142
+ * 선언을 읽는 곳이 있는지 보는 하네스가 이것을 잡았다(§`declaration-is-read.test.ts`).
143
+ *
144
+ * 판정은 계약이 한다 — 여기서 다시 만들면 규칙이 두 벌이 되고, 화면도 같은 판정을 해야 한다.
145
+ */
146
+ assertRouteProcedures() {
147
+ const routes = this.productionSpec?.definition?.routes ?? [];
148
+ const bad = routes.flatMap(r => procedureViolations(r).map(v => `route '${r.key}': ${v}`));
149
+ if (bad.length) {
150
+ throw new Error(`declared procedure hierarchy does not match the route steps:\n - ${bad.join('\n - ')}\n` +
151
+ 'The steps are the source of truth for execution order; the hierarchy only names them. ' +
152
+ 'A mismatch would show a tree on screen that the twin never executes.');
153
+ }
154
+ }
134
155
  assertRecipeOperationTags() {
135
156
  const def = this.productionSpec?.definition;
136
157
  if (!def?.recipes?.length)
@@ -458,9 +479,33 @@ export class MesKernel extends FlowEngine {
458
479
  throw new Error(`recipe '${key}' is not declared. Declared: [${(d.recipes ?? []).map(r => r.key).join(', ') || '(none)'}]. ` +
459
480
  `The kernel does not substitute another recipe — that would make this order produce a different item.`);
460
481
  }
482
+ /*
483
+ * **지목한 것은 유효 기간으로 거르지 않는다** (2026-08-31).
484
+ *
485
+ * 지난 실적을 뒤늦게 올릴 때 **그때 쓰던 레시피**를 가리켜야 한다. 그 레시피는 이미 끝난 것일 수
486
+ * 있고, 그것이 사실이다. 「지금 무엇을 쓸 수 있나」와 「그때 무엇을 썼나」는 다른 물음이다.
487
+ */
461
488
  return found;
462
489
  }
463
- return d.recipes[0];
490
+ /*
491
+ * **지목이 없으면 지금 유효한 것 중에서 고른다** (2026-08-31).
492
+ *
493
+ * 예전에는 선언 순서의 첫 번째를 그대로 썼다. 그래서 첫 레시피가 개정으로 끝난 뒤에도 그것으로
494
+ * 만들었고, 그 산출이 무엇인지 계보에서 잃었다. 유효 기간이 계약에 열린 것은 이 판정을 하기 위한
495
+ * 것인데 읽는 쪽이 없었다.
496
+ *
497
+ * 여럿이 유효하면 첫 번째다 — 그 모호함은 예전부터 있었고 여기서 정할 일이 아니다(어느 것으로
498
+ * 만들지는 오더의 `recipeKey` 가 말한다). 하나도 유효하지 않으면 **던진다**: 끝난 레시피로 만드는
499
+ * 것이 멈추는 것보다 나쁘다.
500
+ */
501
+ const usable = (d.recipes ?? []).filter(r => !this.effectivityOf(r));
502
+ if (!usable.length) {
503
+ const when = (d.recipes ?? []).map(r => `${r.key}(${this.effectivityOf(r)})`).join(', ');
504
+ throw new Error(`no declared recipe is in effect now. Declared: [${when || '(none)'}]. ` +
505
+ `The kernel does not fall back to a retired recipe — that would make this order produce an item whose ` +
506
+ `bill of materials no longer holds. Name a recipe on the order, or extend the effective period.`);
507
+ }
508
+ return usable[0];
464
509
  }
465
510
  /** 자재 키 → 구체 gtin 클래스(idpat). 구체 식별은 바인딩+prefix 로 인스턴스가 주입. */
466
511
  /**
@@ -1354,6 +1354,32 @@ var OP_PARAM = {
1354
1354
  * 현재 커널은 작업에 붙는 셋업으로 다루므로 모수로 받는다. */
1355
1355
  setupDuration: "setupDuration"
1356
1356
  };
1357
+ function procedureViolations(route) {
1358
+ const elements = route.procedure ?? [];
1359
+ if (!elements.length)
1360
+ return [];
1361
+ const errors = [];
1362
+ const byKey = new Map(elements.map((e) => [e.key, e]));
1363
+ const hasChild = new Set(elements.map((e) => e.parent).filter((k) => !!k));
1364
+ const stepSet = new Set(route.steps ?? []);
1365
+ const seenStep = /* @__PURE__ */ new Map();
1366
+ for (const n of elements) {
1367
+ if (n.parent && !byKey.has(n.parent))
1368
+ errors.push(`${n.key}: \uC0C1\uC704 '${n.parent}' \uAC00 \uC774 \uC808\uCC28\uC5D0 \uC5C6\uB2E4`);
1369
+ if (n.step === void 0)
1370
+ continue;
1371
+ if (hasChild.has(n.key))
1372
+ errors.push(`${n.key}: \uC544\uB798 \uC694\uC18C\uAC00 \uC788\uB294\uB370 \uB2E8\uACC4\uB97C \uB4E0\uB2E4 \u2014 \uC2E4\uD589\uB418\uB294 \uAC83\uC740 \uC78E\uBFD0\uC774\uB2E4`);
1373
+ if (!stepSet.has(n.step))
1374
+ errors.push(`${n.key}: \uBAA8\uB974\uB294 \uB2E8\uACC4 '${n.step}' \u2014 steps \uC5D0 \uC5C6\uB2E4`);
1375
+ const already = seenStep.get(n.step);
1376
+ if (already)
1377
+ errors.push(`\uB2E8\uACC4 '${n.step}' \uAC00 '${already}' \uC640 '${n.key}' \uB458\uC5D0 \uC2E4\uB838\uB2E4 \u2014 \uC808\uCC28 \uC5B4\uB514\uC778\uC9C0 \uB9D0\uD560 \uC218 \uC5C6\uB2E4`);
1378
+ else
1379
+ seenStep.set(n.step, n.key);
1380
+ }
1381
+ return errors;
1382
+ }
1357
1383
 
1358
1384
  // ../ops-contract/dist/energy-ingest.js
1359
1385
  function resolveSubject(kind, localId, opts) {
@@ -5999,7 +6025,7 @@ var FlowEngine = class {
5999
6025
  const n = this.locations.get(at);
6000
6026
  if (n) n.occupancy++;
6001
6027
  }
6002
- this.recordMaterialActual(t, gtin, "produced", qty, spec.uom);
6028
+ this.recordMaterialActual(t, gtin, "produced", qty, spec.uom, gtin);
6003
6029
  this.emit(objectEvent({
6004
6030
  eventTime: this.now(),
6005
6031
  action: "ADD",
@@ -6077,12 +6103,12 @@ var FlowEngine = class {
6077
6103
  * 실제 자재 이동을 작업에 적어 둔다 — ISA-95 `JobResponse.MaterialActual`.
6078
6104
  * 같은 품목·같은 쓰임은 **한 줄로 합친다**(줄을 늘리면 실적을 세는 쪽이 중복을 걷어내야 한다).
6079
6105
  */
6080
- recordMaterialActual(t, definitionId, use, quantity, uom) {
6106
+ recordMaterialActual(t, definitionId, use, quantity, uom, lotId) {
6081
6107
  if (!definitionId || !(quantity > 0)) return;
6082
6108
  const rows = t.materialActual ??= [];
6083
- const hit = rows.find((r) => r.definitionId === definitionId && r.use === use && r.uom === uom);
6109
+ const hit = rows.find((r) => r.definitionId === definitionId && r.use === use && r.uom === uom && r.lotId === lotId);
6084
6110
  if (hit) hit.quantity += quantity;
6085
- else rows.push({ definitionId, use, quantity, ...uom ? { uom } : {} });
6111
+ else rows.push({ definitionId, use, quantity, ...uom ? { uom } : {}, ...lotId ? { lotId } : {} });
6086
6112
  }
6087
6113
  /** 확보한 자재를 **작업 시작 시점에 소비**한다 — 수량이 0 이 되면 물품 자체가 사라진다. */
6088
6114
  consumeMaterials(t, taken) {
@@ -6094,7 +6120,7 @@ var FlowEngine = class {
6094
6120
  const left = (it.qty ?? 1) - take;
6095
6121
  if (left > 0) {
6096
6122
  it.qty = left;
6097
- this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom);
6123
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom, it.epc);
6098
6124
  this.emit(objectEvent({
6099
6125
  eventTime: this.now(),
6100
6126
  action: "OBSERVE",
@@ -6107,7 +6133,7 @@ var FlowEngine = class {
6107
6133
  }));
6108
6134
  continue;
6109
6135
  }
6110
- this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom);
6136
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom, it.epc);
6111
6137
  whole.push(epc);
6112
6138
  }
6113
6139
  if (!whole.length) return;
@@ -7228,6 +7254,7 @@ var MesKernel = class extends FlowEngine {
7228
7254
  this.assertNoDoubleProduction(productionSpec.definition.operations);
7229
7255
  }
7230
7256
  this.assertRecipeOperationTags();
7257
+ this.assertRouteProcedures();
7231
7258
  }
7232
7259
  /**
7233
7260
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
@@ -7269,6 +7296,28 @@ var MesKernel = class extends FlowEngine {
7269
7296
  *
7270
7297
  * 런타임에 조용히 어긋나는 것보다 기동이 실패하는 편이 낫다.
7271
7298
  */
7299
+ /**
7300
+ * 라우트의 **절차 계층**이 단계와 어긋나지 않는가 (ISA-88).
7301
+ *
7302
+ * ── 왜 이제야 부르나 (2026-08-31) ─────────────────────────────────────────
7303
+ * 계약이 `procedureViolations` 를 갖고 있었는데 **아무도 부르지 않았다** — 계약의 자기 시험만
7304
+ * 불렀다. 그래서 절차를 선언해도 어긋난 것이 그냥 지나갔고, 화면은 잘못된 트리를 오류 없이 보였다.
7305
+ *
7306
+ * 선언을 읽는 곳이 있는지 보는 하네스가 이것을 잡았다(§`declaration-is-read.test.ts`).
7307
+ *
7308
+ * 판정은 계약이 한다 — 여기서 다시 만들면 규칙이 두 벌이 되고, 화면도 같은 판정을 해야 한다.
7309
+ */
7310
+ assertRouteProcedures() {
7311
+ const routes = this.productionSpec?.definition?.routes ?? [];
7312
+ const bad = routes.flatMap((r) => procedureViolations(r).map((v) => `route '${r.key}': ${v}`));
7313
+ if (bad.length) {
7314
+ throw new Error(
7315
+ `declared procedure hierarchy does not match the route steps:
7316
+ - ${bad.join("\n - ")}
7317
+ The steps are the source of truth for execution order; the hierarchy only names them. A mismatch would show a tree on screen that the twin never executes.`
7318
+ );
7319
+ }
7320
+ }
7272
7321
  assertRecipeOperationTags() {
7273
7322
  const def = this.productionSpec?.definition;
7274
7323
  if (!def?.recipes?.length) return;
@@ -7564,7 +7613,14 @@ var MesKernel = class extends FlowEngine {
7564
7613
  }
7565
7614
  return found;
7566
7615
  }
7567
- return d.recipes[0];
7616
+ const usable = (d.recipes ?? []).filter((r) => !this.effectivityOf(r));
7617
+ if (!usable.length) {
7618
+ const when = (d.recipes ?? []).map((r) => `${r.key}(${this.effectivityOf(r)})`).join(", ");
7619
+ throw new Error(
7620
+ `no declared recipe is in effect now. Declared: [${when || "(none)"}]. The kernel does not fall back to a retired recipe \u2014 that would make this order produce an item whose bill of materials no longer holds. Name a recipe on the order, or extend the effective period.`
7621
+ );
7622
+ }
7623
+ return usable[0];
7568
7624
  }
7569
7625
  /** 자재 키 → 구체 gtin 클래스(idpat). 구체 식별은 바인딩+prefix 로 인스턴스가 주입. */
7570
7626
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
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.8.0"
31
+ "@operato/ops-contract": "^0.9.0"
32
32
  }
33
33
  }