@operato/twin-kernel 0.2.1 → 0.2.2

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.
@@ -223,6 +223,18 @@ export interface OrderState {
223
223
  status: string;
224
224
  progress?: number;
225
225
  held?: boolean;
226
+ /**
227
+ * 요청·이행 수량과 라인 — **진척(progress)으로 압축하지 않는다.**
228
+ *
229
+ * 예전에는 상태가 `progress` 하나만 들고 있어서, 미러에서 예측을 세우려면 **저널을 따로 읽어**
230
+ * 오더 원값을 되찾아야 했다(`buildForecastKernel`). 남은 데맨드(라인별 requested − fulfilled)를
231
+ * 모르면 "무엇을 얼마나 더 내보내야 하나" 를 재계획할 수 없기 때문이다.
232
+ *
233
+ * 진척은 이 둘에서 나오는 파생값이다 — 파생을 남기고 원본을 버린 것이 잘못이었다.
234
+ */
235
+ requested?: number;
236
+ fulfilled?: number;
237
+ lines?: ObservedOrderLine[];
226
238
  }
227
239
  export type AttentionSeverity = 'low' | 'medium' | 'high' | 'critical';
228
240
  export type AttentionState = 'active' | 'acknowledged' | 'cleared';
@@ -410,6 +422,11 @@ export interface TaskStatusDelta {
410
422
  personnel?: string[];
411
423
  /** 투입된 물리 자산(팔레트 등). */
412
424
  assets?: string[];
425
+ /**
426
+ * 투입된 설비 전부 — `resourceRef` 는 그중 **대표**(첫 번째)다.
427
+ * 대표만 두면 "크레인 + 스프레더" 처럼 함께 잡히는 설비가 상태에서 사라진다.
428
+ */
429
+ resources?: string[];
413
430
  orderId?: string;
414
431
  kind: string;
415
432
  status: TaskStatus;
@@ -126,6 +126,19 @@ export interface OperationDef {
126
126
  assetClass?: string;
127
127
  quantity: number;
128
128
  }[];
129
+ /**
130
+ * 필요 설비 — **ISA-95 `OperationsSegment.EquipmentSpecification`**(`EquipmentClassID` + `Quantity`).
131
+ *
132
+ * 예전에는 작업 하나에 설비 **한 대**만 붙었다(`resourceType` 하나). 그래서 "크레인 1대 + 스프레더
133
+ * 1대", "용접 로봇 2대가 함께" 같은 현장을 표현할 수 없었고, 그 동시 점유가 만드는 줄이 예측에서
134
+ * 사라졌다. 인원·자산과 **같은 규칙**으로 요구한다: 등급 + 대수, 부분 확보 없이 전량 아니면 대기.
135
+ *
136
+ * 미지정이면 기존 거동(`resourceType` 한 대, 없으면 아무 유휴 설비).
137
+ */
138
+ equipmentSpecification?: {
139
+ equipmentClass?: string;
140
+ quantity: number;
141
+ }[];
129
142
  }
130
143
  /** 라우트(오퍼레이션 시퀀스) — ISA-95 ProcessSegment 연결. */
131
144
  export interface RouteDef {
@@ -1,4 +1,4 @@
1
- import type { Attention, BoardDef, CanonicalEnvelope, Command, CommandAck, EventHandler, MoverMotion, OeeMetrics, AssetState, GeneratorSpec, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, NodeState, ItemState, MoverState, OrderStatusDelta, TaskState } from './contract.ts';
1
+ import type { Attention, BoardDef, CanonicalEnvelope, Command, CommandAck, EventHandler, MoverMotion, OeeMetrics, AssetState, GeneratorSpec, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, NodeState, ItemState, MoverState, OrderStatusDelta, TaskState } from './contract.ts';
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -95,6 +95,8 @@ export interface FlowTask {
95
95
  personnel?: string[];
96
96
  /** 이 작업에 투입된 물리 자산(팔레트 등). */
97
97
  assets?: string[];
98
+ /** 이 작업에 투입된 설비 전부 — `resource` 는 그중 대표(첫 번째). */
99
+ resources?: string[];
98
100
  remainingMs: number;
99
101
  durationMs: number;
100
102
  orderId?: string;
@@ -255,6 +257,7 @@ export declare abstract class FlowEngine implements TwinKernel {
255
257
  persons?: PersonState[];
256
258
  assets?: AssetState[];
257
259
  tasks?: TaskState[];
260
+ orders?: OrderState[];
258
261
  }, orders?: OrderStatusDelta[]): void;
259
262
  /** what-if 구성 변주 — 노드 용량 변경(fork 대상). 존재하면 true. */
260
263
  setNodeCapacity(nodeId: string, capacity: number): boolean;
@@ -459,6 +462,7 @@ export declare abstract class FlowEngine implements TwinKernel {
459
462
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
460
463
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
461
464
  protected emitMover(m: FlowMover, motion?: MoverMotion): void;
465
+ /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
462
466
  protected emitOrder(o: FlowOrder): void;
463
467
  /**
464
468
  * 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
@@ -507,6 +511,12 @@ export declare abstract class FlowEngine implements TwinKernel {
507
511
  * (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
508
512
  */
509
513
  private claimPersonnel;
514
+ /**
515
+ * 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
516
+ * 명세(`equipmentSpecification`)가 없으면 기존 거동: `resourceType` 한 대(없으면 아무 유휴 설비).
517
+ * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
518
+ */
519
+ private claimEquipment;
510
520
  /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
511
521
  private assignCrew;
512
522
  /** 작업이 끝나면 사람을 놓아 준다 — 설비 해제와 별개 경로. */
@@ -288,7 +288,14 @@ export class FlowEngine {
288
288
  }
289
289
  }
290
290
  }
