@operato/twin-kernel 0.2.3 → 0.4.0

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,19 +1,32 @@
1
1
  /*
2
2
  * FlowEngine — 도메인-일반 flow 엔진 base (WMS·YMS 공유 mechanics 추출).
3
3
  *
4
- * WMS/YMS 커널의 mechanics(RNG·clock·scenario·tick 루프·무버 배정·태스크 진행·emit·snapshot·
4
+ * WMS/YMS 커널의 mechanics(RNG·clock·scenario·tick 루프·설비 배정·태스크 진행·emit·snapshot·
5
5
  * slotViews)는 동일했다 → 이 base 로 승격. 도메인 kernel 은 **flow 동사만** 구현:
6
6
  * onArrival(자극:입고 도착) · onOrder(자극:출고 오더) · allocate(오더→태스크) · onTaskComplete(태스크 완료)
7
7
  *
8
8
  * TwinKernel 구현 → StateProjector·TwinRuntime 가 도메인 무관하게 소비.
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
- * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowNode 단일 base 방향과 정합.)
10
+ * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, nodeStatusOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, priorityRank, dueStatusOf, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
- import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR } from "./epcis.js";
14
+ import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP } from "./epcis.js";
15
15
  import { parseIsoDuration } from "./iso-duration.js";
16
+ import { analyzeCapacity } from "./capacity.js";
16
17
  const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
18
+ /**
19
+ * 선언한 유효 기간만 뽑는다 — 마스터가 말하지 않으면 필드를 만들지 않는다.
20
+ *
21
+ * `{ effectiveStart: undefined }` 를 넣으면 스냅샷 왕복·미러 비교에서 "선언했지만 비었다" 와
22
+ * "선언하지 않았다" 가 구별되지 않는다(적합성 하네스가 갈라짐으로 잡는다).
23
+ */
24
+ function effectiveOnly(r) {
25
+ return {
26
+ ...(r.effectiveStart ? { effectiveStart: r.effectiveStart } : {}),
27
+ ...(r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {})
28
+ };
29
+ }
17
30
  function mulberry32(seed) {
18
31
  let a = seed >>> 0;
19
32
  const fn = (() => {
@@ -26,20 +39,22 @@ function mulberry32(seed) {
26
39
  return fn;
27
40
  }
28
41
  /**
29
- * 주목신호 계산(순수) — State 스냅샷(movers/nodes/orders)에서 attentions 파생.
42
+ * 주목신호 계산(순수) — State 스냅샷(equipment/locations/orders)에서 attentions 파생.
30
43
  * FlowEngine.computeAttentions(sim) 와 live projector 미러가 **공유**하는 계산 층(face2-inbound-live §1.1):
31
44
  * sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
32
45
  */
33
- export function deriveAttentions(view, acked) {
46
+ export function deriveAttentions(view, acked,
47
+ /** 지금(ISO) — 납기 판정에 필요하다. **주지 않으면 지연을 판정하지 않는다**(모르면 판단하지 않는다). */
48
+ nowIso) {
34
49
  // 언어 중립: kind + params(원시값) + 조치 code 만 방출. 사람이 읽는 title/detail/rationale/라벨은
35
50
  // 표현계층(클라 i18next)이 kind/code 로 렌더한다(무방언·다국어 — design/plans/i18n.md L3).
36
51
  const out = [];
37
- for (const m of view.movers) {
52
+ for (const m of view.equipment) {
38
53
  if (m.status === 'down') {
39
54
  out.push({
40
55
  id: `breakdown:${m.id}`, kind: 'breakdown', severity: 'critical',
41
- anchor: { moverId: m.id, nodeId: m.location },
42
- params: { moverId: m.id, ...(m.location ? { nodeId: m.location } : {}) },
56
+ anchor: { moverId: m.id, locationId: m.location },
57
+ params: { moverId: m.id, ...(m.location ? { locationId: m.location } : {}) },
43
58
  recommendedActions: [
44
59
  { code: 'act.repair', command: CMD.resourceRepair, args: { resourceId: m.id } },
45
60
  { code: 'act.hold-until-repair', command: CMD.resourceHold, args: { resourceId: m.id } }
@@ -48,29 +63,78 @@ export function deriveAttentions(view, acked) {
48
63
  });
49
64
  }
50
65
  }
51
- for (const n of view.nodes) {
66
+ for (const n of view.locations) {
52
67
  if ((n.capacity ?? 0) > 0) {
53
68
  const r = (n.occupancy ?? 0) / n.capacity;
54
69
  if (r >= 0.9) {
55
70
  const saturated = r >= 1;
56
71
  out.push({
57
72
  id: `bottleneck:${n.id}`, kind: 'bottleneck', severity: saturated ? 'high' : 'medium',
58
- anchor: { nodeId: n.id },
59
- params: { nodeId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
73
+ anchor: { locationId: n.id },
74
+ params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
60
75
  recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
61
76
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
62
77
  });
63
78
  }
64
79
  }
65
80
  }
66
- for (const m of view.movers) {
81
+ /*
82
+ * **일이 들어오는 속도를 처리 속도가 못 따라간다** — 자리 포화(`bottleneck`)와 다른 종류의 막힘이다.
83
+ *
84
+ * 실제로 이것을 놓쳤다. Rosarito 도장은 부스가 텅 비어 있었고(점유율 낮음) 대수가 모자랐다.
85
+ * 자리로 재는 규칙은 그런 공장을 "정상" 이라 답한다 — 오더는 무한히 쌓이는데 화면 어디에도
86
+ * 깨지는 지점이 없었고, 사람이 용량을 손으로 계산하고서야 알았다. **용량 부족은 조용하다.**
87
+ *
88
+ * 재는 방법은 스냅샷만으로 답할 수 있어야 한다(추세를 쌓아 두지 않는다). 그래서 같은 공정에서
89
+ * **기다리는 일과 하고 있는 일의 비**를 본다 — 처리 능력이 충분하면 대기가 길게 늘지 않는다.
90
+ * 공정이 아예 안 돌면(하는 일 0, 기다리는 일 여럿) 그것이 가장 강한 신호다.
91
+ *
92
+ * 공정 이름은 작업이 스스로 말하는 것(`kind`)을 쓴다. 설비 종류로 환산하지 않는다 — 그 대응은
93
+ * 현장마다 다르고, 커널이 그 방언을 알면 보편 계약이 깨진다.
94
+ */
95
+ const QUEUE_FLOOR = 5; // 이보다 얕은 줄은 정상 변동이다
96
+ /*
97
+ * **공장이 쉬는 동안 쌓이는 것은 신호가 아니다.** 주말·휴일·휴게 시간에 줄이 길어지는 것은 달력이
98
+ * 시킨 일이고, 월요일이면 빠진다. 그때마다 사람을 부르면 매주 토요일에 같은 경고가 떠서 — 진짜
99
+ * 막혔을 때의 경고까지 같이 무시하게 된다.
100
+ *
101
+ * 쉬는 중인지는 **설비가 이미 말하고 있다**(`offShift`, 달력에서 파생). 여기서 달력을 다시 읽지
102
+ * 않는다 — 두 벌이 되면 갈라진다. 설비를 아예 모르면 판단하지 않는다(줄만 보고 단정하지 않는다).
103
+ */
104
+ const shutdown = (view.equipment?.length ?? 0) > 0 && view.equipment.every(m => m.offShift);
105
+ if (view.tasks?.length && !shutdown) {
106
+ const waiting = new Map();
107
+ const working = new Map();
108
+ for (const t of view.tasks) {
109
+ const bucket = t.status === 'created' || t.status === 'assigned' ? waiting : t.status === 'in-progress' ? working : undefined;
110
+ if (bucket)
111
+ bucket.set(t.kind, (bucket.get(t.kind) ?? 0) + 1);
112
+ }
113
+ for (const [operation, queued] of waiting) {
114
+ if (queued < QUEUE_FLOOR)
115
+ continue;
116
+ const active = working.get(operation) ?? 0;
117
+ const ratio = queued / Math.max(active, 1);
118
+ if (active > 0 && ratio < 3)
119
+ continue;
120
+ out.push({
121
+ id: `work-backlog:${operation}`,
122
+ kind: 'work-backlog',
123
+ severity: active === 0 || ratio >= 6 ? 'high' : 'medium',
124
+ anchor: { operation },
125
+ params: { operation, queued, active, ratio: Math.round(ratio * 10) / 10 },
126
+ recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
127
+ });
128
+ }
129
+ }
130
+ for (const m of view.equipment) {
67
131
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
68
132
  if (total >= 10) {
69
133
  const rate = (m.scrapCount ?? 0) / total;
70
134
  if (rate >= 0.15)
71
135
  out.push({
72
136
  id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate >= 0.3 ? 'high' : 'medium',
73
- anchor: { moverId: m.id, nodeId: m.location },
137
+ anchor: { moverId: m.id, locationId: m.location },
74
138
  params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
75
139
  recommendedActions: [
76
140
  { code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } },
@@ -90,6 +154,26 @@ export function deriveAttentions(view, acked) {
90
154
  suggestedAction: { code: 'act.resume-order', command: CMD.orderResume, args: { orderId: o.id } }
91
155
  });
92
156
  }
157
+ /* 납기 초과 — **표준 `EndTime` 이 있어야 판정할 수 있다.** 없으면 신호를 만들지 않는다(없는 납기를
158
+ 지연으로도 정시로도 말하지 않는다). 이미 끝난 오더는 대상이 아니다.
159
+ 심각도는 얼마나 늦었는지로 가른다 — 방금 넘긴 것과 하루 넘긴 것을 같게 부르면 신호가 무의미해진다. */
160
+ for (const o of view.orders) {
161
+ if (o.status === 'completed' || o.status === 'fulfilled')
162
+ continue;
163
+ if (dueStatusOf(o, nowIso) !== 'late')
164
+ continue;
165
+ const overdueMs = Date.parse(nowIso) - Date.parse(o.endTime);
166
+ out.push({
167
+ id: `late:${o.id}`,
168
+ kind: 'order-late',
169
+ severity: overdueMs >= 3600_000 ? 'critical' : 'high',
170
+ anchor: { orderId: o.id },
171
+ params: { orderId: o.id, endTime: o.endTime, overdueMs, ...(o.priority !== undefined ? { priority: o.priority } : {}) },
172
+ /* 조치는 권고만 둔다 — 지연을 커널이 스스로 해소할 수단이 없다(자원을 늘리거나 우선순위를
173
+ 올리는 것은 사람의 결정이다). 없는 조치를 만들어 붙이지 않는다. */
174
+ recommendedActions: [{ code: 'advice.raise-priority' }, { code: 'advice.add-resource' }]
175
+ });
176
+ }
93
177
  if (acked)
94
178
  for (const a of out)
95
179
  if (acked.has(a.id))
@@ -115,9 +199,16 @@ export function computeOee(c, nowMs) {
115
199
  }
116
200
  export class FlowEngine {
117
201
  tenantId;
118
- nodes = new Map();
202
+ locations = new Map();
119
203
  items = new Map();
120
- movers = new Map();
204
+ equipment = new Map();
205
+ /** 등급 정의(표준 `<X>Class`) — 상속·유효기간 판정의 재료. 선언 안 하면 비어 있고, 소속 그대로 판정한다. */
206
+ classDefs = {};
207
+ /**
208
+ * 품목 정의 색인(표준 `MaterialDefinition`) — 단위 환산의 **유일한 근거**.
209
+ * 비어 있으면 환산하지 않는다(계수를 모르는데 값을 만들지 않는다).
210
+ */
211
+ materialDefs = new Map();
121
212
  /** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
122
213
  persons = new Map();
123
214
  /** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
@@ -164,61 +255,84 @@ export class FlowEngine {
164
255
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
165
256
  loadBoard(def) {
166
257
  this.boardDef = def;
167
- for (const n of def.nodes)
168
- this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
258
+ for (const n of readBoardLocations(def))
259
+ this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
260
+ this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
261
+ this.materialDefs = new Map((def.materialDefinitions ?? []).filter(d => d?.id).map(d => [d.id, d]));
169
262
  for (const p of def.persons ?? [])
170
- this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle', taskId: null, window: p.window });
171
- for (const a of def.assets ?? [])
172
- this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: 'idle', taskId: null });
173
- for (const m of def.movers) {
174
- const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window };
263
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.workCalendar ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOnly(p) });
264
+ for (const a of readBoardAssets(def))
265
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOnly(a) });
266
+ for (const m of readBoardEquipment(def)) {
267
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
175
268
  if (m.mtbfMs !== undefined) {
176
- mover.mtbfMs = m.mtbfMs;
177
- mover.mttrMs = m.mttrMs;
178
- mover.nextFailureMs = this.sampleExp(m.mtbfMs);
269
+ eq.mtbfMs = m.mtbfMs;
270
+ eq.mttrMs = m.mttrMs;
271
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
179
272
  }
180
- this.movers.set(m.id, mover);
273
+ this.equipment.set(m.id, eq);
181
274
  }
182
275
  }
183
276
  /**
184
- * what-if 구성 변주 — fork(또는 실행 중) 엔진에 무버 추가. loadBoard 무버 삽입과 동일 규약.
277
+ * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadBoard 설비 삽입과 동일 규약.
185
278
  * 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
186
279
  */
187
- addMover(m) {
188
- if (this.movers.has(m.id))
280
+ addEquipment(m) {
281
+ if (this.equipment.has(m.id))
189
282
  return;
190
- const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
283
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
191
284
  if (m.mtbfMs !== undefined) {
192
- mover.mtbfMs = m.mtbfMs;
193
- mover.mttrMs = m.mttrMs;
194
- mover.nextFailureMs = this.sampleExp(m.mtbfMs);
285
+ eq.mtbfMs = m.mtbfMs;
286
+ eq.mttrMs = m.mttrMs;
287
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
195
288
  }
196
- this.movers.set(m.id, mover);
289
+ this.equipment.set(m.id, eq);
197
290
  }
198
291
  /**
199
- * 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(재고·무버·노드)과
292
+ * 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(재고·설비·자리)과
200
293
  * 저널 오더(원값+라인)로 이 커널의 맵을 채운다. tick 으로 만든 게 아니라 "현재 관측된 현실"을 심어
201
294
  * 이후 fork/tick 으로 예측한다. 라이브 런타임은 여전히 projector 미러 — 이 커널은 예측용 임시본.
202
295
  * 오더는 남은 데맨드(라인별 requested-fulfilled)를 'created' 로 복원(현재 재고에서 재계획).
203
296
  * 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
204
297
  */
298
+ /**
299
+ * **이어서 센다** — 재기동 뒤 저널을 이어 쓸 때.
300
+ *
301
+ * 커널은 리비전을 0부터 센다. 새로 시작하는 우주라면 맞지만, **이어지는 현실**(sim-world)에서는
302
+ * 그 번호가 이미 저널에 있는 행과 겹친다 — 같은 트윈에 revision 7이 두 개 생기고, 재생 순서가
303
+ * 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다. 조용히 깨지는 종류다(오류가 안 난다).
304
+ *
305
+ * 그래서 이어 쓰기 전에 마지막 번호를 알려 준다. **뒤로는 못 간다** — 뒤로 가는 것은 곧 겹치는
306
+ * 것이고, 그것을 허용하면 이 함수가 막으려던 일이 이 함수를 통해 일어난다.
307
+ */
308
+ resumeRevision(from) {
309
+ if (!Number.isFinite(from) || from < 0)
310
+ throw new Error(`이어 셀 리비전이 올바르지 않다: ${from}`);
311
+ if (from < this.revision)
312
+ throw new Error(`리비전을 뒤로 되돌릴 수 없다: ${this.revision} → ${from} (겹치는 번호가 생긴다)`);
313
+ this.revision = from;
314
+ }
205
315
  hydrateObserved(snap, orders = []) {
206
- /* **관측된 것을 버리지 않는다.** 예전에는 노드·무버 상태를 'idle' 로, OEE 누적을 0 으로 덮고
316
+ /* **관측된 것을 버리지 않는다.** 예전에는 자리·설비 상태를 'idle' 로, OEE 누적을 0 으로 덮고
207
317
  * 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다** —
208
318
  * 고장 난 설비를 정상으로, 진행 중인 일을 없는 것으로 놓고 미래를 굴리면 답이 낙관 쪽으로 치우친다. */
209
- for (const n of snap.nodes) {
210
- this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? 'idle', parentId: n.parentId });
319
+ for (const n of snap.locations) {
320
+ this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? 'idle', parentId: n.parentId });
211
321
  }
212
322
  this.items.clear();
213
323
  for (const it of snap.items) {
214
- this.items.set(it.epc, {
324
+ /* **부분마다 한 줄로 심는다** — `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 접혀
325
+ 씨앗에서 재고가 줄어든다(§MaterialSubLot 에서 겪은 것과 같은 오류의 세 번째 자리). */
326
+ this.items.set(itemKeyOf(it), {
215
327
  /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
216
328
  epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable,
217
- gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom,
329
+ ...(it.subLotId ? { subLotId: it.subLotId } : {}),
330
+ ...(it.definitionId ? { definitionId: it.definitionId } : {}),
331
+ gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom, ...(it.quantities?.length ? { quantities: it.quantities } : {}),
218
332
  parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
219
333
  });
220
334
  }
221
- for (const m of snap.movers) {
335
+ for (const m of snap.equipment) {
222
336
  /*
223
337
  * 관측된 계측을 이어받는다 — **없어서 0 이었던 게 아니라 잘못 읽어서 0 이었다.**
224
338
  * `OeeMetrics` 의 이름은 `runMs`·`setupMs`·`downMs`·`goodCount`·`scrapCount` 인데 예전 코드가
@@ -231,32 +345,121 @@ export class FlowEngine {
231
345
  * 값을 그대로 이어받고, 없으면 0 에서 시작한다(꾸미지 않는다).
232
346
  */
233
347
  const oee = m.oee;
234
- this.movers.set(m.id, {
235
- id: m.id, kind: m.kind, location: m.location ?? '', status: m.status ?? 'idle', taskId: null,
348
+ /* **마스터가 말한 것을 관측이 지우지 않는다.** 예전에는 이 자리에서 통째로 새 객체를 만들어
349
+ * 교대 캘린더·속성·유효 기간·계획정지를 **전부 버렸다.** 결과 라이브(관측) 커널은
350
+ * 근무 시간을 모르고(항상 가용), 폐기를 모르고, 속도를 몰라 이동시간을 상수로 떨어뜨렸다.
351
+ * 관측이 말하는 것(위치·상태·계측)만 덮고 **선언은 지킨다**. */
352
+ const prev = this.equipment.get(m.id);
353
+ this.equipment.set(m.id, {
354
+ ...(prev ?? {}),
355
+ id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: null,
236
356
  runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
237
- goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0
357
+ goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0,
358
+ /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 굴린다
359
+ (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
360
+ ...(m.held ? { held: true } : {}),
361
+ /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
362
+ ...(m.properties ? { properties: m.properties } : {}),
363
+ ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}),
364
+ ...effectiveOnly(m)
238
365
  });
239
366
  }
240
367
  /* 사람을 이어 붙인다 — **없으면 인원 요구가 영원히 채워지지 않는다.** 관측 상태에 사람이 있는데
241
368
  * 씨앗이 버리면, 인원을 요구하는 공정의 작업이 하나도 시작되지 못하고 예측이 멈춘다(0 명 < 2 명).
242
369
  * 배정 상태(busy/taskId)는 아래 작업 복원이 다시 세우므로 여기서는 등급·교대만 살린다. */
243
370
  for (const p of snap.persons ?? []) {
244
- this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle', taskId: null });
371
+ const prev = this.persons.get(p.id);
372
+ this.persons.set(p.id, {
373
+ ...(prev ?? {}),
374
+ id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds, status: 'idle', taskId: null,
375
+ ...(p.location ? { location: p.location } : {}),
376
+ ...(p.properties ? { properties: p.properties } : {}),
377
+ ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
378
+ ...effectiveOnly(p)
379
+ });
245
380
  }
246
381
  /* 자산도 같다 — 잃으면 자산을 요구하는 작업이 영원히 못 나간다(빈 팔레트 0개 < 1개). */
247
382
  for (const a of snap.assets ?? []) {
248
- this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.location, status: 'idle', taskId: null, carrying: a.carrying });
383
+ const prev = this.assets.get(a.id);
384
+ this.assets.set(a.id, {
385
+ ...(prev ?? {}),
386
+ id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location, status: 'idle', taskId: null, carrying: a.carrying,
387
+ ...(a.properties ? { properties: a.properties } : {}),
388
+ ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}),
389
+ ...effectiveOnly(a)
390
+ });
249
391
  }
250
392
  /* 진행 중이던 작업을 이어 붙인다 — 없으면 예측이 "일이 하나도 없는 현장" 에서 출발한다.
251
393
  * 남은 시간을 모르면 **진척을 꾸미지 않고** 미착수(created)로 되돌린다: 그 일이 남아 있다는 사실은
252
394
  * 지키면서, 얼마나 진행됐는지는 모른다고 말하는 쪽이 정직하다(커널이 다시 배정해 굴린다). */
395
+ /*
396
+ * **주체가 없는 작업은 되살리지 않는다.**
397
+ *
398
+ * 씨앗은 진행 중이던 작업을 이어 붙이는데, 그 작업이 가리키는 물품이 상태에 없을 수 있다
399
+ * (이미 소비·출하됐거나, 관측이 그 물품을 담지 못했다). 그런 작업을 살려 두면 완료 시점에
400
+ * "없는 물품을 옮기려" 하다 **예측 전체가 죽는다** — 실제로 그렇게 죽었다(정확도 추세 서버 오류).
401
+ *
402
+ * 그렇다고 조용히 버리지도 않는다: **몇 건을 왜 뺐는지 말한다.** 조용한 누락은 "일이 없었다" 로
403
+ * 읽히고, 그것이 예측을 낙관 쪽으로 기울인다.
404
+ */
405
+ /* 오더 원값은 이제 **스냅샷에 있다** — 따로 넘겨받은 것이 없으면 스냅샷에서 읽는다.
406
+ * (예전에는 상태가 progress 로 압축돼 호출부가 저널을 뒤져 원값을 넘겨야 했다.) */
407
+ const observedOrders = orders.length
408
+ ? orders
409
+ : (snap.orders ?? [])
410
+ .filter(o => o.requested !== undefined)
411
+ .map(o => ({
412
+ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines,
413
+ /* 약속한 시각과 우선순위를 함께 옮긴다 — 없으면 관측 커널이 **"늦었나" 를 판단할 재료가
414
+ 없다.** 라이브 트윈이 납기 초과를 한 번도 보고하지 못한 원인 중 하나였다. */
415
+ ...(o.endTime ? { endTime: o.endTime } : {}),
416
+ ...(o.startTime ? { startTime: o.startTime } : {}),
417
+ ...(o.priority !== undefined ? { priority: o.priority } : {})
418
+ }));
419
+ for (const o of observedOrders) {
420
+ const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
421
+ /* **라인이 없는 오더를 조용히 빼지 않는다.** 예전에는 남은 양을 라인에서만 셌으므로, 품목 라인
422
+ * 없이 총량만 오는 오더(실 시스템에 흔하다)가 전부 `remaining=0` 이 되어 **사라졌다** — 라이브
423
+ * 커널에 오더가 아예 없으니 지연도 이행 예측도 나올 수 없었다. 라인이 없으면 총량으로 센다. */
424
+ const remaining = lines.length
425
+ ? lines.reduce((s, l) => s + l.requested, 0)
426
+ : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
427
+ if (remaining <= 0)
428
+ continue; // 이미 이행 완료 → 예측 대상 아님
429
+ this.orders.set(o.orderId, {
430
+ id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0,
431
+ bizTransaction: '', allocated: [], picked: [], shipmentEpc: null, lines,
432
+ /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
433
+ 씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
434
+ ...(o.held ? { held: true } : {}),
435
+ ...(o.endTime ? { endTime: o.endTime } : {}),
436
+ ...(o.startTime ? { startTime: o.startTime } : {}),
437
+ ...(o.priority !== undefined ? { priority: o.priority } : {})
438
+ });
439
+ }
440
+ /* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
441
+ const seededOrderIds = new Set(this.orders.keys());
442
+ let orphaned = 0;
253
443
  for (const t of snap.tasks ?? []) {
254
444
  if (t.status === 'completed')
255
445
  continue;
446
+ const ref = t.itemRefs?.[0];
447
+ if (ref && !this.itemByRef(ref)) {
448
+ orphaned++;
449
+ continue;
450
+ }
451
+ /* 오더도 같다 — 이미 이행된 오더는 심지 않으므로(위 `remaining <= 0`), 그 오더에 딸린 작업만
452
+ 남으면 완료 시점에 없는 오더를 딛는다. 심는 단계에서 함께 뺀다. */
453
+ if (t.orderId && !seededOrderIds.has(t.orderId)) {
454
+ orphaned++;
455
+ continue;
456
+ }
256
457
  const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
257
458
  this.tasks.set(t.id, {
258
459
  id: t.id, kind: t.kind,
259
460
  status: known && t.status === 'in-progress' ? 'in-progress' : 'created',
461
+ /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
462
+ ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
260
463
  itemEpc: t.itemRefs?.[0] ?? '',
261
464
  fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
262
465
  resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
@@ -268,7 +471,7 @@ export class FlowEngine {
268
471
  });
269
472
  /* 진행 중으로 살린 작업은 그 자원을 점유한 상태여야 한다(자원이 동시에 다른 일을 받지 않게). */
270
473
  if (known && t.status === 'in-progress' && t.resourceRef) {
271
- const mv = this.movers.get(t.resourceRef);
474
+ const mv = this.equipment.get(t.resourceRef);
272
475
  if (mv) {
273
476
  mv.status = 'busy';
274
477
  mv.taskId = t.id;
@@ -288,24 +491,14 @@ export class FlowEngine {
288
491
  }
289
492
  }
290
493
  }
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) {
299
- const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
300
- const remaining = lines.reduce((s, l) => s + l.requested, 0);
301
- if (remaining <= 0)
302
- continue; // 이미 이행 완료 → 예측 대상 아님
303
- this.orders.set(o.orderId, { id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0, bizTransaction: '', allocated: [], picked: [], shipmentEpc: null, lines });
494
+ if (orphaned) {
495
+ console.warn(`[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state ` +
496
+ 'they cannot be continued (the item was consumed/shipped, or the observation did not carry it).');
304
497
  }
305
498
  }
306
- /** what-if 구성 변주 — 노드 용량 변경(fork 대상). 존재하면 true. */
307
- setNodeCapacity(nodeId, capacity) {
308
- const n = this.nodes.get(nodeId);
499
+ /** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
500
+ setLocationCapacity(locationId, capacity) {
501
+ const n = this.locations.get(locationId);
309
502
  if (!n)
310
503
  return false;
311
504
  n.capacity = Math.max(0, capacity);
@@ -329,7 +522,16 @@ export class FlowEngine {
329
522
  * 도메인 커맨드(order.release 등)는 handleCommand 로 위임. 커맨드는 sim 상태를 변이하고
330
523
  * State 델타를 유발한다(command → 행위 → 관측 폐루프).
331
524
  */
332
- _acked = new Set(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
525
+ _acked = new Set();
526
+ /**
527
+ * 완료 시점에 **주체가 사라져 접은 작업 수** — 조용한 누락이 되지 않게 센다.
528
+ *
529
+ * 물품이 포장·출하·소비로 없어지는 것은 정상이지만, 그 물품을 향한 작업이 남아 있었다는 것은
530
+ * 상류에 어긋남이 있다는 신호다. 0 이 아니면 그 사실을 소비처가 볼 수 있어야 한다.
531
+ */
532
+ abandonedTasks = 0;
533
+ /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
534
+ _attentionSince = new Map(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
333
535
  dispatch(cmd) {
334
536
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
335
537
  // 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
@@ -351,30 +553,30 @@ export class FlowEngine {
351
553
  this._acked.add(id);
352
554
  return ok();
353
555
  }
354
- // Operable 코어 — 자원(설비·무버) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
556
+ // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
355
557
  case CMD.resourceHold:
356
558
  case CMD.resourceResume: {
357
- const m = this.movers.get(cmd.args?.resourceId ?? '');
559
+ const m = this.equipment.get(cmd.args?.resourceId ?? '');
358
560
  if (!m)
359
561
  return fail('resource-not-found');
360
562
  m.held = cmd.type === CMD.resourceHold; // 계획 정지 → 배정 스킵
361
- this.emitMover(m);
563
+ this.emitEquipment(m);
362
564
  return ok();
363
565
  }
364
566
  case CMD.resourceDown: {
365
567
  const a = cmd.args;
366
- const m = this.movers.get(a?.resourceId ?? '');
568
+ const m = this.equipment.get(a?.resourceId ?? '');
367
569
  if (!m)
368
570
  return fail('resource-not-found');
369
571
  if (m.status !== 'down') {
370
572
  m.status = 'down'; // 비계획 고장 주입 — 기존 고장 machinery(processFailures)가 downMs 누적·수리 처리
371
573
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 60_000);
372
- this.emitMover(m);
574
+ this.emitEquipment(m);
373
575
  }
374
576
  return ok();
375
577
  }
376
578
  case CMD.resourceRepair: {
377
- const m = this.movers.get(cmd.args?.resourceId ?? '');
579
+ const m = this.equipment.get(cmd.args?.resourceId ?? '');
378
580
  if (!m)
379
581
  return fail('resource-not-found');
380
582
  if (m.status === 'down') {
@@ -382,12 +584,12 @@ export class FlowEngine {
382
584
  m.repairUntilMs = undefined;
383
585
  if (m.mtbfMs !== undefined)
384
586
  m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
385
- this.emitMover(m);
587
+ this.emitEquipment(m);
386
588
  }
387
589
  return ok();
388
590
  }
389
591
  case CMD.resourceResetMetrics: {
390
- const m = this.movers.get(cmd.args?.resourceId ?? '');
592
+ const m = this.equipment.get(cmd.args?.resourceId ?? '');
391
593
  if (!m)
392
594
  return fail('resource-not-found');
393
595
  m.runMs = 0;
@@ -397,27 +599,27 @@ export class FlowEngine {
397
599
  m.scrapCount = 0;
398
600
  m.holdMs = 0;
399
601
  m.metricsSinceMs = this.clockMs; // 계측 창을 지금부터 재시작
400
- this.emitMover(m);
602
+ this.emitEquipment(m);
401
603
  return ok();
402
604
  }
403
605
  case CMD.resourceAdd: {
404
- // 라이브 자원 추가(실제 act) — addMover(what-if 와 동일 경로)로 런타임에 무버 삽입 + equipment 델타 방출.
405
- // 새 무버는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
606
+ // 라이브 자원 추가(실제 act) — addEquipment(what-if 와 동일 경로)로 런타임에 설비 삽입 + equipment 델타 방출.
607
+ // 새 설비는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
406
608
  const a = cmd.args;
407
609
  if (!a?.kind)
408
610
  return fail('kind-required');
409
- if (!a?.homeNode || !this.nodes.has(a.homeNode))
410
- return fail('home-node-not-found', { homeNode: a?.homeNode ?? '' });
611
+ if (!a?.homeLocation || !this.locations.has(a.homeLocation))
612
+ return fail('home-location-not-found', { homeLocation: a?.homeLocation ?? '' });
411
613
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
412
- let seq = this.movers.size;
614
+ let seq = this.equipment.size;
413
615
  for (let i = 0; i < count; i++) {
414
616
  let id = `${a.kind}-${++seq}`;
415
- while (this.movers.has(id))
617
+ while (this.equipment.has(id))
416
618
  id = `${a.kind}-${++seq}`;
417
- this.addMover({ id, kind: a.kind, homeNode: a.homeNode });
418
- const m = this.movers.get(id);
619
+ this.addEquipment({ id, kind: a.kind, homeLocation: a.homeLocation });
620
+ const m = this.equipment.get(id);
419
621
  if (m)
420
- this.emitMover(m); // equipment.status 델타 → 상태/저널에 새 무버 반영
622
+ this.emitEquipment(m); // equipment.status 델타 → 상태/저널에 새 설비 반영
421
623
  }
422
624
  return ok();
423
625
  }
@@ -460,32 +662,50 @@ export class FlowEngine {
460
662
  return {
461
663
  revision: this.revision,
462
664
  simClockMs: this.clockMs,
665
+ nowTime: this.now(), // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
463
666
  /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
464
667
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
465
- nodes: [...this.nodes.values()].map(n => {
668
+ locations: [...this.locations.values()].map(n => {
466
669
  const { status, ...rest } = n;
467
670
  /* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
468
- const derived = nodeStatusOf(n);
671
+ const derived = locationStatusOf(n);
469
672
  return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
470
673
  }),
471
674
  items: [...this.items.values()].map(i => this.itemState(i)),
472
- movers: [...this.movers.values()].map(m => {
473
- const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, origin: 'master', ...(this.offShift(m) ? { offShift: true } : {}) };
675
+ equipment: [...this.equipment.values()].map(m => {
676
+ const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}) };
474
677
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
475
678
  if (t && t.status === 'in-progress' && t.intent !== 'process')
476
679
  s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
477
680
  return s;
478
681
  }),
479
682
  assets: [...this.assets.values()].map(a => {
480
- const st = { id: a.id, assetClass: a.assetClass, location: a.location, status: a.status, taskId: a.taskId ?? undefined };
683
+ const st = { id: a.id, assetClassIds: a.assetClassIds, location: a.location, status: a.status, taskId: a.taskId ?? undefined };
481
684
  if (a.carrying)
482
685
  st.carrying = a.carrying;
686
+ if (a.properties)
687
+ st.properties = a.properties;
688
+ if (a.testSpecificationIds)
689
+ st.testSpecificationIds = a.testSpecificationIds;
690
+ Object.assign(st, this.effectivePart(a));
483
691
  return st;
484
692
  }),
485
693
  persons: [...this.persons.values()].map(p => {
486
- const st = { id: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined };
487
- if (this.personOffShift(p))
694
+ const st = { id: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined };
695
+ if (p.location)
696
+ st.location = p.location;
697
+ if (p.properties)
698
+ st.properties = p.properties;
699
+ if (p.testSpecificationIds)
700
+ st.testSpecificationIds = p.testSpecificationIds;
701
+ if (this.personOffShift(p)) {
488
702
  st.offShift = true;
703
+ Object.assign(st, this.offReason(p));
704
+ }
705
+ const sh = this.shiftOf(p);
706
+ if (sh)
707
+ st.shift = sh;
708
+ Object.assign(st, this.effectivePart(p));
489
709
  return st;
490
710
  }),
491
711
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
@@ -499,31 +719,60 @@ export class FlowEngine {
499
719
  ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
500
720
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
501
721
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
502
- ...(t.resources?.length ? { resources: t.resources.slice() } : {})
722
+ ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
723
+ ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
724
+ ...(t.priority !== undefined ? { priority: t.priority } : {}),
725
+ ...(t.startTime ? { startTime: t.startTime } : {}),
726
+ ...(t.endTime ? { endTime: t.endTime } : {})
503
727
  })),
504
728
  orders: [...this.orders.values()].map(o => ({
505
729
  id: o.id, kind: o.kind, status: o.status,
506
730
  progress: o.requested ? o.fulfilled / o.requested : 0,
507
731
  requested: o.requested, fulfilled: o.fulfilled,
508
732
  ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
733
+ ...(o.priority !== undefined ? { priority: o.priority } : {}),
734
+ ...(o.startTime ? { startTime: o.startTime } : {}),
735
+ ...(o.endTime ? { endTime: o.endTime } : {}),
509
736
  held: o.held
510
737
  })),
511
738
  attentions: this.computeAttentions()
512
739
  };
513
740
  }
514
741
  /*
515
- * 주목 신호 판단 — 상태(노드·무버·오더)에서 도메인 조건을 평가해 Attention 방출.
742
+ * 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
516
743
  * severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
517
744
  * 도메인별 추가 판단은 서브클래스가 override 로 확장(super.computeAttentions() 합성).
518
745
  */
519
746
  computeAttentions() {
520
747
  // 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
521
- const out = deriveAttentions({ movers: [...this.movers.values()], nodes: [...this.nodes.values()], orders: [...this.orders.values()] }, this._acked);
522
- // 확인(ack) 프루닝 — 사라진 조건은 ack 해제(재발 시 다시 active). 엔진 상태 변이라 여기 유지(marking 은 deriveAttentions).
748
+ const now = this.now();
749
+ const out = deriveAttentions({
750
+ equipment: [...this.equipment.values()],
751
+ locations: [...this.locations.values()],
752
+ orders: [...this.orders.values()],
753
+ tasks: [...this.tasks.values()]
754
+ }, this._acked, now // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
755
+ );
523
756
  const present = new Set(out.map(a => a.id));
757
+ /*
758
+ * **조건이 처음 성립한 시각**을 기억해 붙인다(표준 `WorkAlert.TimeStamp`).
759
+ *
760
+ * 주목 신호는 매 스냅샷 다시 계산되는 파생값이라, 여기서 "지금" 을 찍으면 모든 신호가 언제나
761
+ * 방금 생긴 것으로 보인다(스냅샷 시각과 같은 값 = 정보 0). 처음 본 시각만이 "얼마나 오래됐나" 에
762
+ * 답한다 — 방금 찬 자리와 세 시간째 막힌 자리는 할 일이 다르다.
763
+ */
764
+ for (const a of out) {
765
+ const first = this._attentionSince.get(a.id) ?? now;
766
+ this._attentionSince.set(a.id, first);
767
+ a.timeStamp = first;
768
+ }
769
+ // 확인(ack)·시작 시각 프루닝 — 사라진 조건은 함께 지운다(재발하면 그때가 새 시작이다).
524
770
  for (const id of [...this._acked])
525
771
  if (!present.has(id))
526
772
  this._acked.delete(id);
773
+ for (const id of [...this._attentionSince.keys()])
774
+ if (!present.has(id))
775
+ this._attentionSince.delete(id);
527
776
  return out;
528
777
  }
529
778
  /**
@@ -557,7 +806,43 @@ export class FlowEngine {
557
806
  return clone;
558
807
  }
559
808
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
560
- now() { return new Date(BASE_EPOCH + this.clockMs).toISOString(); }
809
+ /**
810
+ * 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
811
+ *
812
+ * ── 관측 모드에서 시계가 멈춰 있었다 ────────────────────────────────────
813
+ * `apply()` 는 `clockMs` 를 밀지 않는다(관측은 시간을 굴리지 않는다). 그래서 라이브 트윈의 "지금" 이
814
+ * **BASE_EPOCH(2026-01-01)에 얼어 있었다.** 실 이벤트는 실제 시각을 달고 오므로 결과가 이렇게 된다:
815
+ * · **납기 초과를 영원히 보고하지 않는다** — 실 납기가 항상 미래로 보인다
816
+ * · 폐기·도입예정 판정이 틀린다(§EffectivePeriod)
817
+ * · 교대 판정이 틀린다(§offCalendarAt)
818
+ * 셋 다 "시각으로만 바뀌는 사실" 이라 같은 결함이었다. 그래서 기준을 **한 곳**으로 모은다.
819
+ *
820
+ * 관측 중이면 **마지막으로 들은 발생 시각**을 쓴다(미러의 `lastObservedMs` 와 같은 기준 — 규칙 한 벌).
821
+ * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
822
+ */
823
+ nowMs() {
824
+ const observed = this.observeMode ? this.observer?.lastObservedMs : undefined;
825
+ return observed ?? BASE_EPOCH + this.clockMs;
826
+ }
827
+ now() { return new Date(this.nowMs()).toISOString(); }
828
+ /**
829
+ * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
830
+ *
831
+ * **base 에 한 곳만 둔다.** 오더를 만드는 곳이 도메인마다 있어(WMS 판매오더·MES 작업지시·YMS
832
+ * 어포인트먼트) 각자 계산하면 시간축이나 규약이 갈린다 — 실제로 한 번 갈렸다(납기를 `clockMs` 로
833
+ * 재고 `now()` 와 비교해 갓 만든 오더가 56년 늦은 것으로 판정됐다).
834
+ *
835
+ * 선언이 없으면 **빈 객체**를 준다 — 없는 약속을 만들지 않는다.
836
+ */
837
+ promiseOf(spec) {
838
+ const at = this.now();
839
+ return {
840
+ ...(spec.priority !== undefined ? { priority: spec.priority } : {}),
841
+ ...(spec.promisedLeadMinutes !== undefined
842
+ ? { startTime: at, endTime: new Date(Date.parse(at) + spec.promisedLeadMinutes * 60_000).toISOString() }
843
+ : {})
844
+ };
845
+ }
561
846
  randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
562
847
  /**
563
848
  * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
@@ -577,7 +862,7 @@ export class FlowEngine {
577
862
  */
578
863
  apply(envelope) {
579
864
  if (!this.observer) {
580
- this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
865
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
581
866
  this.observeMode = true;
582
867
  }
583
868
  this.observer.apply(envelope);
@@ -616,6 +901,43 @@ export class FlowEngine {
616
901
  if (o?.key)
617
902
  this.operationSpecs.set(o.key, o);
618
903
  }
904
+ /**
905
+ * 라우트(공정 순서) — 수율을 거슬러 올릴 때 필요하다. 기본은 모른다(선언 순서를 쓴다).
906
+ * 생산 정의를 가진 커널이 override 해서 자기 라우트를 답한다.
907
+ */
908
+ routeKeys() {
909
+ return undefined;
910
+ }
911
+ /**
912
+ * **이 공장이 하루 몇 대를 낼 수 있는가** — 굴려 보지 않고 답한다.
913
+ *
914
+ * 커널이 직접 답하는 이유: 필요한 사실이 전부 여기 있다(공정 명세·설비와 신뢰도·인원·물리자산·
915
+ * 자리·근무 달력·시각 기준). 밖에서 모으면 그 값을 옮겨 적게 되고, 한쪽만 바뀌는 순간 "충분하다"
916
+ * 가 조용히 거짓이 된다. 계산 자체는 순수 함수(`analyzeCapacity`)에 맡긴다.
917
+ *
918
+ * 달력은 **자원이 선언한 것**을 쓴다. 자원마다 다른 달력을 쓰는 현장이면 대표를 고를 수 없으므로
919
+ * 그 사실을 결과에 실어 보낸다(`mixedCalendars`) — 조용히 하나를 골라 계산하면 천장이 틀린 채로
920
+ * 그럴듯해 보인다.
921
+ */
922
+ capacity(opts) {
923
+ const calendars = new Map();
924
+ for (const m of this.equipment.values())
925
+ if (m.workCalendar?.length)
926
+ calendars.set(JSON.stringify(m.workCalendar), m.workCalendar);
927
+ const analysis = analyzeCapacity({
928
+ operations: [...this.operationSpecs.values()],
929
+ ...(this.routeKeys() ? { route: this.routeKeys() } : {}),
930
+ equipment: [...this.equipment.values()].map(m => ({ kind: m.kind, mtbfMs: m.mtbfMs, mttrMs: m.mttrMs })),
931
+ persons: [...this.persons.values()],
932
+ assets: [...this.assets.values()],
933
+ locations: [...this.locations.values()],
934
+ ...(calendars.size === 1 ? { calendar: [...calendars.values()][0] } : {}),
935
+ ...(this.boardDef?.utcOffsetMinutes !== undefined ? { utcOffsetMinutes: this.boardDef.utcOffsetMinutes } : {}),
936
+ sampleWeekStartMs: opts.sampleWeekStartMs,
937
+ unitsPerDay: opts.unitsPerDay
938
+ });
939
+ return { ...analysis, mixedCalendars: calendars.size > 1 };
940
+ }
619
941
  /**
620
942
  * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
621
943
  *
@@ -743,8 +1065,19 @@ export class FlowEngine {
743
1065
  defaultDurations: operations.filter(o => o.duration === 'default').length
744
1066
  };
745
1067
  }
746
- /** skuMix 에서 weight 로 gtin 선택(rng) — 도착/오더 자극의 품목 결정. */
1068
+ /**
1069
+ * `skuMix` 에서 weight 로 gtin 선택(rng) — 도착·오더 자극의 품목 결정.
1070
+ *
1071
+ * **빈 목록이면 고르지 않는다**(`undefined`). 예전에는 `mix[mix.length - 1].gtin` 으로 떨어져
1072
+ * `mix[-1]` 이 undefined 가 되고 거기서 던졌다 — 그 예외가 서버까지 올라가 **정확도 추세 전체를
1073
+ * 죽였다**(품목 구성을 선언하지 않은 자극 하나가 예측 전체를 껐다).
1074
+ *
1075
+ * 없는 품목을 지어내지 않는다: 무엇을 만들지 모르면 **만들지 않는 것**이 맞고, 부르는 쪽이
1076
+ * 그 사실을 알고 건너뛴다.
1077
+ */
747
1078
  pickGtin(mix) {
1079
+ if (!mix?.length)
1080
+ return undefined;
748
1081
  const total = mix.reduce((s, m) => s + m.weight, 0);
749
1082
  let r = this.rng() * total;
750
1083
  for (const m of mix) {
@@ -754,8 +1087,8 @@ export class FlowEngine {
754
1087
  }
755
1088
  return mix[mix.length - 1].gtin;
756
1089
  }
757
- nodeByType(type) {
758
- for (const n of this.nodes.values())
1090
+ locationByType(type) {
1091
+ for (const n of this.locations.values())
759
1092
  if (n.type === type)
760
1093
  return n;
761
1094
  return undefined;
@@ -764,7 +1097,7 @@ export class FlowEngine {
764
1097
  * process 변화 대수(EPCIS TransformationEvent · ISA-95 Material Consumed/Produced · 씬 Processable.transform).
765
1098
  * inputs 소비 → outputs 생산. 입출력 arity 가 곧 대수:
766
1099
  * merge N→1(조립) · split 1→N(분해) · transform 1→1(타입변경) · loss N→0(소실) · gain 0→N(부산물·생성).
767
- * 아이템 상태(소비/생산)와 노드 점유를 갱신하고 계보(TransformationEvent)를 방출한다.
1100
+ * 아이템 상태(소비/생산)와 자리 점유를 갱신하고 계보(TransformationEvent)를 방출한다.
768
1101
  * 도메인은 이 원시만 호출 — onTaskComplete 의 소비/생산/EPCIS 손코딩을 대체.
769
1102
  */
770
1103
  transform(inputs, outputs, opts) {
@@ -772,14 +1105,14 @@ export class FlowEngine {
772
1105
  const it = this.items.get(epc);
773
1106
  if (!it)
774
1107
  continue;
775
- const n = this.nodes.get(it.location);
1108
+ const n = this.locations.get(it.location);
776
1109
  if (n)
777
1110
  n.occupancy--;
778
1111
  this.items.delete(epc);
779
1112
  }
780
1113
  for (const o of outputs) {
781
1114
  this.items.set(o.epc, { epc: o.epc, gtin: o.gtin, qty: o.qty, location: o.location, disposition: o.disposition });
782
- const n = this.nodes.get(o.location);
1115
+ const n = this.locations.get(o.location);
783
1116
  if (n)
784
1117
  n.occupancy++;
785
1118
  }
@@ -849,7 +1182,7 @@ export class FlowEngine {
849
1182
  for (const c of children) {
850
1183
  const it = this.items.get(c);
851
1184
  if (it) {
852
- const n = this.nodes.get(it.location);
1185
+ const n = this.locations.get(it.location);
853
1186
  if (n)
854
1187
  n.occupancy--;
855
1188
  this.items.delete(c);
@@ -867,7 +1200,7 @@ export class FlowEngine {
867
1200
  }
868
1201
  /**
869
1202
  * containment 분해(EPCIS AggregationEvent DELETE) — 부모(용기)에서 자식들을 풀어냄.
870
- * materialize 지정 시 자식이 독립 아이템으로 등장(materialize: 노드 배치 + occupancy + ObjectEvent ADD).
1203
+ * materialize 지정 시 자식이 독립 아이템으로 등장(materialize: 자리 배치 + occupancy + ObjectEvent ADD).
871
1204
  * 미지정 시 이벤트만.
872
1205
  */
873
1206
  disaggregate(parent, children, opts) {
@@ -882,7 +1215,7 @@ export class FlowEngine {
882
1215
  const m = opts.materialize;
883
1216
  for (const c of children) {
884
1217
  this.items.set(c, { epc: c, location: m.location, disposition: m.disposition });
885
- const n = this.nodes.get(m.location);
1218
+ const n = this.locations.get(m.location);
886
1219
  if (n)
887
1220
  n.occupancy++;
888
1221
  this.emit(objectEvent({ eventTime: this.now(), action: 'ADD', bizStep: opts.bizStep, disposition: m.disposition, epcList: [c], readPoint: m.location, bizLocation: m.location }));
@@ -891,7 +1224,7 @@ export class FlowEngine {
891
1224
  }
892
1225
  /** 품질 보고(OEE Quality) — 도메인이 완료 시 자원별 양품/불량 1건 계상(예: 용접 수율). */
893
1226
  recordOutput(moverId, good) {
894
- const m = moverId ? this.movers.get(moverId) : undefined;
1227
+ const m = moverId ? this.equipment.get(moverId) : undefined;
895
1228
  if (!m)
896
1229
  return;
897
1230
  if (good)
@@ -901,20 +1234,20 @@ export class FlowEngine {
901
1234
  // 품질 델타 방출 — live OEE 누적기가 good/scrap 을 정확 추적(equipment.status 는 quality 미포함). WMS/YMS 는 미호출→무영향.
902
1235
  this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, scrapCount: m.scrapCount });
903
1236
  }
904
- /** 무버 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
1237
+ /** 설비 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
905
1238
  oeeOf(m) {
906
1239
  // 순수 공식 computeOee 로 위임 — sim(여기)과 live 가 같은 계산 층 공유. planned 는 hold 제외(계획정지 OEE 무영향).
907
1240
  return computeOee(m, this.clockMs);
908
1241
  }
909
- /** 정책에 넘길 특정 타입 노드의 관측 뷰 — 예약(그 노드로 향하는 in-flight task) 포함. */
910
- slotViews(nodeType) {
1242
+ /** 정책에 넘길 특정 타입 자리의 관측 뷰 — 예약(그 자리로 향하는 in-flight task) 포함. */
1243
+ slotViews(locationType) {
911
1244
  const reserved = new Map();
912
1245
  for (const t of this.tasks.values())
913
1246
  if (t.status !== 'completed')
914
1247
  reserved.set(t.toNode, (reserved.get(t.toNode) ?? 0) + 1);
915
1248
  const views = [];
916
- for (const n of this.nodes.values())
917
- if (n.type === nodeType)
1249
+ for (const n of this.locations.values())
1250
+ if (n.type === locationType)
918
1251
  views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
919
1252
  return views;
920
1253
  }
@@ -947,26 +1280,37 @@ export class FlowEngine {
947
1280
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
948
1281
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
949
1282
  ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
1283
+ /* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
1284
+ ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
950
1285
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
1286
+ ...(t.priority !== undefined ? { priority: t.priority } : {}),
1287
+ ...(t.startTime ? { startTime: t.startTime } : {}),
1288
+ ...(t.endTime ? { endTime: t.endTime } : {}),
951
1289
  ...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
952
1290
  });
953
1291
  }
954
1292
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
955
1293
  emitAsset(a) {
956
- this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClass: a.assetClass, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying });
1294
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying, ...effectiveOnly(a) });
957
1295
  }
958
1296
  emitPerson(p) {
959
- this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined, ...(this.personOffShift(p) ? { offShift: true } : {}) });
1297
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? undefined, ...(p.location ? { location: p.location } : {}), ...effectiveOnly(p) });
1298
+ /* `offShift` 를 더 이상 실어 보내지 않는다 — 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로
1299
+ 전이 순간의 판정을 보내면 유휴로 경계를 지난 사람이 옛 값에 머문다. 미러는 같은 함수
1300
+ (`offCalendarAt`)를 자기 시각으로 부른다(유효 기간과 같은 규율). */
960
1301
  }
961
1302
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
962
1303
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
963
- emitMover(m, motion) {
964
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
1304
+ emitEquipment(m, motion) {
1305
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), taskId: m.taskId ?? undefined, motion, ...(m.held ? { held: true } : {}), ...effectiveOnly(m) });
965
1306
  }
966
1307
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
967
1308
  emitOrder(o) {
968
1309
  this.emitOp(OP_EVENT.order, {
969
1310
  orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held,
1311
+ ...(o.priority !== undefined ? { priority: o.priority } : {}),
1312
+ ...(o.startTime ? { startTime: o.startTime } : {}),
1313
+ ...(o.endTime ? { endTime: o.endTime } : {}),
970
1314
  ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {})
971
1315
  });
972
1316
  }
@@ -1041,7 +1385,13 @@ export class FlowEngine {
1041
1385
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
1042
1386
  if (!want)
1043
1387
  continue;
1044
- const avail = [...this.assets.values()].filter(a => a.status === 'idle' && !picked.includes(a.id) && (req.assetClass === undefined || a.assetClass === req.assetClass));
1388
+ const avail = [...this.assets.values()].filter(
1389
+ /* 인원과 같은 규칙 — 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
1390
+ a => a.status === 'idle' &&
1391
+ !picked.includes(a.id) &&
1392
+ !this.outOfEffect(a) && // 폐기한 팔레트는 풀에서 빠진다
1393
+ (req.assetClass === undefined ||
1394
+ classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass)));
1045
1395
  if (avail.length < want)
1046
1396
  return null;
1047
1397
  for (let i = 0; i < want; i++)
@@ -1064,7 +1414,7 @@ export class FlowEngine {
1064
1414
  a.carrying = t.itemEpc;
1065
1415
  /* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
1066
1416
  * 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
1067
- const it = this.items.get(t.itemEpc);
1417
+ const it = this.itemByRef(t.itemEpc);
1068
1418
  if (it)
1069
1419
  it.carriedBy = a.id;
1070
1420
  }
@@ -1098,6 +1448,223 @@ export class FlowEngine {
1098
1448
  * 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
1099
1449
  * (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
1100
1450
  */
1451
+ /**
1452
+ * 필요 자재를 확보한다 — **ISA-95 `OperationsSegment.MaterialSpecification`**(`use: 'consumed'`).
1453
+ *
1454
+ * ── 4대 자원 중 자재만 게이트가 없었다 ────────────────────────────────────
1455
+ * 인원·자산·설비는 모자라면 기다리는데 **자재는 없어도 작업이 시작됐다.** 그래서 부품이 떨어져
1456
+ * 라인이 서는 상황이 예측에 아예 나타나지 않았다 — 트레일러 조립 공장에서 그것은 가장 흔한 정지다.
1457
+ *
1458
+ * 인원·자산과 **같은 규칙**이다: 등급(또는 품목)으로 요구하고, **부분 확보 없이 전량 아니면 대기.**
1459
+ * 자재는 **작업이 일어나는 자리에 있어야** 한다(다른 창고의 부품은 지금 쓸 수 없다).
1460
+ *
1461
+ * 소비 시점은 **작업 시작**이다 — 자재는 들어가는 순간 없어진다(완료 시점에 빼면 그 사이 다른
1462
+ * 작업이 같은 부품을 또 잡는다). 산출(`produced`)은 여기서 다루지 않는다(완료 시점의 일이다).
1463
+ */
1464
+ claimMaterials(t) {
1465
+ const need = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter(m => m.use === 'consumed');
1466
+ if (!need.length)
1467
+ return [];
1468
+ const at = t.toNode;
1469
+ const picked = [];
1470
+ const takenSoFar = new Map();
1471
+ for (const req of need) {
1472
+ let remaining = Math.max(0, req.quantity ?? 0);
1473
+ if (!remaining)
1474
+ continue;
1475
+ for (const it of this.items.values()) {
1476
+ if (remaining <= 0)
1477
+ break;
1478
+ if (it.location !== at)
1479
+ continue; // 그 자리에 있는 것만 — 다른 창고의 부품은 지금 쓸 수 없다
1480
+ if (!this.materialMatches(it, req))
1481
+ continue;
1482
+ const already = takenSoFar.get(it.epc) ?? 0;
1483
+ const avail = Math.max(0, (it.qty ?? 1) - already);
1484
+ if (avail <= 0)
1485
+ continue;
1486
+ const take = Math.min(avail, remaining);
1487
+ picked.push({ epc: it.epc, take });
1488
+ takenSoFar.set(it.epc, already + take);
1489
+ remaining -= take;
1490
+ }
1491
+ if (remaining > 0)
1492
+ return null; // 한 줄이라도 모자라면 시작하지 않는다(부분 투입 없음)
1493
+ }
1494
+ return picked;
1495
+ }
1496
+ /** 이 물품이 명세를 만족하나 — 품목 지목이 우선, 없으면 등급(상속을 타고 닫는다). */
1497
+ materialMatches(it, req) {
1498
+ const defId = it.definitionId ?? it.gtin;
1499
+ if (req.materialDefinition)
1500
+ return defId === req.materialDefinition;
1501
+ if (req.materialClass) {
1502
+ const def = defId ? this.materialDefs.get(defId) : undefined;
1503
+ return classClosure(def?.materialClassIds, this.classDefs.material, this.now()).has(req.materialClass);
1504
+ }
1505
+ return true; // 지목도 등급도 없으면 아무 자재나(명세가 그렇게 느슨하면 그대로 따른다)
1506
+ }
1507
+ /**
1508
+ * 산출 자재를 만든다 — ISA-95 `MaterialSpecification`(`use: 'produced'`).
1509
+ *
1510
+ * **소비의 짝이다**: 자재는 작업이 시작될 때 들어가고 끝날 때 나온다. 산출이 없으면 조립 공정이
1511
+ * 부품만 먹고 아무것도 내놓지 않아, 다음 공정이 영원히 재료를 기다린다(라인 전체가 한 칸에서 멈춘다).
1512
+ *
1513
+ * 산출물의 자리는 **작업이 끝난 자리**(`toNode`)다. 식별자는 품목 식별자에 작업 id 를 붙여 만든다 —
1514
+ * **EPC 처럼 보이게 꾸미지 않는다**(상류가 준 직렬번호와 구별돼야 한다). 상류가 진짜 식별자를
1515
+ * 말해 주면 그것이 들어올 자리는 관측 경로다.
1516
+ *
1517
+ * 같은 품목이 그 자리에 이미 있으면 **수량을 더한다** — 새 줄을 만들면 같은 자리의 같은 로트가
1518
+ * 둘로 갈려 재고가 부푼다(§MaterialSubLot 에서 겪은 것과 반대 방향의 같은 오류).
1519
+ */
1520
+ produceMaterials(t) {
1521
+ const made = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter(m => m.use === 'produced');
1522
+ if (!made.length)
1523
+ return;
1524
+ for (const spec of made) {
1525
+ const qty = Math.max(0, spec.quantity ?? 0);
1526
+ if (!qty)
1527
+ continue;
1528
+ /* 무엇을 만드는지 말하지 않으면 만들지 않는다 — 이름 없는 물품을 재고에 올리면
1529
+ 그 뒤 누구도 그것이 무엇인지 답할 수 없다(등급만으로는 품목이 정해지지 않는다). */
1530
+ const gtin = spec.materialDefinition;
1531
+ if (!gtin)
1532
+ continue;
1533
+ const at = t.toNode;
1534
+ /*
1535
+ * **클래스 + 수량으로 낸다 — 일련번호를 지어내지 않는다.**
1536
+ *
1537
+ * 처음에는 산출물마다 `<gtin>#made-<taskId>` 같은 EPC 를 만들었다. 적합성 하네스가 잡았다:
1538
+ * 미러는 그 문자열을 파싱해 품목을 알아내려는데 EPC 가 아니라서 **품번이 갈렸다.** 애초에
1539
+ * 우리는 만든 물건의 일련번호를 **모른다** — 상류가 말해 줄 때만 안다. 모르는 것을 지어내면
1540
+ * 그 뒤 추적이 전부 가짜 식별자 위에 선다.
1541
+ *
1542
+ * 그래서 비직렬 생산으로 낸다(표준이 허용하는 모양): 그 자리의 **새 총량**을 관측으로 알린다.
1543
+ * 미러도 같은 규칙으로 읽으므로(자리마다 한 줄 — §MaterialSubLot) 두 구동이 같은 답을 낸다.
1544
+ */
1545
+ const key = subLotIdOf(gtin, at);
1546
+ const existing = this.items.get(key);
1547
+ const total = (existing?.qty ?? 0) + qty;
1548
+ if (existing)
1549
+ existing.qty = total;
1550
+ else {
1551
+ this.items.set(key, { epc: gtin, subLotId: key, gtin, qty: total, location: at, disposition: DISP.in_progress, ...(spec.uom ? { uom: spec.uom } : {}) });
1552
+ const n = this.locations.get(at);
1553
+ if (n)
1554
+ n.occupancy++;
1555
+ }
1556
+ this.recordMaterialActual(t, gtin, 'produced', qty, spec.uom);
1557
+ this.emit(objectEvent({
1558
+ eventTime: this.now(), action: 'ADD', bizStep: CBV_BIZSTEP.commissioning, disposition: DISP.in_progress,
1559
+ epcList: [], quantityList: [{ epcClass: gtin, quantity: total, ...(spec.uom ? { uom: spec.uom } : {}) }],
1560
+ readPoint: at, bizLocation: at
1561
+ }));
1562
+ }
1563
+ }
1564
+ /**
1565
+ * 이 작업이 **딛고 선 것**이 아직 있나 — 없으면 무엇이 없는지 답한다.
1566
+ *
1567
+ * 작업은 혼자 서지 못한다: 옮길 **물품**과, (있다면) 그것을 시킨 **오더** 위에 선다. 진행 중에
1568
+ * 둘 중 하나가 사라질 수 있다 — 물품은 포장·출하·소비로, 오더는 이미 이행돼 씨앗이 심지 않아서.
1569
+ *
1570
+ * 그때 도메인 훅은 없는 것을 딛으려다 던진다(`order.gtin` · `item.location`). 그 예외 하나가
1571
+ * **예측 전체를 죽였다** — 사용자에게는 기능이 통째로 사라진 것으로 보였다. 그래서 완료 **전에**
1572
+ * 여기서 묻고, 없으면 그 작업만 접는다.
1573
+ */
1574
+ missingContextOf(t) {
1575
+ if (t.itemEpc && !this.itemByRef(t.itemEpc))
1576
+ return 'item';
1577
+ if (t.orderId && !this.orders.has(t.orderId))
1578
+ return 'order';
1579
+ if (!this.canComplete(t))
1580
+ return 'domain';
1581
+ return undefined;
1582
+ }
1583
+ /**
1584
+ * 이 도메인이 이 작업을 **끝맺을 수 있나** — 코어가 모르는 조건은 도메인이 답한다.
1585
+ *
1586
+ * 코어는 물품과 오더까지만 안다. 그런데 도메인은 더 필요할 수 있다 — MES 는 완료 시점에 **오더와
1587
+ * 제품 정의**를 딛고 서고, 그 중 하나만 없어도 던진다. 코어가 그 조건을 추측하면 도메인마다 다른
1588
+ * 가정을 코어에 박게 되므로(방언), **묻는다.**
1589
+ *
1590
+ * 기본은 `true` — 대부분의 작업은 코어가 확인한 것으로 충분하다.
1591
+ */
1592
+ canComplete(_t) {
1593
+ return true;
1594
+ }
1595
+ /**
1596
+ * 작업이 가리키는 물품을 찾는다 — **참조와 키가 다를 수 있다.**
1597
+ *
1598
+ * 물품 맵의 키는 `itemKeyOf`(직렬 물품은 `epc`, 로트의 부분은 `subLotId`)인데, 작업은 로트 식별자
1599
+ * (`itemEpc`)로 가리킨다. 비직렬 로트에서는 둘이 **다르다** — 그래서 그냥 `get` 하면 못 찾는다.
1600
+ * 실제로 그 회귀를 냈다: 예측(씨앗) 경로에서 `item.location = …` 이 `undefined` 위에서 던져
1601
+ * **정확도 추세 전체가 서버 오류로 죽었다.**
1602
+ *
1603
+ * **부분이 여럿이면 풀지 않는다** — 어느 부분을 가리키는지 알 수 없고, 아무거나 고르면 그 뒤
1604
+ * 이동·처분이 엉뚱한 자리에 적힌다. 그때는 참조가 부족한 것이고, 부르는 쪽이 그 사실을 말해야 한다.
1605
+ */
1606
+ itemByRef(ref) {
1607
+ const exact = this.items.get(ref);
1608
+ if (exact)
1609
+ return exact;
1610
+ let hit;
1611
+ for (const it of this.items.values()) {
1612
+ if (it.epc !== ref)
1613
+ continue;
1614
+ if (hit)
1615
+ return undefined; // 부분이 여럿 — 어느 것인지 알 수 없다
1616
+ hit = it;
1617
+ }
1618
+ return hit;
1619
+ }
1620
+ /**
1621
+ * 실제 자재 이동을 작업에 적어 둔다 — ISA-95 `JobResponse.MaterialActual`.
1622
+ * 같은 품목·같은 쓰임은 **한 줄로 합친다**(줄을 늘리면 실적을 세는 쪽이 중복을 걷어내야 한다).
1623
+ */
1624
+ recordMaterialActual(t, definitionId, use, quantity, uom) {
1625
+ if (!definitionId || !(quantity > 0))
1626
+ return;
1627
+ const rows = (t.materialActual ??= []);
1628
+ const hit = rows.find(r => r.definitionId === definitionId && r.use === use && r.uom === uom);
1629
+ if (hit)
1630
+ hit.quantity += quantity;
1631
+ else
1632
+ rows.push({ definitionId, use, quantity, ...(uom ? { uom } : {}) });
1633
+ }
1634
+ /** 확보한 자재를 **작업 시작 시점에 소비**한다 — 수량이 0 이 되면 물품 자체가 사라진다. */
1635
+ consumeMaterials(t, taken) {
1636
+ if (!taken.length)
1637
+ return;
1638
+ /* 통째로 없어지는 것은 **원시로** 내보낸다(계보가 남고 미러가 배운다). 부분 소비는 수량만 줄고
1639
+ 물품이 남으므로 `transform` 의 입력(전량 소비)으로 표현할 수 없다 — 그 경우 수량 변화를
1640
+ 관측 이벤트로 낸다. 어느 쪽이든 **상태만 바뀌는 일은 없다.** */
1641
+ const whole = [];
1642
+ for (const { epc, take } of taken) {
1643
+ const it = this.items.get(epc);
1644
+ if (!it)
1645
+ continue;
1646
+ const left = (it.qty ?? 1) - take;
1647
+ if (left > 0) {
1648
+ it.qty = left;
1649
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom);
1650
+ /* 남은 수량을 그대로 알린다 — 관측 경로가 그 자리의 사실을 갱신한다(§quantityList). */
1651
+ this.emit(objectEvent({
1652
+ eventTime: this.now(), action: 'OBSERVE', bizStep: CBV_BIZSTEP.consuming, disposition: DISP.in_progress,
1653
+ epcList: [], quantityList: [{ epcClass: it.gtin ?? it.epc, quantity: left, uom: it.uom }],
1654
+ readPoint: it.location, bizLocation: it.location
1655
+ }));
1656
+ continue;
1657
+ }
1658
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom);
1659
+ whole.push(epc);
1660
+ }
1661
+ if (whole.length) {
1662
+ this.transform(whole, [], {
1663
+ bizStep: CBV_BIZSTEP.consuming, disposition: DISP.in_progress, transformationId: t.id,
1664
+ readPoint: this.items.get(whole[0])?.location ?? t.toNode
1665
+ });
1666
+ }
1667
+ }
1101
1668
  claimPersonnel(t) {
1102
1669
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
1103
1670
  if (!need?.length)
@@ -1110,7 +1677,12 @@ export class FlowEngine {
1110
1677
  const avail = [...this.persons.values()].filter(p => p.status === 'idle' &&
1111
1678
  !picked.includes(p.id) &&
1112
1679
  !this.personOffShift(p) &&
1113
- (req.personnelClass === undefined || p.personnelClass === req.personnelClass));
1680
+ !this.outOfEffect(p) && // 입사 전·퇴사 후는 이 시각의 모델에 없다
1681
+ /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
1682
+ 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
1683
+ 유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
1684
+ (req.personnelClass === undefined ||
1685
+ classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass)));
1114
1686
  if (avail.length < want)
1115
1687
  return null; // 한 등급이라도 모자라면 시작하지 않는다
1116
1688
  for (let i = 0; i < want; i++)
@@ -1124,7 +1696,15 @@ export class FlowEngine {
1124
1696
  * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
1125
1697
  */
1126
1698
  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));
1699
+ const free = (kind, picked = []) => [...this.equipment.values()].filter(m => m.status === 'idle' &&
1700
+ !m.held &&
1701
+ !this.offShift(m) &&
1702
+ !this.outOfEffect(m) && // 도입 전·폐기 후는 이 시각의 모델에 없다
1703
+ !picked.includes(m.id) &&
1704
+ /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
1705
+ 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
1706
+ 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
1707
+ (kind === undefined || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind)));
1128
1708
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
1129
1709
  if (!need?.length) {
1130
1710
  const one = free(t.resourceType)[0];
@@ -1170,39 +1750,95 @@ export class FlowEngine {
1170
1750
  }
1171
1751
  /** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
1172
1752
  personOffShift(p) {
1173
- const w = p.window;
1174
- if (!w)
1175
- return false;
1176
- const h = this.hourOfDay();
1177
- return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
1753
+ return this.offCalendar(p);
1178
1754
  }
1179
1755
  /**
1180
1756
  * 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
1181
1757
  * 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
1182
1758
  */
1183
- stationFull(nodeId) {
1184
- if (!nodeId)
1759
+ stationFull(locationId) {
1760
+ if (!locationId)
1185
1761
  return false;
1186
- const limit = this.nodes.get(nodeId)?.parallelism;
1762
+ const limit = this.locations.get(locationId)?.parallelism;
1187
1763
  if (!(typeof limit === 'number' && limit > 0))
1188
1764
  return false;
1189
1765
  let running = 0;
1190
1766
  for (const t of this.tasks.values())
1191
- if (t.status === 'in-progress' && t.toNode === nodeId)
1767
+ if (t.status === 'in-progress' && t.toNode === locationId)
1192
1768
  running++;
1193
1769
  return running >= limit;
1194
1770
  }
1195
1771
  /** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
1196
1772
  offShift(m) {
1197
- const w = m.window;
1198
- if (!w)
1199
- return false;
1200
- const h = this.hourOfDay();
1201
- return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
1773
+ return this.offCalendar(m);
1774
+ }
1775
+ /** 왜 쉬는가 — 휴일·휴게(non-working)인지 주말·교대 사이(off-hours)인지(§OffCalendarReason). */
1776
+ offReason(r) {
1777
+ const why = offCalendarReasonAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
1778
+ return why ? { offShiftReason: why } : {};
1779
+ }
1780
+ /**
1781
+ * 유효 기간 밖인가 — **네 번째 이유**(§Effectivity). 설비·사람·자산이 같은 규칙을 쓴다.
1782
+ *
1783
+ * 시뮬 시각을 ISO 로 풀어 계약의 `effectivityAt` 에 넘긴다. **판정은 커널에 두지 않는다** —
1784
+ * 호스트(라이브 관측)도 같은 판정을 해야 하고, 규칙이 두 벌이면 갈라진다.
1785
+ */
1786
+ effectivityOf(r) {
1787
+ if (!r.effectiveStart && !r.effectiveEnd)
1788
+ return undefined; // 흔한 경우에 Date 를 만들지 않는다
1789
+ return effectivityAt(r, this.now());
1790
+ }
1791
+ /**
1792
+ * 지금 어느 교대인가 — 계약의 `activeShiftOf` 에 위임한다(미러도 같은 함수를 쓴다).
1793
+ * 선언이 없으면 `undefined`: 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.
1794
+ */
1795
+ shiftOf(r) {
1796
+ if (!r.workCalendar?.length)
1797
+ return undefined;
1798
+ /* 시각까지 넘긴다 — 휴일에는 어느 교대도 서지 않는다(되풀이만 보면 평소 교대로 잘못 답한다). */
1799
+ return activeShiftAt(r.workCalendar, this.nowMs(), this.boardDef?.utcOffsetMinutes);
1800
+ }
1801
+ /** 유효 기간 밖이라 이 시각의 모델에 참여하지 않는가 — 배정 게이트가 쓰는 형태. */
1802
+ outOfEffect(r) {
1803
+ return this.effectivityOf(r) !== undefined;
1804
+ }
1805
+ /**
1806
+ * 스냅샷·델타에 실을 조각 — **선언한 기간과 판정을 함께** 낸다.
1807
+ *
1808
+ * 판정만 내면 화면이 "왜" 를 말할 수 없고(언제 폐기됐나), 기간만 내면 소비처마다 다시 판정해 갈라진다.
1809
+ * 선언이 없으면 아무것도 붙이지 않는다(대부분의 자원이 그렇다 — 필드를 늘리지 않는다).
1810
+ */
1811
+ effectivePart(r) {
1812
+ if (!r.effectiveStart && !r.effectiveEnd)
1813
+ return {};
1814
+ const e = this.effectivityOf(r);
1815
+ return {
1816
+ ...(r.effectiveStart ? { effectiveStart: r.effectiveStart } : {}),
1817
+ ...(r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}),
1818
+ ...(e ? { effectivity: e } : {})
1819
+ };
1820
+ }
1821
+ /**
1822
+ * 지금 근무 시간 밖인가 — **캘린더가 있으면 그것으로, 없으면 옛 `window` 로** 판정한다.
1823
+ *
1824
+ * `window`(시 단위 하나)는 캘린더의 특수한 경우다. 둘을 한 함수로 모아 두면 인원·설비가 같은 규칙을
1825
+ * 쓴다(예전에는 같은 식이 두 곳에 복사돼 있었다 — 한쪽만 고치면 조용히 갈라진다).
1826
+ */
1827
+ offCalendar(r) {
1828
+ return offCalendarAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
1829
+ }
1830
+ /**
1831
+ * 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
1832
+ *
1833
+ * **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
1834
+ * 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
1835
+ */
1836
+ minuteOfDay() {
1837
+ return minuteOfDayAt(this.nowMs(), this.boardDef?.utcOffsetMinutes);
1202
1838
  }
1203
1839
  /** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
1204
1840
  hourOfDay() {
1205
- return new Date(BASE_EPOCH + this.clockMs).getUTCHours();
1841
+ return new Date(this.nowMs()).getUTCHours();
1206
1842
  }
1207
1843
  /**
1208
1844
  * 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
@@ -1219,12 +1855,12 @@ export class FlowEngine {
1219
1855
  /** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
1220
1856
  sampleExp(meanMs) { return -Math.log(1 - this.rng()) * meanMs; }
1221
1857
  /**
1222
- * 확률적 설비 고장(MTBF/MTTR) — mtbf 지정 무버만 참여(미지정=고장 없음, rng 무소비 → byte-identical).
1858
+ * 확률적 설비 고장(MTBF/MTTR) — mtbf 지정 설비만 참여(미지정=고장 없음, rng 무소비 → byte-identical).
1223
1859
  * up: nextFailure 도래 시 down(수리까지 repairUntil). down: downMs 누적, repair 도래 시 up(다음 고장 예약).
1224
- * down 중 무버는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
1860
+ * down 중 설비는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
1225
1861
  */
1226
1862
  processFailures(dt) {
1227
- for (const m of this.movers.values()) {
1863
+ for (const m of this.equipment.values()) {
1228
1864
  if (m.status === 'down') {
1229
1865
  // down 회계 + 수리 — 확률적 고장·강제 고장(resource.down) 공통. repairUntilMs 도래 시 복구.
1230
1866
  m.downMs += dt;
@@ -1233,7 +1869,7 @@ export class FlowEngine {
1233
1869
  m.repairUntilMs = undefined;
1234
1870
  if (m.mtbfMs !== undefined)
1235
1871
  m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
1236
- this.emitMover(m);
1872
+ this.emitEquipment(m);
1237
1873
  }
1238
1874
  continue;
1239
1875
  }
@@ -1243,12 +1879,12 @@ export class FlowEngine {
1243
1879
  m.holdMs = (m.holdMs ?? 0) + dt;
1244
1880
  continue;
1245
1881
  }
1246
- // 확률적 고장 — mtbf 지정 무버만(미지정=고장 없음, rng 무소비 → byte-identical baseline).
1882
+ // 확률적 고장 — mtbf 지정 설비만(미지정=고장 없음, rng 무소비 → byte-identical baseline).
1247
1883
  if (m.mtbfMs !== undefined && this.clockMs >= (m.nextFailureMs ?? Infinity)) {
1248
1884
  m.status = 'down';
1249
1885
  m.repairUntilMs = this.clockMs + this.sampleExp(m.mttrMs ?? m.mtbfMs);
1250
1886
  m.nextFailureMs = undefined;
1251
- this.emitMover(m);
1887
+ this.emitEquipment(m);
1252
1888
  }
1253
1889
  }
1254
1890
  }
@@ -1268,6 +1904,8 @@ export class FlowEngine {
1268
1904
  const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
1269
1905
  return {
1270
1906
  epc: i.epc,
1907
+ ...(i.subLotId ? { subLotId: i.subLotId } : {}),
1908
+ ...(i.definitionId ? { definitionId: i.definitionId } : {}),
1271
1909
  ...(i.gtin ? { gtin: i.gtin } : {}),
1272
1910
  ...(gtinKey ? { gtinKey } : {}),
1273
1911
  ...(lot ? { lot } : {}),
@@ -1277,6 +1915,7 @@ export class FlowEngine {
1277
1915
  ...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
1278
1916
  ...(i.qty !== undefined ? { qty: i.qty } : {}),
1279
1917
  ...(i.uom ? { uom: i.uom } : {}),
1918
+ ...(i.quantities?.length ? { quantities: i.quantities } : {}),
1280
1919
  ...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
1281
1920
  ...(i.ilmd ? { ilmd: i.ilmd } : {})
1282
1921
  };
@@ -1309,9 +1948,13 @@ export class FlowEngine {
1309
1948
  }
1310
1949
  }
1311
1950
  processOrders() {
1312
- for (const o of this.orders.values())
1313
- if (o.status === 'created' && !o.held)
1314
- this.allocate(o);
1951
+ /* **우선순위 순으로 할당한다** — 표준 `OperationsRequest.Priority`. 같은 우선순위는 입력 순서를
1952
+ 지켜 결정성을 잃지 않는다(정렬이 안정적이어야 같은 seed 가 같은 결과를 낸다). */
1953
+ const pending = [...this.orders.values()].filter(o => o.status === 'created' && !o.held);
1954
+ if (pending.some(o => o.priority !== undefined))
1955
+ pending.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
1956
+ for (const o of pending)
1957
+ this.allocate(o);
1315
1958
  }
1316
1959
  /**
1317
1960
  * 작업 진행 — **진행을 먼저, 배정을 나중에.**
@@ -1326,7 +1969,12 @@ export class FlowEngine {
1326
1969
  }
1327
1970
  assignTasks() {
1328
1971
  // created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
1329
- for (const t of this.tasks.values()) {
1972
+ /* 자원이 모자랄 **무엇을 먼저 잡는가** — 우선순위가 그것을 정한다(표준 `JobOrder.Priority`).
1973
+ 선언이 하나도 없으면 정렬하지 않는다(기존 거동 그대로, 불필요한 순서 변경 없음). */
1974
+ const queue = [...this.tasks.values()];
1975
+ if (queue.some(t => t.priority !== undefined))
1976
+ queue.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
1977
+ for (const t of queue) {
1330
1978
  if (t.status !== 'created')
1331
1979
  continue;
1332
1980
  /* 필요 인원 — 오퍼레이션 명세가 요구하면 등급별로 사람을 잡는다. 모자라면 **기다린다**
@@ -1338,12 +1986,17 @@ export class FlowEngine {
1338
1986
  const gear = this.claimAssets(t);
1339
1987
  if (gear === null)
1340
1988
  continue;
1989
+ /* 필요 자재 — 부품이 없으면 라인이 선다. 인원·자산과 같은 규칙(부분 투입 없이 전량 아니면 대기). */
1990
+ const stock = this.claimMaterials(t);
1991
+ if (stock === null)
1992
+ continue;
1341
1993
  if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
1342
1994
  t.status = 'in-progress';
1343
1995
  t.remainingMs = t.durationMs;
1344
1996
  t.startedAtSimMs = this.clockMs;
1345
1997
  this.assignCrew(t, crew);
1346
1998
  this.assignAssets(t, gear);
1999
+ this.consumeMaterials(t, stock);
1347
2000
  this.emitTask(t);
1348
2001
  continue;
1349
2002
  }
@@ -1356,33 +2009,34 @@ export class FlowEngine {
1356
2009
  const rigs = this.claimEquipment(t);
1357
2010
  if (!rigs)
1358
2011
  continue;
1359
- const mover = this.movers.get(rigs[0]);
1360
- // 체인지오버: task 의 changeoverKey 가 무버 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
1361
- if (t.setupMs && t.changeoverKey !== undefined && mover.lastChangeoverKey !== undefined && mover.lastChangeoverKey !== t.changeoverKey) {
2012
+ const eq = this.equipment.get(rigs[0]);
2013
+ // 체인지오버: task 의 changeoverKey 가 설비 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
2014
+ if (t.setupMs && t.changeoverKey !== undefined && eq.lastChangeoverKey !== undefined && eq.lastChangeoverKey !== t.changeoverKey) {
1362
2015
  t.appliedSetupMs = t.setupMs;
1363
- t.durationMs += t.setupMs; // 셋업을 가동 앞에 folded(무버 점유 = 셋업+사이클)
2016
+ t.durationMs += t.setupMs; // 셋업을 가동 앞에 folded(설비 점유 = 셋업+사이클)
1364
2017
  }
1365
2018
  if (t.changeoverKey !== undefined)
1366
- mover.lastChangeoverKey = t.changeoverKey;
2019
+ eq.lastChangeoverKey = t.changeoverKey;
1367
2020
  for (const id of rigs) {
1368
- const m = this.movers.get(id);
2021
+ const m = this.equipment.get(id);
1369
2022
  m.status = 'busy';
1370
2023
  m.taskId = t.id;
1371
2024
  }
1372
2025
  t.status = 'in-progress';
1373
- t.resource = mover.id;
2026
+ t.resource = eq.id;
1374
2027
  t.remainingMs = t.durationMs;
1375
2028
  t.startedAtSimMs = this.clockMs;
1376
2029
  if (rigs.length > 1)
1377
2030
  t.resources = rigs.slice(); // 대표만으로는 함께 잡힌 설비가 사라진다
1378
2031
  this.assignCrew(t, crew);
1379
2032
  this.assignAssets(t, gear);
2033
+ this.consumeMaterials(t, stock);
1380
2034
  this.emitTask(t);
1381
2035
  // process: 제자리 변환(모션 없음). transport: 이동 모션 방출.
1382
2036
  if (t.intent === 'process')
1383
- this.emitMover(mover);
2037
+ this.emitEquipment(eq);
1384
2038
  else
1385
- this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
2039
+ this.emitEquipment(eq, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
1386
2040
  }
1387
2041
  }
1388
2042
  /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
@@ -1390,11 +2044,61 @@ export class FlowEngine {
1390
2044
  for (const t of this.tasks.values()) {
1391
2045
  if (t.status !== 'in-progress')
1392
2046
  continue;
1393
- if (t.resource && this.movers.get(t.resource)?.status === 'down')
2047
+ if (t.resource && this.equipment.get(t.resource)?.status === 'down')
1394
2048
  continue; // 설비 고장 → 작업 동결
1395
2049
  t.remainingMs -= dt;
1396
2050
  if (t.remainingMs > 0)
1397
2051
  continue;
2052
+ /*
2053
+ * **산출 자재를 만든다** — ISA-95 `MaterialSpecification`(`use: 'produced'`).
2054
+ *
2055
+ * 도메인 훅(`onTaskComplete`)보다 **먼저** 한다: 훅이 산출물을 보고 다음 일을 만들 수 있어야
2056
+ * 한다(만들어지기 전에 물으면 없다고 답한다). 소비가 시작 시점인 것과 짝이다 — 자재는
2057
+ * 들어갈 때 없어지고 나올 때 생긴다.
2058
+ */
2059
+ /*
2060
+ * **주체가 사라진 작업은 완료시키지 않고 접는다.**
2061
+ *
2062
+ * 진행 중에 그 물품이 없어질 수 있다(포장으로 합쳐지고, 출하로 나가고, 다른 공정이 소비한다).
2063
+ * 그때 도메인 훅은 "없는 물품을 옮기려" 하다 던졌고, **그 예외 하나가 예측 전체를 죽였다** —
2064
+ * 사용자에게는 정확도 추세가 통째로 사라진 것으로 보였다.
2065
+ *
2066
+ * 조용히 넘기지도 않는다: 몇 건이 왜 접혔는지 센다. 그것이 잦다면 **상류에 진짜 문제가 있는 것**
2067
+ * 이고, 그 신호를 지우면 아무도 모른다.
2068
+ */
2069
+ const missing = this.missingContextOf(t);
2070
+ if (missing) {
2071
+ this.abandonedTasks++;
2072
+ /* **한 번은 말한다.** 세어만 두고 아무도 안 보면 없는 것과 같고, 매번 말하면 로그가 묻힌다.
2073
+ 잦아지면 상류에 진짜 어긋남이 있다는 뜻이다 — 그 신호를 지우지 않는다. */
2074
+ if (this.abandonedTasks === 1) {
2075
+ const why = missing === 'item'
2076
+ ? 'the item it moves is no longer in state (consumed, shipped, or the observation did not carry it)'
2077
+ : missing === 'order'
2078
+ ? 'the order it belongs to is no longer in state (already fulfilled, so the seed did not restore it)'
2079
+ : 'the domain cannot complete it (its order or product definition is missing)';
2080
+ console.warn(`[twin-kernel] "${this.tenantId}": folding task ${t.id} — ${why}. Further such tasks are counted, not logged.`);
2081
+ }
2082
+ this.tasks.delete(t.id);
2083
+ this.releaseCrew(t);
2084
+ this.releaseAssets(t);
2085
+ if (t.resource) {
2086
+ const rig = this.equipment.get(t.resource);
2087
+ if (rig) {
2088
+ rig.status = 'idle';
2089
+ rig.taskId = null;
2090
+ }
2091
+ }
2092
+ for (const id of t.resources ?? []) {
2093
+ const m = this.equipment.get(id);
2094
+ if (m) {
2095
+ m.status = 'idle';
2096
+ m.taskId = null;
2097
+ }
2098
+ }
2099
+ continue;
2100
+ }
2101
+ this.produceMaterials(t);
1398
2102
  this.onTaskComplete(t);
1399
2103
  t.status = 'completed';
1400
2104
  this.releaseCrew(t); // 사람은 설비와 별개로 해제한다(dwell 도 사람은 잡고 있었을 수 있다)
@@ -1403,20 +2107,20 @@ export class FlowEngine {
1403
2107
  this.emitTask(t);
1404
2108
  continue;
1405
2109
  } // dwell(무자원) — 해제할 자원 없음
1406
- const mover = this.movers.get(t.resource);
2110
+ const eq = this.equipment.get(t.resource);
1407
2111
  // OEE 계측: 셋업/가동 누적(가동 = 총 duration − 셋업). onTaskComplete 가 recordOutput 로 품질 보고.
1408
2112
  const setup = t.appliedSetupMs ?? 0;
1409
- mover.setupMs += setup;
1410
- mover.runMs += t.durationMs - setup;
1411
- mover.status = 'idle';
1412
- mover.taskId = null;
2113
+ eq.setupMs += setup;
2114
+ eq.runMs += t.durationMs - setup;
2115
+ eq.status = 'idle';
2116
+ eq.taskId = null;
1413
2117
  if (t.intent !== 'process')
1414
- mover.location = t.toNode; // 운반만 위치 이동; process 는 제자리
2118
+ eq.location = t.toNode; // 운반만 위치 이동; process 는 제자리
1415
2119
  /* 함께 잡힌 설비도 같은 규칙으로 놓아 준다 — 대표만 풀면 나머지가 영원히 묶인다. */
1416
2120
  for (const id of t.resources ?? []) {
1417
- if (id === mover.id)
2121
+ if (id === eq.id)
1418
2122
  continue;
1419
- const m = this.movers.get(id);
2123
+ const m = this.equipment.get(id);
1420
2124
  if (!m)
1421
2125
  continue;
1422
2126
  m.setupMs += setup;
@@ -1425,10 +2129,10 @@ export class FlowEngine {
1425
2129
  m.taskId = null;
1426
2130
  if (t.intent !== 'process')
1427
2131
  m.location = t.toNode;
1428
- this.emitMover(m);
2132
+ this.emitEquipment(m);
1429
2133
  }
1430
2134
  this.emitTask(t);
1431
- this.emitMover(mover);
2135
+ this.emitEquipment(eq);
1432
2136
  }
1433
2137
  }
1434
2138
  }