@operato/twin-kernel 0.11.22 → 0.11.23

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, 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';
1
+ import type { MaterialActual, RecipeExecutionBasis, MaterialExecutionBasis, 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';
@@ -197,6 +197,7 @@ export interface FlowTask {
197
197
  * 채널에 실어야 실적을 **한 곳에서** 읽는다(EPCIS 이벤트에서 다시 계산하려면 작업과 잇는 끈이 없다).
198
198
  */
199
199
  materialActual?: MaterialActual[];
200
+ workShiftBasis?: import('@operato/ops-contract').WorkShiftExecutionBasis;
200
201
  itemEpc: string;
201
202
  fromNode: string;
202
203
  toNode: string;
@@ -271,6 +272,7 @@ export declare function allocatedQty(rows: readonly AllocatedQuantity[] | undefi
271
272
  /** 잡아 둔 줄들의 식별자 — EPCIS 이벤트가 싣는 것은 열쇠뿐이다. */
272
273
  export declare function allocatedEpcs(rows: readonly AllocatedQuantity[] | undefined): string[];
273
274
  export interface FlowOrder {
275
+ materialBasis?: MaterialExecutionBasis[];
274
276
  recipeBasis?: RecipeExecutionBasis;
275
277
  id: string;
276
278
  kind: string;
@@ -9,6 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
+ import { isWorkShiftExecutionBasis } from '@operato/ops-contract';
12
13
  import { OP_EVENT, CMD, USE_UOM, locationStatusOf, readBoardEquipment, equipmentClassMembership, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit, computeOee } from '@operato/ops-contract';
13
14
  import { ObservedReducer } from "./observed-reducer.js";
14
15
  /* 주체를 정하는 규칙은 유입 문과 한 벌이다 — 두 곳에 적으면 한쪽만 고쳐진다. */
@@ -1050,6 +1051,10 @@ export class FlowEngine {
1050
1051
  * 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
1051
1052
  */
1052
1053
  purpose = 'seed') {
1054
+ for (const task of snap.tasks ?? []) {
1055
+ if (task.workShiftBasis !== undefined && !isWorkShiftExecutionBasis(task.workShiftBasis))
1056
+ throw new Error('Invalid pinned work shift evidence');
1057
+ }
1053
1058
  /* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 주입하면 곧바로 주목 신호가 계산되므로, 늦게
1054
1059
  * 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
1055
1060
  for (const id of snap.acked ?? [])
@@ -1242,6 +1247,7 @@ export class FlowEngine {
1242
1247
  ...(o.gtin ? { gtin: o.gtin } : {}),
1243
1248
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1244
1249
  ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
1250
+ ...(o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {}),
1245
1251
  ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
1246
1252
  ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
1247
1253
  ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
@@ -1305,6 +1311,7 @@ export class FlowEngine {
1305
1311
  남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
1306
1312
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1307
1313
  ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
1314
+ ...(o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {}),
1308
1315
  bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null,
1309
1316
  /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
1310
1317
  lines: observing ? (o.lines ?? []).map(l => ({ ...l })) : lines,
@@ -1368,6 +1375,7 @@ export class FlowEngine {
1368
1375
  status: purpose === 'observe' ? t.status : known && t.status === 'in-progress' ? 'in-progress' : 'created',
1369
1376
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
1370
1377
  ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
1378
+ ...(t.workShiftBasis ? { workShiftBasis: structuredClone(t.workShiftBasis) } : {}),
1371
1379
  itemEpc: t.itemRefs?.[0] ?? '',
1372
1380
  fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
1373
1381
  /*
@@ -1802,6 +1810,7 @@ export class FlowEngine {
1802
1810
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
1803
1811
  ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
1804
1812
  ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
1813
+ ...(t.workShiftBasis ? { workShiftBasis: structuredClone(t.workShiftBasis) } : {}),
1805
1814
  ...(t.priority !== undefined ? { priority: t.priority } : {}),
1806
1815
  ...(t.startTime ? { startTime: t.startTime } : {}),
1807
1816
  ...(t.endTime ? { endTime: t.endTime } : {})
@@ -1823,6 +1832,7 @@ export class FlowEngine {
1823
1832
  /* **어느 레시피로 만드나** — 품목만으로는 「무엇으로」가 안 남는다(같은 품목의 대체 레시피가 있다). */
1824
1833
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1825
1834
  ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
1835
+ ...(o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {}),
1826
1836
  /* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
1827
1837
  ...(o.seedIncomplete ? { seedIncomplete: true } : {}),
1828
1838
  /* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
@@ -2954,6 +2964,7 @@ export class FlowEngine {
2954
2964
  ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
2955
2965
  /* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
2956
2966
  ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
2967
+ ...(t.workShiftBasis ? { workShiftBasis: structuredClone(t.workShiftBasis) } : {}),
2957
2968
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
2958
2969
  /* 품질 판정이 있었으면 **그 자리에서** 실어 보낸다 — 없으면 필드를 만들지 않는다(모름≠양품). */
2959
2970
  ...(t.outcome ? { outcome: t.outcome } : {}),
@@ -2993,6 +3004,7 @@ export class FlowEngine {
2993
3004
  레시피가 있다). 시뮬만 아는 사실이 남으면 두 스냅샷을 같은 규칙으로 읽을 수 없다. */
2994
3005
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
2995
3006
  ...(o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {}),
3007
+ ...(o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {}),
2996
3008
  ...(o.allocated?.length ? { allocated: o.allocated.slice() } : {}),
2997
3009
  ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
2998
3010
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
@@ -3197,7 +3209,7 @@ export class FlowEngine {
3197
3209
  for (const it of this.items.at(at)) {
3198
3210
  if (remaining <= 0)
3199
3211
  break;
3200
- if (!this.materialMatches(it, req))
3212
+ if (!this.materialMatches(it, req, t.orderId))
3201
3213
  continue;
3202
3214
  /*
3203
3215
  * 변환 중인 것은 잡지 않는다 — 어떤 공정이 이미 먹어 그 단계의 산출로 바뀔 물품이다
@@ -3221,12 +3233,13 @@ export class FlowEngine {
3221
3233
  return picked;
3222
3234
  }
3223
3235
  /** 이 물품이 명세를 만족하나 — 품목 지목이 우선, 없으면 등급(상속을 타고 닫는다). */
3224
- materialMatches(it, req) {
3236
+ materialMatches(it, req, orderId) {
3225
3237
  const defId = it.definitionId ?? it.gtin;
3226
3238
  if (req.materialDefinition)
3227
3239
  return defId === req.materialDefinition;
3228
3240
  if (req.materialClass) {
3229
- const def = defId ? this.materialDefs.get(defId) : undefined;
3241
+ const basis = orderId ? this.orders.get(orderId)?.materialBasis : undefined;
3242
+ const def = basis !== undefined ? basis.find(b => b.material.id === defId)?.material : defId ? this.materialDefs.get(defId) : undefined;
3230
3243
  return classClosure(def?.materialClassIds, this.classDefs.material, this.now()).has(req.materialClass);
3231
3244
  }
3232
3245
  return true; // 지목도 등급도 없으면 아무 자재나(명세가 그렇게 느슨하면 그대로 따른다)
@@ -19,7 +19,7 @@
19
19
  * - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
20
20
  * 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
21
21
  */
22
- import { computeOee } from '@operato/ops-contract';
22
+ import { computeOee, isWorkShiftExecutionBasis } from '@operato/ops-contract';
23
23
  import { OP_EVENT, capabilityOf, itemKeyOf, judgeAgainstSpec, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from '@operato/ops-contract';
24
24
  import { ILMD_ATTR, parseEpc } from '@operato/ops-contract';
25
25
  import { expiryFromAttributes, lotFromAttributes } from '@operato/ops-contract';
@@ -345,6 +345,8 @@ export class ObservedReducer {
345
345
  switch (e.eventType) {
346
346
  case OP_EVENT.task: {
347
347
  const d = e.data;
348
+ if (d.workShiftBasis !== undefined && !isWorkShiftExecutionBasis(d.workShiftBasis))
349
+ throw new Error('Invalid pinned work shift evidence');
348
350
  if (this.stale(`task:${d.taskId}`, e))
349
351
  return;
350
352
  /* 커널이 실어 보낸 것을 그대로 담는다 — 진척·남은 시간·의도가 있어야 이 상태를 씨앗으로
@@ -387,6 +389,7 @@ export class ObservedReducer {
387
389
  ...(d.resources?.length ? { resources: d.resources.slice() } : {}),
388
390
  /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다).
389
391
  말하지 않았으면 **앞의 것을 지킨다**(위 주석) — 빈 배열은 「지웠다」로 말한 것이다. */
392
+ ...(d.workShiftBasis ? { workShiftBasis: structuredClone(d.workShiftBasis) } : prevTask?.workShiftBasis ? { workShiftBasis: structuredClone(prevTask.workShiftBasis) } : {}),
390
393
  ...(d.materialActual?.length
391
394
  ? { materialActual: d.materialActual.map(r => ({ ...r })) }
392
395
  : keptActual?.length
@@ -509,6 +512,8 @@ export class ObservedReducer {
509
512
  * **지금 상태를 바꾸지 않는다.** 어제의 구간이 오늘의 상태가 되면 안 된다.
510
513
  */
511
514
  const d = e.data;
515
+ if (d.workShiftBasis !== undefined && !isWorkShiftExecutionBasis(d.workShiftBasis))
516
+ throw new Error('Invalid pinned work shift evidence');
512
517
  if (!d?.moverId || !d?.from || !d?.to)
513
518
  break;
514
519
  const from = Date.parse(d.from);
@@ -723,6 +728,7 @@ export class ObservedReducer {
723
728
  /* 어느 레시피로 만드는가 — 품목만으로는 「무엇으로」가 남지 않는다(같은 품목에 대체 레시피). */
724
729
  ...(d.recipeKey ? { recipeKey: d.recipeKey } : {}),
725
730
  ...(d.recipeBasis ? { recipeBasis: structuredClone(d.recipeBasis) } : {}),
731
+ ...(d.materialBasis ? { materialBasis: structuredClone(d.materialBasis) } : {}),
726
732
  ...(d.allocated?.length ? { allocated: d.allocated.slice() } : {}),
727
733
  ...(d.bizTransaction ? { bizTransaction: d.bizTransaction } : {}),
728
734
  /* 이행하는 계획 — 「이 계획이 얼마나 됐나」를 이 값으로 묶어 답한다(§`OrderState.operationsRequestId`). */
@@ -1321,7 +1327,7 @@ export class ObservedReducer {
1321
1327
  /* 수량으로 담긴 것도 이어받는다 — 되풀어 주지 않는 축이다(원천은 담김을 다시 말해 주지 않는다). */
1322
1328
  qtyAggregation: [...this.qtyAggregation.entries()].map(([parent, quantities]) => ({ parent, quantities })),
1323
1329
  pendingQuantities: [...this.pendingQuantities.entries()].map(([parent, quantities]) => ({ parent, quantities })),
1324
- tasks: [...this.tasks.values()].map(t => ({ ...t })),
1330
+ tasks: [...this.tasks.values()].map(t => structuredClone(t)),
1325
1331
  equipment: [...this.equipment.values()].map(m => ({ ...m })),
1326
1332
  persons: [...this.persons.values()].map(x => ({ ...x })),
1327
1333
  assets: [...this.assets.values()].map(x => ({ ...x })),
@@ -1349,6 +1355,10 @@ export class ObservedReducer {
1349
1355
  * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
1350
1356
  */
1351
1357
  restore(cp) {
1358
+ for (const task of cp?.tasks ?? []) {
1359
+ if (task.workShiftBasis !== undefined && !isWorkShiftExecutionBasis(task.workShiftBasis))
1360
+ throw new Error('Invalid pinned work shift evidence');
1361
+ }
1352
1362
  this.observations = new Map((cp?.observations ?? []).map(o => [o.id, new Map((o.values ?? []).map(v => [v.propertyId, { ...v }]))]));
1353
1363
  this.revision = cp?.revision ?? 0;
1354
1364
  this.master = new Map((cp?.master ?? []).map(n => [n.id, { ...n }]));
@@ -1359,7 +1369,7 @@ export class ObservedReducer {
1359
1369
  this.pendingParent = new Map((cp?.pendingParent ?? []).map(x => [x.child, x.parent]));
1360
1370
  this.qtyAggregation = new Map((cp?.qtyAggregation ?? []).map(x => [x.parent, x.quantities]));
1361
1371
  this.pendingQuantities = new Map((cp?.pendingQuantities ?? []).map(x => [x.parent, x.quantities]));
1362
- this.tasks = new Map((cp?.tasks ?? []).map(t => [t.id, { ...t }]));
1372
+ this.tasks = new Map((cp?.tasks ?? []).map(t => [t.id, structuredClone(t)]));
1363
1373
  this.equipment = new Map((cp?.equipment ?? []).map(m => [m.id, { ...m }]));
1364
1374
  this.oeeCounters = new Map((cp?.oeeCounters ?? []).map((c) => [String(c.id), { ...emptyOeeCounters(), ...c, id: undefined }]));
1365
1375
  this.persons = new Map((cp?.persons ?? []).map(x => [x.id, { ...x }]));
@@ -1412,7 +1422,7 @@ export class ObservedReducer {
1412
1422
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
1413
1423
  persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
1414
1424
  assets: [...this.assets.values()].map(a => ({ ...a, ...this.effectivityPart(a), ...this.capabilityPart(a, `asset:${a.id}`, a.assetClassIds, this.classDefs.asset) })),
1415
- tasks: [...this.tasks.values()].map(t => ({ ...t })),
1425
+ tasks: [...this.tasks.values()].map(t => structuredClone(t)),
1416
1426
  equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.oeePart(m.id), ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
1417
1427
  orders: [...this.orders.values()].map(o => ({ ...o })),
1418
1428
  acked: [...this.acked]
@@ -1846,6 +1846,43 @@ function readEpochMs(raw) {
1846
1846
  return Number.isNaN(at) ? void 0 : at;
1847
1847
  }
1848
1848
 
1849
+ // ../ops-contract/dist/work-shift-basis.js
1850
+ var instant = (value) => typeof value === "string" && /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value));
1851
+ var nonempty = (value) => typeof value === "string" && !!value.trim();
1852
+ var clock = (value) => typeof value === "string" && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value);
1853
+ function isWorkShiftExecutionBasis(value) {
1854
+ if (!value || typeof value !== "object" || Array.isArray(value))
1855
+ return false;
1856
+ const b = value;
1857
+ if (![b.basisId, b.arrangementId, b.revisionId, b.planId, b.contentHash].every(nonempty) || !Number.isSafeInteger(b.revisionNumber) || b.revisionNumber < 1 || !instant(b.businessTime) || !nonempty(b.timezone))
1858
+ return false;
1859
+ try {
1860
+ new Intl.DateTimeFormat("en", { timeZone: b.timezone }).format(0);
1861
+ } catch {
1862
+ return false;
1863
+ }
1864
+ if (typeof b.workDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(b.workDate) || !Number.isFinite(Date.parse(b.workDate)) || new Date(b.workDate).toISOString().slice(0, 10) !== b.workDate)
1865
+ return false;
1866
+ if (!Array.isArray(b.shifts) || !b.shifts.length || b.shifts.length > 64)
1867
+ return false;
1868
+ const names = /* @__PURE__ */ new Set();
1869
+ for (const s of b.shifts) {
1870
+ if (!s || typeof s !== "object" || !nonempty(s.name) || names.has(s.name) || s.description !== void 0 && typeof s.description !== "string" || ![-1, 0, 1].includes(s.fromDate) || ![-1, 0, 1].includes(s.toDate) || !clock(s.fromTime) || !clock(s.toTime) || typeof s.daysOfWeek !== "string" || !/^(?:[0-6](?:,[0-6])*)?$/.test(s.daysOfWeek))
1871
+ return false;
1872
+ const minutes = (time) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3));
1873
+ const duration = (s.toDate - s.fromDate) * 1440 + minutes(s.toTime) - minutes(s.fromTime);
1874
+ if (duration <= 0 || duration > 1440)
1875
+ return false;
1876
+ names.add(s.name);
1877
+ }
1878
+ if (b.shiftName === void 0)
1879
+ return b.occurrence === void 0;
1880
+ if (!nonempty(b.shiftName) || !names.has(b.shiftName) || !b.occurrence || !instant(b.occurrence.from) || !instant(b.occurrence.to))
1881
+ return false;
1882
+ const at = Date.parse(b.businessTime);
1883
+ return Date.parse(b.occurrence.from) <= at && at < Date.parse(b.occurrence.to);
1884
+ }
1885
+
1849
1886
  // ../ops-contract/dist/operational-ingest.js
1850
1887
  var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
1851
1888
  var EQUIPMENT_STATUS = ["idle", "busy", "down", "setup", "planned-stop"];
@@ -1881,7 +1918,8 @@ var SPECS = {
1881
1918
  startTime: "string",
1882
1919
  endTime: "string",
1883
1920
  materialActual: "object[]",
1884
- recordTime: "string"
1921
+ recordTime: "string",
1922
+ workShiftBasis: "object"
1885
1923
  },
1886
1924
  /*
1887
1925
  * **자재 줄의 안쪽** — 계약의 `MaterialActual` 그대로다.
@@ -1897,6 +1935,11 @@ var SPECS = {
1897
1935
  * 물려받으면 단위를 모르는 현장의 사실이 통째로 거절된다.
1898
1936
  */
1899
1937
  shapes: {
1938
+ workShiftBasis: {
1939
+ required: ["basisId", "arrangementId", "revisionId", "revisionNumber", "planId", "contentHash", "businessTime", "timezone", "shifts", "workDate"],
1940
+ fields: { basisId: "string", arrangementId: "string", revisionId: "string", revisionNumber: "number", planId: "string", contentHash: "string", businessTime: "string", timezone: "string", workDate: "string" }
1941
+ // Full nested content and selected occurrence are checked by isWorkShiftExecutionBasis below.
1942
+ },
1900
1943
  materialActual: {
1901
1944
  required: ["definitionId", "use"],
1902
1945
  fields: { definitionId: "string", lotId: "string", quantity: "number", uom: "string" },
@@ -2023,13 +2066,19 @@ var SPECS = {
2023
2066
  * 새 어휘가 아니다 — 계약이 `recipeKey` 를 네 자리에서 이미 쓴다. 오더 관측에만 자리가 없었다.
2024
2067
  */
2025
2068
  recipeKey: "string",
2026
- recipeBasis: "object"
2069
+ recipeBasis: "object",
2070
+ materialBasis: "object[]"
2027
2071
  },
2028
2072
  /*
2029
2073
  * 확보분의 안쪽을 본다 — 열쇠와 수 둘이다(§`AllocatedQuantity`). 안 보면 틀린 이름이 조용히
2030
2074
  * 지나가고(`epcs`·`quantity` 따위), 미러는 잡힌 수를 0 으로 읽는다.
2031
2075
  */
2032
2076
  shapes: {
2077
+ materialBasis: {
2078
+ required: ["basisId", "materialId", "revisionId", "revisionNumber", "planId", "contentHash", "businessTime", "material"],
2079
+ fields: { basisId: "string", materialId: "string", revisionId: "string", revisionNumber: "number", planId: "string", contentHash: "string", businessTime: "string" }
2080
+ // Recursive material properties and reference lists are checked by isMaterialExecutionBasis.
2081
+ },
2033
2082
  recipeBasis: {
2034
2083
  required: ["basisId", "recipeId", "revisionId", "planId", "contentHash", "businessTime", "recipe"],
2035
2084
  fields: { basisId: "string", recipeId: "string", revisionId: "string", planId: "string", contentHash: "string", businessTime: "string" }
@@ -2115,7 +2164,14 @@ var SPECS = {
2115
2164
  /* 사람이 정한 사유 — 없을 수 있다(적지 않은 것과 사유가 없는 것은 다르므로 지어내지 않는다). */
2116
2165
  reasonCode: "string",
2117
2166
  decidedBy: "string",
2118
- recordTime: "string"
2167
+ recordTime: "string",
2168
+ workShiftBasis: "object"
2169
+ },
2170
+ shapes: {
2171
+ workShiftBasis: {
2172
+ required: ["basisId", "arrangementId", "revisionId", "revisionNumber", "planId", "contentHash", "businessTime", "timezone", "shifts", "workDate"],
2173
+ fields: { basisId: "string", arrangementId: "string", revisionId: "string", revisionNumber: "number", planId: "string", contentHash: "string", businessTime: "string", timezone: "string", workDate: "string" }
2174
+ }
2119
2175
  },
2120
2176
  enums: { status: EQUIPMENT_STATUS }
2121
2177
  },
@@ -2898,6 +2954,7 @@ var ObservedReducer = class {
2898
2954
  switch (e.eventType) {
2899
2955
  case OP_EVENT.task: {
2900
2956
  const d = e.data;
2957
+ if (d.workShiftBasis !== void 0 && !isWorkShiftExecutionBasis(d.workShiftBasis)) throw new Error("Invalid pinned work shift evidence");
2901
2958
  if (this.stale(`task:${d.taskId}`, e)) return;
2902
2959
  this.touchLocation(d.fromNode);
2903
2960
  this.touchLocation(d.toNode);
@@ -2922,6 +2979,7 @@ var ObservedReducer = class {
2922
2979
  ...d.resources?.length ? { resources: d.resources.slice() } : {},
2923
2980
  /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다).
2924
2981
  말하지 않았으면 **앞의 것을 지킨다**(위 주석) — 빈 배열은 「지웠다」로 말한 것이다. */
2982
+ ...d.workShiftBasis ? { workShiftBasis: structuredClone(d.workShiftBasis) } : prevTask?.workShiftBasis ? { workShiftBasis: structuredClone(prevTask.workShiftBasis) } : {},
2925
2983
  ...d.materialActual?.length ? { materialActual: d.materialActual.map((r) => ({ ...r })) } : keptActual?.length ? { materialActual: keptActual.map((r) => ({ ...r })) } : {},
2926
2984
  ...d.priority !== void 0 ? { priority: d.priority } : {},
2927
2985
  ...d.startTime ? { startTime: d.startTime } : {},
@@ -3003,6 +3061,7 @@ var ObservedReducer = class {
3003
3061
  }
3004
3062
  case OP_EVENT.equipmentPeriod: {
3005
3063
  const d = e.data;
3064
+ if (d.workShiftBasis !== void 0 && !isWorkShiftExecutionBasis(d.workShiftBasis)) throw new Error("Invalid pinned work shift evidence");
3006
3065
  if (!d?.moverId || !d?.from || !d?.to) break;
3007
3066
  const from = Date.parse(d.from);
3008
3067
  const to = Date.parse(d.to);
@@ -3112,6 +3171,7 @@ var ObservedReducer = class {
3112
3171
  /* 어느 레시피로 만드는가 — 품목만으로는 「무엇으로」가 남지 않는다(같은 품목에 대체 레시피). */
3113
3172
  ...d.recipeKey ? { recipeKey: d.recipeKey } : {},
3114
3173
  ...d.recipeBasis ? { recipeBasis: structuredClone(d.recipeBasis) } : {},
3174
+ ...d.materialBasis ? { materialBasis: structuredClone(d.materialBasis) } : {},
3115
3175
  ...d.allocated?.length ? { allocated: d.allocated.slice() } : {},
3116
3176
  ...d.bizTransaction ? { bizTransaction: d.bizTransaction } : {},
3117
3177
  /* 이행하는 계획 — 「이 계획이 얼마나 됐나」를 이 값으로 묶어 답한다(§`OrderState.operationsRequestId`). */
@@ -3536,7 +3596,7 @@ var ObservedReducer = class {
3536
3596
  /* 수량으로 담긴 것도 이어받는다 — 되풀어 주지 않는 축이다(원천은 담김을 다시 말해 주지 않는다). */
3537
3597
  qtyAggregation: [...this.qtyAggregation.entries()].map(([parent, quantities]) => ({ parent, quantities })),
3538
3598
  pendingQuantities: [...this.pendingQuantities.entries()].map(([parent, quantities]) => ({ parent, quantities })),
3539
- tasks: [...this.tasks.values()].map((t) => ({ ...t })),
3599
+ tasks: [...this.tasks.values()].map((t) => structuredClone(t)),
3540
3600
  equipment: [...this.equipment.values()].map((m) => ({ ...m })),
3541
3601
  persons: [...this.persons.values()].map((x) => ({ ...x })),
3542
3602
  assets: [...this.assets.values()].map((x) => ({ ...x })),
@@ -3564,6 +3624,9 @@ var ObservedReducer = class {
3564
3624
  * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
3565
3625
  */
3566
3626
  restore(cp) {
3627
+ for (const task of cp?.tasks ?? []) {
3628
+ if (task.workShiftBasis !== void 0 && !isWorkShiftExecutionBasis(task.workShiftBasis)) throw new Error("Invalid pinned work shift evidence");
3629
+ }
3567
3630
  this.observations = new Map(
3568
3631
  (cp?.observations ?? []).map((o) => [o.id, new Map((o.values ?? []).map((v) => [v.propertyId, { ...v }]))])
3569
3632
  );
@@ -3576,7 +3639,7 @@ var ObservedReducer = class {
3576
3639
  this.pendingParent = new Map((cp?.pendingParent ?? []).map((x) => [x.child, x.parent]));
3577
3640
  this.qtyAggregation = new Map((cp?.qtyAggregation ?? []).map((x) => [x.parent, x.quantities]));
3578
3641
  this.pendingQuantities = new Map((cp?.pendingQuantities ?? []).map((x) => [x.parent, x.quantities]));
3579
- this.tasks = new Map((cp?.tasks ?? []).map((t) => [t.id, { ...t }]));
3642
+ this.tasks = new Map((cp?.tasks ?? []).map((t) => [t.id, structuredClone(t)]));
3580
3643
  this.equipment = new Map((cp?.equipment ?? []).map((m) => [m.id, { ...m }]));
3581
3644
  this.oeeCounters = new Map(
3582
3645
  (cp?.oeeCounters ?? []).map((c) => [String(c.id), { ...emptyOeeCounters(), ...c, id: void 0 }])
@@ -3630,7 +3693,7 @@ var ObservedReducer = class {
3630
3693
  만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
3631
3694
  persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`), ...this.capabilityPart(p, `person:${p.id}`, p.personnelClassIds, this.classDefs.personnel) })),
3632
3695
  assets: [...this.assets.values()].map((a) => ({ ...a, ...this.effectivityPart(a), ...this.capabilityPart(a, `asset:${a.id}`, a.assetClassIds, this.classDefs.asset) })),
3633
- tasks: [...this.tasks.values()].map((t) => ({ ...t })),
3696
+ tasks: [...this.tasks.values()].map((t) => structuredClone(t)),
3634
3697
  equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.oeePart(m.id), ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`), ...this.capabilityPart(m, `eq:${m.id}`, m.kind ? [m.kind] : [], this.classDefs.equipment) })),
3635
3698
  orders: [...this.orders.values()].map((o) => ({ ...o })),
3636
3699
  acked: [...this.acked]
@@ -4527,6 +4590,9 @@ var FlowEngine = class {
4527
4590
  return { kept, dropped };
4528
4591
  }
4529
4592
  hydrateObserved(snap, orders = [], purpose = "seed") {
4593
+ for (const task of snap.tasks ?? []) {
4594
+ if (task.workShiftBasis !== void 0 && !isWorkShiftExecutionBasis(task.workShiftBasis)) throw new Error("Invalid pinned work shift evidence");
4595
+ }
4530
4596
  for (const id of snap.acked ?? []) this._acked.add(id);
4531
4597
  for (const e of snap.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
4532
4598
  for (const n of snap.locations) {
@@ -4688,6 +4754,7 @@ var FlowEngine = class {
4688
4754
  ...o.gtin ? { gtin: o.gtin } : {},
4689
4755
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
4690
4756
  ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
4757
+ ...o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {},
4691
4758
  ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
4692
4759
  ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
4693
4760
  ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
@@ -4713,6 +4780,7 @@ var FlowEngine = class {
4713
4780
  남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
4714
4781
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
4715
4782
  ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
4783
+ ...o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {},
4716
4784
  bizTransaction: o.bizTransaction ?? "",
4717
4785
  allocated: resolved.kept,
4718
4786
  picked: [],
@@ -4766,6 +4834,7 @@ var FlowEngine = class {
4766
4834
  status: purpose === "observe" ? t.status : known && t.status === "in-progress" ? "in-progress" : "created",
4767
4835
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
4768
4836
  ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
4837
+ ...t.workShiftBasis ? { workShiftBasis: structuredClone(t.workShiftBasis) } : {},
4769
4838
  itemEpc: t.itemRefs?.[0] ?? "",
4770
4839
  fromNode: t.fromNode ?? "",
4771
4840
  toNode: t.toNode ?? "",
@@ -5123,6 +5192,7 @@ var FlowEngine = class {
5123
5192
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
5124
5193
  ...t.resources?.length ? { resources: t.resources.slice() } : {},
5125
5194
  ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
5195
+ ...t.workShiftBasis ? { workShiftBasis: structuredClone(t.workShiftBasis) } : {},
5126
5196
  ...t.priority !== void 0 ? { priority: t.priority } : {},
5127
5197
  ...t.startTime ? { startTime: t.startTime } : {},
5128
5198
  ...t.endTime ? { endTime: t.endTime } : {}
@@ -5147,6 +5217,7 @@ var FlowEngine = class {
5147
5217
  /* **어느 레시피로 만드나** — 품목만으로는 「무엇으로」가 안 남는다(같은 품목의 대체 레시피가 있다). */
5148
5218
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
5149
5219
  ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
5220
+ ...o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {},
5150
5221
  /* 씨앗이 다 심지 못했다는 사실 — 조용히 자르지 않는다(그 오더의 답은 부족한 씨앗 위에 있다). */
5151
5222
  ...o.seedIncomplete ? { seedIncomplete: true } : {},
5152
5223
  /* 왜 대기하는지 — 없으면 화면은 「running」만 보여 주고 사람은 원인을 찾을 자리가 없다. */
@@ -6141,6 +6212,7 @@ var FlowEngine = class {
6141
6212
  ...t.resources?.length ? { resources: t.resources.slice() } : {},
6142
6213
  /* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
6143
6214
  ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
6215
+ ...t.workShiftBasis ? { workShiftBasis: structuredClone(t.workShiftBasis) } : {},
6144
6216
  ...t.durationMs ? { durationMs: t.durationMs } : {},
6145
6217
  /* 품질 판정이 있었으면 **그 자리에서** 실어 보낸다 — 없으면 필드를 만들지 않는다(모름≠양품). */
6146
6218
  ...t.outcome ? { outcome: t.outcome } : {},
@@ -6182,6 +6254,7 @@ var FlowEngine = class {
6182
6254
  레시피가 있다). 시뮬만 아는 사실이 남으면 두 스냅샷을 같은 규칙으로 읽을 수 없다. */
6183
6255
  ...o.recipeKey ? { recipeKey: o.recipeKey } : {},
6184
6256
  ...o.recipeBasis ? { recipeBasis: structuredClone(o.recipeBasis) } : {},
6257
+ ...o.materialBasis ? { materialBasis: structuredClone(o.materialBasis) } : {},
6185
6258
  ...o.allocated?.length ? { allocated: o.allocated.slice() } : {},
6186
6259
  ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
6187
6260
  /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
@@ -6356,7 +6429,7 @@ var FlowEngine = class {
6356
6429
  if (!remaining) continue;
6357
6430
  for (const it of this.items.at(at)) {
6358
6431
  if (remaining <= 0) break;
6359
- if (!this.materialMatches(it, req)) continue;
6432
+ if (!this.materialMatches(it, req, t.orderId)) continue;
6360
6433
  if (it.disposition === DISP.in_progress) continue;
6361
6434
  const already = takenSoFar.get(it.epc) ?? 0;
6362
6435
  const avail = Math.max(0, (it.qty ?? 1) - already);
@@ -6371,11 +6444,12 @@ var FlowEngine = class {
6371
6444
  return picked;
6372
6445
  }
6373
6446
  /** 이 물품이 명세를 만족하나 — 품목 지목이 우선, 없으면 등급(상속을 타고 닫는다). */
6374
- materialMatches(it, req) {
6447
+ materialMatches(it, req, orderId) {
6375
6448
  const defId = it.definitionId ?? it.gtin;
6376
6449
  if (req.materialDefinition) return defId === req.materialDefinition;
6377
6450
  if (req.materialClass) {
6378
- const def = defId ? this.materialDefs.get(defId) : void 0;
6451
+ const basis = orderId ? this.orders.get(orderId)?.materialBasis : void 0;
6452
+ const def = basis !== void 0 ? basis.find((b) => b.material.id === defId)?.material : defId ? this.materialDefs.get(defId) : void 0;
6379
6453
  return classClosure(def?.materialClassIds, this.classDefs.material, this.now()).has(req.materialClass);
6380
6454
  }
6381
6455
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.11.22",
3
+ "version": "0.11.23",
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.32"
31
+ "@operato/ops-contract": "^0.9.33"
32
32
  }
33
33
  }