291
- for (const o of orders) {
291
+ /* 오더 원값은 이제 **스냅샷에 있다** — 따로 넘겨받은 것이 없으면 스냅샷에서 읽는다.
292
+ * (예전에는 상태가 progress 로 압축돼 호출부가 저널을 뒤져 원값을 넘겨야 했다.) */
293
+ const observedOrders = orders.length
294
+ ? orders
295
+ : (snap.orders ?? [])
296
+ .filter(o => o.requested !== undefined)
297
+ .map(o => ({ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines }));
298
+ for (const o of observedOrders) {
292
299
  const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
293
300
  const remaining = lines.reduce((s, l) => s + l.requested, 0);
294
301
  if (remaining <= 0)
@@ -491,9 +498,16 @@ export class FlowEngine {
491
498
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
492
499
  ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
493
500
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
494
- ...(t.assets?.length ? { assets: t.assets.slice() } : {})
501
+ ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
502
+ ...(t.resources?.length ? { resources: t.resources.slice() } : {})
503
+ })),
504
+ orders: [...this.orders.values()].map(o => ({
505
+ id: o.id, kind: o.kind, status: o.status,
506
+ progress: o.requested ? o.fulfilled / o.requested : 0,
507
+ requested: o.requested, fulfilled: o.fulfilled,
508
+ ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
509
+ held: o.held
495
510
  })),
496
- orders: [...this.orders.values()].map(o => ({ id: o.id, kind: o.kind, status: o.status, progress: o.requested ? o.fulfilled / o.requested : 0, held: o.held })),
497
511
  attentions: this.computeAttentions()
498
512
  };
499
513
  }
@@ -932,6 +946,7 @@ export class FlowEngine {
932
946
  intent: t.intent,
933
947
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
934
948
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
949
+ ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
935
950
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
936
951
  ...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
937
952
  });
@@ -948,7 +963,13 @@ export class FlowEngine {
948
963
  emitMover(m, motion) {
949
964
  this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
950
965
  }
951
- emitOrder(o) { this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held }); }
966
+ /** 오더 델타 **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 없다. */
967
+ emitOrder(o) {
968
+ this.emitOp(OP_EVENT.order, {
969
+ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held,
970
+ ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {})
971
+ });
972
+ }
952
973
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
953
974
  /**
954
975
  * 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
@@ -1097,6 +1118,31 @@ export class FlowEngine {
1097
1118
  }
1098
1119
  return picked;
1099
1120
  }
1121
+ /**
1122
+ * 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
1123
+ * 명세(`equipmentSpecification`)가 없으면 기존 거동: `resourceType` 한 대(없으면 아무 유휴 설비).
1124
+ * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
1125
+ */
1126
+ claimEquipment(t) {
1127
+ const free = (kind, picked = []) => [...this.movers.values()].filter(m => m.status === 'idle' && !m.held && !this.offShift(m) && !picked.includes(m.id) && (kind === undefined || m.kind === kind));
1128
+ const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
1129
+ if (!need?.length) {
1130
+ const one = free(t.resourceType)[0];
1131
+ return one ? [one.id] : null;
1132
+ }
1133
+ const picked = [];
1134
+ for (const req of need) {
1135
+ const want = Math.max(0, Math.floor(req.quantity ?? 0));
1136
+ if (!want)
1137
+ continue;
1138
+ const avail = free(req.equipmentClass, picked);
1139
+ if (avail.length < want)
1140
+ return null; // 한 등급이라도 모자라면 시작하지 않는다
1141
+ for (let i = 0; i < want; i++)
1142
+ picked.push(avail[i].id);
1143
+ }
1144
+ return picked.length ? picked : null;
1145
+ }
1100
1146
  /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
1101
1147
  assignCrew(t, crew) {
1102
1148
  if (!crew.length)
@@ -1305,10 +1351,12 @@ export class FlowEngine {
1305
1351
  * 이 제약이 없으면 대기가 생기지 않아 병목이 사라지고 예측이 낙관 쪽으로 치우친다. */
1306
1352
  if (this.stationFull(t.toNode))
1307
1353
  continue;
1308
- // resourceType 있으면 kind 무버만; 없으면 아무 유휴 무버. 교대 자원은 배정하지 않는다.
1309
- const mover = [...this.movers.values()].find(m => m.status === 'idle' && !m.held && !this.offShift(m) && (t.resourceType === undefined || m.kind === t.resourceType));
1310
- if (!mover)
1311
- continue; // 맞는 유휴 자원 없음 → 다음 task(break 아님: 다른 타입은 가용할 수 있음)
1354
+ /* 필요 설비 명세가 있으면 등급별 대수만큼(부분 확보 없이), 없으면 기존대로 대.
1355
+ * 확보 실패는 `null` 다음 작업으로 넘어간다(다른 타입은 가용할 있다). */
1356
+ const rigs = this.claimEquipment(t);
1357
+ if (!rigs)
1358
+ continue;
1359
+ const mover = this.movers.get(rigs[0]);
1312
1360
  // 체인지오버: task 의 changeoverKey 가 무버 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
1313
1361
  if (t.setupMs && t.changeoverKey !== undefined && mover.lastChangeoverKey !== undefined && mover.lastChangeoverKey !== t.changeoverKey) {
1314
1362
  t.appliedSetupMs = t.setupMs;
@@ -1316,12 +1364,17 @@ export class FlowEngine {
1316
1364
  }
1317
1365
  if (t.changeoverKey !== undefined)
1318
1366
  mover.lastChangeoverKey = t.changeoverKey;
1319
- mover.status = 'busy';
1320
- mover.taskId = t.id;
1367
+ for (const id of rigs) {
1368
+ const m = this.movers.get(id);
1369
+ m.status = 'busy';
1370
+ m.taskId = t.id;
1371
+ }
1321
1372
  t.status = 'in-progress';
1322
1373
  t.resource = mover.id;
1323
1374
  t.remainingMs = t.durationMs;
1324
1375
  t.startedAtSimMs = this.clockMs;
1376
+ if (rigs.length > 1)
1377
+ t.resources = rigs.slice(); // 대표만으로는 함께 잡힌 설비가 사라진다
1325
1378
  this.assignCrew(t, crew);
1326
1379
  this.assignAssets(t, gear);
1327
1380
  this.emitTask(t);
@@ -1359,6 +1412,21 @@ export class FlowEngine {
1359
1412
  mover.taskId = null;
1360
1413
  if (t.intent !== 'process')
1361
1414
  mover.location = t.toNode; // 운반만 위치 이동; process 는 제자리
1415
+ /* 함께 잡힌 설비도 같은 규칙으로 놓아 준다 — 대표만 풀면 나머지가 영원히 묶인다. */
1416
+ for (const id of t.resources ?? []) {
1417
+ if (id === mover.id)
1418
+ continue;
1419
+ const m = this.movers.get(id);
1420
+ if (!m)
1421
+ continue;
1422
+ m.setupMs += setup;
1423
+ m.runMs += t.durationMs - setup;
1424
+ m.status = 'idle';
1425
+ m.taskId = null;
1426
+ if (t.intent !== 'process')
1427
+ m.location = t.toNode;
1428
+ this.emitMover(m);
1429
+ }
1362
1430
  this.emitTask(t);
1363
1431
  this.emitMover(mover);
1364
1432
  }
@@ -148,7 +148,8 @@ export class ObservedReducer {
148
148
  intent: d.intent, progress: d.progress, remainingMs: d.remainingMs, durationMs: d.durationMs,
149
149
  startedAtSimMs: d.startedAtSimMs,
150
150
  ...(d.personnel?.length ? { personnel: d.personnel.slice() } : {}),
151
- ...(d.assets?.length ? { assets: d.assets.slice() } : {})
151
+ ...(d.assets?.length ? { assets: d.assets.slice() } : {}),
152
+ ...(d.resources?.length ? { resources: d.resources.slice() } : {})
152
153
  });
153
154
  break;
154
155
  }
@@ -197,7 +198,16 @@ export class ObservedReducer {
197
198
  const d = e.data;
198
199
  if (this.stale(`order:${d.orderId}`, e))
199
200
  return;
200
- this.orders.set(d.orderId, { id: d.orderId, kind: d.kind, status: d.status, progress: d.requested ? d.fulfilled / d.requested : 0, held: d.held });
201
+ /* **진척으로 압축하지 않는다.** 예전에는 progress 남겨서, 미러에서 예측을 세우려면 저널을
202
+ * 따로 읽어 오더 원값을 되찾아야 했다. 진척은 요청·이행에서 나오는 파생이다 — 파생을 남기고
203
+ * 원본을 버리면 남은 데맨드를 재계획할 수 없다. */
204
+ this.orders.set(d.orderId, {
205
+ id: d.orderId, kind: d.kind, status: d.status,
206
+ progress: d.requested ? d.fulfilled / d.requested : 0,
207
+ requested: d.requested, fulfilled: d.fulfilled,
208
+ ...(d.lines?.length ? { lines: d.lines.map(l => ({ ...l })) } : {}),
209
+ held: d.held
210
+ });
201
211
  break;
202
212
  }
203
213
  // 알 수 없는 eventType 은 무시(전방 호환).
@@ -639,7 +639,8 @@ var ObservedReducer = class {
639
639
  durationMs: d.durationMs,
640
640
  startedAtSimMs: d.startedAtSimMs,
641
641
  ...d.personnel?.length ? { personnel: d.personnel.slice() } : {},
642
- ...d.assets?.length ? { assets: d.assets.slice() } : {}
642
+ ...d.assets?.length ? { assets: d.assets.slice() } : {},
643
+ ...d.resources?.length ? { resources: d.resources.slice() } : {}
643
644
  });
644
645
  break;
645
646
  }
@@ -681,7 +682,16 @@ var ObservedReducer = class {
681
682
  case OP_EVENT.order: {
682
683
  const d = e.data;
683
684
  if (this.stale(`order:${d.orderId}`, e)) return;
684
- this.orders.set(d.orderId, { id: d.orderId, kind: d.kind, status: d.status, progress: d.requested ? d.fulfilled / d.requested : 0, held: d.held });
685
+ this.orders.set(d.orderId, {
686
+ id: d.orderId,
687
+ kind: d.kind,
688
+ status: d.status,
689
+ progress: d.requested ? d.fulfilled / d.requested : 0,
690
+ requested: d.requested,
691
+ fulfilled: d.fulfilled,
692
+ ...d.lines?.length ? { lines: d.lines.map((l) => ({ ...l })) } : {},
693
+ held: d.held
694
+ });
685
695
  break;
686
696
  }
687
697
  }
@@ -1448,7 +1458,8 @@ var FlowEngine = class {
1448
1458
  }
1449
1459
  }
1450
1460
  }
1451
- for (const o of orders) {
1461
+ const observedOrders = orders.length ? orders : (snap.orders ?? []).filter((o) => o.requested !== void 0).map((o) => ({ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines }));
1462
+ for (const o of observedOrders) {
1452
1463
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
1453
1464
  const remaining = lines.reduce((s, l) => s + l.requested, 0);
1454
1465
  if (remaining <= 0) continue;
@@ -1644,9 +1655,19 @@ var FlowEngine = class {
1644
1655
  ...t.durationMs ? { durationMs: t.durationMs } : {},
1645
1656
  ...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
1646
1657
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
1647
- ...t.assets?.length ? { assets: t.assets.slice() } : {}
1658
+ ...t.assets?.length ? { assets: t.assets.slice() } : {},
1659
+ ...t.resources?.length ? { resources: t.resources.slice() } : {}
1660
+ })),
1661
+ orders: [...this.orders.values()].map((o) => ({
1662
+ id: o.id,
1663
+ kind: o.kind,
1664
+ status: o.status,
1665
+ progress: o.requested ? o.fulfilled / o.requested : 0,
1666
+ requested: o.requested,
1667
+ fulfilled: o.fulfilled,
1668
+ ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {},
1669
+ held: o.held
1648
1670
  })),
1649
- orders: [...this.orders.values()].map((o) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.requested ? o.fulfilled / o.requested : 0, held: o.held })),
1650
1671
  attentions: this.computeAttentions()
1651
1672
  };
1652
1673
  }
@@ -2040,6 +2061,7 @@ var FlowEngine = class {
2040
2061
  intent: t.intent,
2041
2062
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
2042
2063
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
2064
+ ...t.resources?.length ? { resources: t.resources.slice() } : {},
2043
2065
  ...t.durationMs ? { durationMs: t.durationMs } : {},
2044
2066
  ...inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
2045
2067
  });
@@ -2056,8 +2078,17 @@ var FlowEngine = class {
2056
2078
  emitMover(m, motion) {
2057
2079
  this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? void 0, motion });
2058
2080
  }
2081
+ /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
2059
2082
  emitOrder(o) {
2060
- this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held });
2083
+ this.emitOp(OP_EVENT.order, {
2084
+ orderId: o.id,
2085
+ kind: o.kind,
2086
+ status: o.status,
2087
+ requested: o.requested,
2088
+ fulfilled: o.fulfilled,
2089
+ held: o.held,
2090
+ ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {}
2091
+ });
2061
2092
  }
2062
2093
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
2063
2094
  /**
@@ -2189,6 +2220,30 @@ var FlowEngine = class {
2189
2220
  }
2190
2221
  return picked;
2191
2222
  }
2223
+ /**
2224
+ * 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
2225
+ * 명세(`equipmentSpecification`)가 없으면 기존 거동: `resourceType` 한 대(없으면 아무 유휴 설비).
2226
+ * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
2227
+ */
2228
+ claimEquipment(t) {
2229
+ const free = (kind, picked2 = []) => [...this.movers.values()].filter(
2230
+ (m) => m.status === "idle" && !m.held && !this.offShift(m) && !picked2.includes(m.id) && (kind === void 0 || m.kind === kind)
2231
+ );
2232
+ const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
2233
+ if (!need?.length) {
2234
+ const one = free(t.resourceType)[0];
2235
+ return one ? [one.id] : null;
2236
+ }
2237
+ const picked = [];
2238
+ for (const req of need) {
2239
+ const want = Math.max(0, Math.floor(req.quantity ?? 0));
2240
+ if (!want) continue;
2241
+ const avail = free(req.equipmentClass, picked);
2242
+ if (avail.length < want) return null;
2243
+ for (let i = 0; i < want; i++) picked.push(avail[i].id);
2244
+ }
2245
+ return picked.length ? picked : null;
2246
+ }
2192
2247
  /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
2193
2248
  assignCrew(t, crew) {
2194
2249
  if (!crew.length) return;
@@ -2365,21 +2420,24 @@ var FlowEngine = class {
2365
2420
  continue;
2366
2421
  }
2367
2422
  if (this.stationFull(t.toNode)) continue;
2368
- const mover = [...this.movers.values()].find(
2369
- (m) => m.status === "idle" && !m.held && !this.offShift(m) && (t.resourceType === void 0 || m.kind === t.resourceType)
2370
- );
2371
- if (!mover) continue;
2423
+ const rigs = this.claimEquipment(t);
2424
+ if (!rigs) continue;
2425
+ const mover = this.movers.get(rigs[0]);
2372
2426
  if (t.setupMs && t.changeoverKey !== void 0 && mover.lastChangeoverKey !== void 0 && mover.lastChangeoverKey !== t.changeoverKey) {
2373
2427
  t.appliedSetupMs = t.setupMs;
2374
2428
  t.durationMs += t.setupMs;
2375
2429
  }
2376
2430
  if (t.changeoverKey !== void 0) mover.lastChangeoverKey = t.changeoverKey;
2377
- mover.status = "busy";
2378
- mover.taskId = t.id;
2431
+ for (const id of rigs) {
2432
+ const m = this.movers.get(id);
2433
+ m.status = "busy";
2434
+ m.taskId = t.id;
2435
+ }
2379
2436
  t.status = "in-progress";
2380
2437
  t.resource = mover.id;
2381
2438
  t.remainingMs = t.durationMs;
2382
2439
  t.startedAtSimMs = this.clockMs;
2440
+ if (rigs.length > 1) t.resources = rigs.slice();
2383
2441
  this.assignCrew(t, crew);
2384
2442
  this.assignAssets(t, gear);
2385
2443
  this.emitTask(t);
@@ -2409,6 +2467,17 @@ var FlowEngine = class {
2409
2467
  mover.status = "idle";
2410
2468
  mover.taskId = null;
2411
2469
  if (t.intent !== "process") mover.location = t.toNode;
2470
+ for (const id of t.resources ?? []) {
2471
+ if (id === mover.id) continue;
2472
+ const m = this.movers.get(id);
2473
+ if (!m) continue;
2474
+ m.setupMs += setup;
2475
+ m.runMs += t.durationMs - setup;
2476
+ m.status = "idle";
2477
+ m.taskId = null;
2478
+ if (t.intent !== "process") m.location = t.toNode;
2479
+ this.emitMover(m);
2480
+ }
2412
2481
  this.emitTask(t);
2413
2482
  this.emitMover(mover);
2414
2483
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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": {