@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.
@@ -25,77 +25,362 @@ __export(index_exports, {
25
25
  BTT_PRODORDER: () => BTT_PRODORDER,
26
26
  CAPABILITIES: () => CAPABILITIES,
27
27
  CAPABILITY_KEYS: () => CAPABILITY_KEYS,
28
+ CBV_BIZSTEP: () => CBV_BIZSTEP,
28
29
  CMD: () => CMD,
29
30
  DISP: () => DISP,
30
31
  DOMAIN_CATALOG: () => DOMAIN_CATALOG,
31
32
  DOMAIN_SYSTEMS: () => DOMAIN_SYSTEMS,
32
33
  EPCIS_CONTEXT: () => EPCIS_CONTEXT,
34
+ EQUIPMENT_LEVEL: () => EQUIPMENT_LEVEL,
33
35
  EventJournal: () => EventJournal,
34
36
  FlowEngine: () => FlowEngine,
37
+ GUARD_PRAGMA: () => GUARD_PRAGMA,
35
38
  ILMD_ATTR: () => ILMD_ATTR,
39
+ LOCATION_SATURATION_NEAR: () => LOCATION_SATURATION_NEAR,
40
+ MATERIAL_PROPERTY: () => MATERIAL_PROPERTY,
36
41
  MES_BIZSTEP: () => MES_BIZSTEP,
37
- MES_NODE_TYPES: () => MES_NODE_TYPES,
42
+ MES_LOCATION_TYPES: () => MES_LOCATION_TYPES,
38
43
  MES_PART_GTINS: () => MES_PART_GTINS,
39
44
  MES_PRODUCTS: () => MES_PRODUCTS,
40
45
  MES_PRODUCT_GTINS: () => MES_PRODUCT_GTINS,
41
46
  MES_TYPES: () => MES_TYPES,
42
47
  MesKernel: () => MesKernel,
43
- NODE_SATURATION_NEAR: () => NODE_SATURATION_NEAR,
44
48
  OP_EVENT: () => OP_EVENT,
45
49
  OP_PARAM: () => OP_PARAM,
46
50
  ObservedReducer: () => ObservedReducer,
51
+ PRIORITY_UNSET: () => PRIORITY_UNSET,
52
+ RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
47
53
  StateProjector: () => ObservedReducer,
48
54
  TwinHistory: () => TwinHistory,
49
55
  TwinObserver: () => TwinObserver,
50
56
  TwinRuntime: () => TwinRuntime,
51
57
  UTC_OFFSET: () => UTC_OFFSET,
52
- WMS_NODE_TYPES: () => WMS_NODE_TYPES,
58
+ VOCABULARY_EXCEPTIONS: () => VOCABULARY_EXCEPTIONS,
59
+ WMS_LOCATION_TYPES: () => WMS_LOCATION_TYPES,
53
60
  WMS_TYPES: () => WMS_TYPES,
54
61
  WmsKernel: () => WmsKernel,
55
62
  YARD_BIZSTEP: () => YARD_BIZSTEP,
56
- YMS_NODE_TYPES: () => YMS_NODE_TYPES,
63
+ YMS_LOCATION_TYPES: () => YMS_LOCATION_TYPES,
57
64
  YMS_TYPES: () => YMS_TYPES,
58
65
  YmsKernel: () => YmsKernel,
66
+ activeShiftAt: () => activeShiftAt,
67
+ activeShiftOf: () => activeShiftOf,
59
68
  aggregationEvent: () => aggregationEvent,
69
+ analyzeCapacity: () => analyzeCapacity,
60
70
  capabilitiesForType: () => capabilitiesForType,
71
+ classClosure: () => classClosure,
61
72
  compareStates: () => compareStates,
62
73
  computeOee: () => computeOee,
63
74
  constantDuration: () => constantDuration,
75
+ conversionFactorOf: () => conversionFactorOf,
64
76
  counterfactualAt: () => counterfactualAt,
65
77
  deriveAttentions: () => deriveAttentions,
78
+ dueStatusOf: () => dueStatusOf,
79
+ effectivityAt: () => effectivityAt,
66
80
  fefoPolicy: () => fefoPolicy,
67
81
  firstFitPolicy: () => firstFitPolicy,
82
+ foldJobResponses: () => foldJobResponses,
68
83
  foldTaskRecords: () => foldTaskRecords,
69
84
  gdtiUri: () => gdtiUri,
70
85
  graiUri: () => graiUri,
86
+ hierarchyOf: () => hierarchyOf,
87
+ inWorkCalendar: () => inWorkCalendar,
88
+ inWorkCalendarAt: () => inWorkCalendarAt,
71
89
  ingest: () => ingest,
90
+ isEquipmentLevel: () => isEquipmentLevel,
91
+ isoDurationHours: () => isoDurationHours,
92
+ itemKeyOf: () => itemKeyOf,
72
93
  lgtinClass: () => lgtinClass,
94
+ locationStatusOf: () => locationStatusOf,
73
95
  mapRecord: () => mapRecord,
96
+ minuteOfDayAt: () => minuteOfDayAt,
74
97
  monteCarloForecast: () => monteCarloForecast,
75
- nodeStatusOf: () => nodeStatusOf,
76
98
  objectEvent: () => objectEvent,
99
+ offCalendarAt: () => offCalendarAt,
100
+ offCalendarReasonAt: () => offCalendarReasonAt,
77
101
  parseEpc: () => parseEpc,
78
102
  parseIsoDuration: () => parseIsoDuration,
79
103
  partialFitPolicy: () => partialFitPolicy,
104
+ priorityRank: () => priorityRank,
105
+ quantityIn: () => quantityIn,
106
+ readBoardAssets: () => readBoardAssets,
107
+ readBoardEquipment: () => readBoardEquipment,
108
+ readBoardLocations: () => readBoardLocations,
80
109
  replay: () => replay,
110
+ replaySegments: () => replaySegments,
111
+ retiredVocabularyIn: () => retiredVocabularyIn,
81
112
  sgtinClass: () => sgtinClass,
82
113
  sgtinUri: () => sgtinUri,
83
114
  ssccUri: () => ssccUri,
84
115
  stateFieldsOf: () => stateFieldsOf,
116
+ subLotIdOf: () => subLotIdOf,
85
117
  transactionEvent: () => transactionEvent,
86
118
  transformationEvent: () => transformationEvent,
87
119
  validateDomainDefinition: () => validateDomainDefinition,
88
- validateEpcisEvent: () => validateEpcisEvent
120
+ validateEpcisEvent: () => validateEpcisEvent,
121
+ weekdayAt: () => weekdayAt,
122
+ workingTimeOfWeek: () => workingTimeOfWeek
89
123
  });
90
124
  module.exports = __toCommonJS(index_exports);
91
125
 
92
126
  // src/contract.ts
93
- var NODE_SATURATION_NEAR = 0.9;
94
- function nodeStatusOf(n) {
127
+ var LOCATION_SATURATION_NEAR = 0.9;
128
+ function locationStatusOf(n) {
95
129
  const cap = n.capacity;
96
130
  if (!(typeof cap === "number" && cap > 0)) return void 0;
97
131
  const r = (n.occupancy ?? 0) / cap;
98
- return r >= 1 ? "full" : r >= NODE_SATURATION_NEAR ? "near-full" : "available";
132
+ return r >= 1 ? "full" : r >= LOCATION_SATURATION_NEAR ? "near-full" : "available";
133
+ }
134
+ var EQUIPMENT_LEVEL = [
135
+ "Enterprise",
136
+ "Site",
137
+ "Area",
138
+ "ProcessCell",
139
+ "Unit",
140
+ "ProductionLine",
141
+ "WorkCell",
142
+ "ProductionUnit",
143
+ "StorageZone",
144
+ "StorageUnit",
145
+ "WorkCenter",
146
+ "WorkUnit",
147
+ "EquipmentModule",
148
+ "ControlModule",
149
+ "Other"
150
+ ];
151
+ function isEquipmentLevel(v) {
152
+ return typeof v === "string" && EQUIPMENT_LEVEL.includes(v);
153
+ }
154
+ function hierarchyOf(s) {
155
+ const push = (m, k, v) => {
156
+ const cur = m.get(k);
157
+ if (cur) cur.push(v);
158
+ else m.set(k, [v]);
159
+ };
160
+ const parent = /* @__PURE__ */ new Map();
161
+ const kids = /* @__PURE__ */ new Map();
162
+ const known = new Set(s.locations.map((n) => n.id));
163
+ for (const n of s.locations) {
164
+ if (!n.parentId || n.parentId === n.id) continue;
165
+ parent.set(n.id, n.parentId);
166
+ if (known.has(n.parentId)) push(kids, n.parentId, n.id);
167
+ }
168
+ for (const start of known) {
169
+ const seen = [start];
170
+ for (let at = parent.get(start); at !== void 0; at = parent.get(at)) {
171
+ if (seen.includes(at)) throw new Error(`location hierarchy has a cycle: ${[...seen, at].join(" \u2192 ")}`);
172
+ seen.push(at);
173
+ if (!known.has(at)) break;
174
+ }
175
+ }
176
+ const homes = /* @__PURE__ */ new Map();
177
+ for (const m of s.equipment ?? []) if (m.homeLocation) push(homes, m.homeLocation, m.id);
178
+ const ancestorsOf = (id) => {
179
+ const out = [];
180
+ for (let at = parent.get(id); at !== void 0; at = parent.get(at)) {
181
+ out.push(at);
182
+ if (!known.has(at)) break;
183
+ }
184
+ return out;
185
+ };
186
+ const descendantsOf = (id) => {
187
+ const out = [];
188
+ const stack = [...kids.get(id) ?? []];
189
+ while (stack.length) {
190
+ const cur = stack.pop();
191
+ out.push(cur);
192
+ stack.push(...kids.get(cur) ?? []);
193
+ }
194
+ return out;
195
+ };
196
+ const typeOf = new Map(s.locations.map((n) => [n.id, n.type]));
197
+ const levelOf = new Map(s.locations.map((n) => [n.id, n.level]));
198
+ return {
199
+ childrenOf: (id) => [...kids.get(id) ?? []],
200
+ ancestorsOf,
201
+ descendantsOf,
202
+ rollupOf: (id) => ancestorsOf(id).find((a) => !known.has(a)),
203
+ ancestorOfLevel: (id, level) => ancestorsOf(id).find((a) => known.has(a) && levelOf.get(a) === level),
204
+ ancestorOfType: (id, type) => ancestorsOf(id).find((a) => known.has(a) && typeOf.get(a) === type),
205
+ equipmentOf: (id, opts) => {
206
+ const scope = opts?.deep ? [id, ...descendantsOf(id)] : [id];
207
+ return scope.flatMap((n) => homes.get(n) ?? []);
208
+ }
209
+ };
210
+ }
211
+ function effectivityAt(p, at) {
212
+ if (!p || !at) return void 0;
213
+ const atMs = Date.parse(at);
214
+ if (!Number.isFinite(atMs)) return void 0;
215
+ const from = p.effectiveStart ? Date.parse(p.effectiveStart) : NaN;
216
+ if (Number.isFinite(from) && atMs < from) return "not-yet";
217
+ const to = p.effectiveEnd ? Date.parse(p.effectiveEnd) : NaN;
218
+ if (Number.isFinite(to) && atMs > to) return "expired";
219
+ return void 0;
220
+ }
221
+ function classClosure(directIds, defs, at) {
222
+ const byId = new Map((defs ?? []).map((d) => [d.id, d]));
223
+ const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
224
+ const out = /* @__PURE__ */ new Set();
225
+ const stack = [...directIds ?? []];
226
+ while (stack.length) {
227
+ const id = stack.pop();
228
+ if (out.has(id)) continue;
229
+ const def = byId.get(id);
230
+ if (!inWindow(def)) continue;
231
+ out.add(id);
232
+ for (const b of def?.baseIds ?? []) if (!out.has(b)) stack.push(b);
233
+ }
234
+ return out;
235
+ }
236
+ var PRIORITY_UNSET = Number.POSITIVE_INFINITY;
237
+ function priorityRank(p) {
238
+ return typeof p === "number" && Number.isFinite(p) ? p : PRIORITY_UNSET;
239
+ }
240
+ function dueStatusOf(x, nowIso) {
241
+ if (!x.endTime || !nowIso) return void 0;
242
+ const due = Date.parse(x.endTime);
243
+ const now = Date.parse(nowIso);
244
+ if (!Number.isFinite(due) || !Number.isFinite(now)) return void 0;
245
+ return now > due ? "late" : "on-time";
246
+ }
247
+ function subLotIdOf(classUri, location) {
248
+ return `${classUri}@${location}`;
249
+ }
250
+ function itemKeyOf(item) {
251
+ return item.subLotId ?? item.epc;
252
+ }
253
+ function quantityIn(item, uom, definitions) {
254
+ const hit = item.quantities?.find((q) => (q.uom ?? void 0) === (uom ?? void 0));
255
+ if (hit) return hit.value;
256
+ if ((item.uom ?? void 0) === (uom ?? void 0)) return item.qty;
257
+ return convertQuantity(item, uom, definitions);
258
+ }
259
+ var MATERIAL_PROPERTY = {
260
+ /** 기준 단위 하나당 이 단위의 양. `uom` 이 대상 단위. */
261
+ perBaseUnit: "perBaseUnit"
262
+ };
263
+ function conversionFactorOf(def, uom) {
264
+ if (!def || !uom) return void 0;
265
+ for (const p of def.properties ?? []) {
266
+ if (p.id !== MATERIAL_PROPERTY.perBaseUnit || p.uom !== uom) continue;
267
+ const n = typeof p.value === "number" ? p.value : Number(p.value);
268
+ if (Number.isFinite(n) && n > 0) return n;
269
+ }
270
+ return void 0;
271
+ }
272
+ function convertQuantity(item, uom, definitions) {
273
+ if (!definitions || !uom) return void 0;
274
+ if (typeof item.qty !== "number" || !Number.isFinite(item.qty)) return void 0;
275
+ if (item.uom) return void 0;
276
+ const def = definitions.get(item.definitionId ?? item.gtin ?? "");
277
+ const factor = conversionFactorOf(def, uom);
278
+ return factor === void 0 ? void 0 : item.qty * factor;
279
+ }
280
+ function minutesOfDay(hhmm) {
281
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm ?? "");
282
+ if (!m) return void 0;
283
+ const h = Number(m[1]);
284
+ const mi = Number(m[2]);
285
+ if (h > 23 || mi > 59) return void 0;
286
+ return h * 60 + mi;
287
+ }
288
+ var isAbsolute = (e) => !!(e.startDateTime || e.finishDateTime);
289
+ var needsDate = (e) => isAbsolute(e) || !!e.daysOfWeek?.length;
290
+ function onDeclaredDay(e, minuteOfDay, weekday) {
291
+ if (!e.daysOfWeek?.length) return true;
292
+ const from = minutesOfDay(e.fromTime);
293
+ const to = minutesOfDay(e.toTime);
294
+ const crosses = from !== void 0 && to !== void 0 && from > to;
295
+ const startedDay = crosses && minuteOfDay < to ? (weekday + 6) % 7 : weekday;
296
+ return e.daysOfWeek.includes(startedDay);
297
+ }
298
+ function weekdayAt(ms2, utcOffsetMinutes) {
299
+ return new Date(ms2 + (utcOffsetMinutes ?? 0) * 6e4).getUTCDay();
300
+ }
301
+ function coversInstant(e, atMs) {
302
+ const from = e.startDateTime ? Date.parse(e.startDateTime) : NaN;
303
+ const to = e.finishDateTime ? Date.parse(e.finishDateTime) : NaN;
304
+ if (!Number.isFinite(from) && !Number.isFinite(to)) return false;
305
+ if (Number.isFinite(from) && atMs < from) return false;
306
+ if (Number.isFinite(to) && atMs >= to) return false;
307
+ return true;
308
+ }
309
+ function coversMinute(e, minuteOfDay) {
310
+ const from = minutesOfDay(e.fromTime);
311
+ const to = minutesOfDay(e.toTime);
312
+ if (from === void 0 || to === void 0) return false;
313
+ return from <= to ? minuteOfDay >= from && minuteOfDay < to : minuteOfDay >= from || minuteOfDay < to;
314
+ }
315
+ function inWorkCalendar(entries, minuteOfDay) {
316
+ return judgeCalendar(entries, (e) => coversMinute(e, minuteOfDay), false);
317
+ }
318
+ function inWorkCalendarAt(entries, atMs, utcOffsetMinutes) {
319
+ const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
320
+ const day = weekdayAt(atMs, utcOffsetMinutes);
321
+ const covers = (e) => {
322
+ if (isAbsolute(e)) return coversInstant(e, atMs);
323
+ if (!coversMinute(e, minute)) return false;
324
+ return onDeclaredDay(e, minute, day);
325
+ };
326
+ return judgeCalendar(entries, covers, true);
327
+ }
328
+ function judgeCalendar(entries, covers, withAbsolute) {
329
+ if (!entries?.length) return true;
330
+ const usable = withAbsolute ? entries : entries.filter((e) => !needsDate(e));
331
+ if (!usable.length) return true;
332
+ const working = usable.filter((e) => (e.entryType ?? "working") === "working");
333
+ const off = usable.filter((e) => e.entryType === "non-working");
334
+ if (off.some(covers)) return false;
335
+ if (!working.length) return true;
336
+ return working.some(covers);
337
+ }
338
+ function activeShiftOf(entries, minuteOfDay) {
339
+ if (!entries?.length) return void 0;
340
+ if (!inWorkCalendar(entries, minuteOfDay)) return void 0;
341
+ for (const e of entries) {
342
+ if (needsDate(e) || (e.entryType ?? "working") !== "working" || !e.id) continue;
343
+ if (coversMinute(e, minuteOfDay)) return e.id;
344
+ }
345
+ return void 0;
346
+ }
347
+ function activeShiftAt(entries, atMs, utcOffsetMinutes) {
348
+ if (!entries?.length) return void 0;
349
+ if (!inWorkCalendarAt(entries, atMs, utcOffsetMinutes)) return void 0;
350
+ const minute = minuteOfDayAt(atMs, utcOffsetMinutes);
351
+ const day = weekdayAt(atMs, utcOffsetMinutes);
352
+ for (const e of entries) {
353
+ if (isAbsolute(e) || (e.entryType ?? "working") !== "working" || !e.id) continue;
354
+ if (!coversMinute(e, minute)) continue;
355
+ if (!onDeclaredDay(e, minute, day)) continue;
356
+ return e.id;
357
+ }
358
+ return void 0;
359
+ }
360
+ function minuteOfDayAt(ms2, utcOffsetMinutes) {
361
+ const d = new Date(ms2 + (utcOffsetMinutes ?? 0) * 6e4);
362
+ return d.getUTCHours() * 60 + d.getUTCMinutes();
363
+ }
364
+ function offCalendarReasonAt(r, ms2, utcOffsetMinutes) {
365
+ if (!offCalendarAt(r, ms2, utcOffsetMinutes)) return void 0;
366
+ const entries = r.workCalendar;
367
+ if (!entries?.length) return "off-hours";
368
+ const minute = minuteOfDayAt(ms2, utcOffsetMinutes);
369
+ const day = weekdayAt(ms2, utcOffsetMinutes);
370
+ for (const e of entries) {
371
+ if (e.entryType !== "non-working") continue;
372
+ const covered = isAbsolute(e) ? coversInstant(e, ms2) : coversMinute(e, minute) && onDeclaredDay(e, minute, day);
373
+ if (covered) return "non-working";
374
+ }
375
+ return "off-hours";
376
+ }
377
+ function offCalendarAt(r, ms2, utcOffsetMinutes) {
378
+ const minute = minuteOfDayAt(ms2, utcOffsetMinutes);
379
+ if (r.workCalendar?.length) return !inWorkCalendarAt(r.workCalendar, ms2, utcOffsetMinutes);
380
+ const w = r.window;
381
+ if (!w) return false;
382
+ const h = Math.floor(minute / 60);
383
+ return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
99
384
  }
100
385
  var OP_EVENT = {
101
386
  task: "task.status",
@@ -114,7 +399,7 @@ var CMD = {
114
399
  orderRelease: "order.release",
115
400
  attentionAck: "attention.ack",
116
401
  // 주목 신호 확인(OPC UA A&C acknowledge) — args:{id}
117
- // Operable 코어 — 모든 operable 자원(설비·이동무버) 공통. args:{resourceId}. capability-keyed(무방언, equipment.* 아님).
402
+ // Operable 코어 — 모든 operable 자원(설비·이동설비) 공통. args:{resourceId}. capability-keyed(무방언, equipment.* 아님).
118
403
  resourceHold: "resource.hold",
119
404
  // 계획 정지(정비/오프라인) — 배정 스킵
120
405
  resourceResume: "resource.resume",
@@ -126,8 +411,27 @@ var CMD = {
126
411
  resourceResetMetrics: "resource.reset-metrics",
127
412
  // OEE 계측 창 리셋
128
413
  resourceAdd: "resource.add"
129
- // 라이브 자원(무버) 추가 — args:{kind, homeNode, count?}. 런타임 구조 변이(what-if 아닌 실제 act)
414
+ // 라이브 자원(설비) 추가 — args:{kind, homeLocation, count?}. 런타임 구조 변이(what-if 아닌 실제 act)
130
415
  };
416
+ function readBoardLocations(def) {
417
+ const d = def;
418
+ return d.locations ?? d.nodes ?? [];
419
+ }
420
+ function readBoardEquipment(def) {
421
+ const d = def;
422
+ const list = d.equipment ?? d.equipmentList ?? [];
423
+ return list.map((e) => normalizeHomeLocation(e));
424
+ }
425
+ function normalizeHomeLocation(entry) {
426
+ const legacy = entry.homeNode;
427
+ if (legacy === void 0 || entry.homeLocation !== void 0) return entry;
428
+ const { homeNode: _drop, ...rest } = entry;
429
+ return { ...rest, homeLocation: legacy };
430
+ }
431
+ function readBoardAssets(def) {
432
+ const list = def.assets ?? [];
433
+ return list.map((a) => normalizeHomeLocation(a));
434
+ }
131
435
 
132
436
  // src/domain-definition.ts
133
437
  var OP_PARAM = {
@@ -152,19 +456,19 @@ function validateDomainDefinition(def) {
152
456
  if (!def || typeof def !== "object") return ["domain definition \uC5C6\uC74C/\uAC1D\uCCB4 \uC544\uB2D8"];
153
457
  if (typeof def.id !== "string" || !def.id) v.push("id \uB204\uB77D");
154
458
  if (typeof def.label !== "string" || !def.label) v.push("label \uB204\uB77D");
155
- if (!Array.isArray(def.nodeTypes) || def.nodeTypes.length === 0) v.push("nodeTypes \uBE44\uC5B4\uC788\uC74C");
459
+ if (!Array.isArray(def.locationTypes) || def.locationTypes.length === 0) v.push("locationTypes \uBE44\uC5B4\uC788\uC74C");
156
460
  if (!Array.isArray(def.resourceTypes)) v.push("resourceTypes \uBC30\uC5F4 \uC544\uB2D8");
157
- const nodeKeys2 = new Set((def.nodeTypes || []).map((n) => n.key));
461
+ const locationKeys2 = new Set((def.locationTypes || []).map((n) => n.key));
158
462
  const resKeys = new Set((def.resourceTypes || []).map((r) => r.key));
159
463
  const matKeys = new Set((def.materials || []).map((m) => m.key));
160
464
  const opKeys = new Set((def.operations || []).map((o) => o.key));
161
465
  const routeKeys = new Set((def.routes || []).map((r) => r.key));
162
- for (const [name, arr] of [["nodeTypes", def.nodeTypes], ["resourceTypes", def.resourceTypes], ["materials", def.materials], ["operations", def.operations], ["routes", def.routes], ["recipes", def.recipes]]) {
466
+ for (const [name, arr] of [["locationTypes", def.locationTypes], ["resourceTypes", def.resourceTypes], ["materials", def.materials], ["operations", def.operations], ["routes", def.routes], ["recipes", def.recipes]]) {
163
467
  for (const d of dupes((arr || []).map((x) => x.key))) v.push(`${name} \uD0A4 \uC911\uBCF5: ${d}`);
164
468
  }
165
469
  for (const o of def.operations || []) {
166
470
  if (!INTENTS.includes(o.intent)) v.push(`operation '${o.key}' intent \uBD80\uC815: ${o.intent}`);
167
- if (o.nodeType && !nodeKeys2.has(o.nodeType)) v.push(`operation '${o.key}' nodeType '${o.nodeType}' \uBBF8\uC815\uC758`);
471
+ if (o.locationType && !locationKeys2.has(o.locationType)) v.push(`operation '${o.key}' locationType '${o.locationType}' \uBBF8\uC815\uC758`);
168
472
  if (o.resourceType && !resKeys.has(o.resourceType)) v.push(`operation '${o.key}' resourceType '${o.resourceType}' \uBBF8\uC815\uC758`);
169
473
  if (o.intent === "dwell" && o.resourceType) v.push(`operation '${o.key}' dwell \uC778\uB370 resourceType \uC9C0\uC815\uB428(\uBB34\uC790\uC6D0\uC774\uC5B4\uC57C)`);
170
474
  }
@@ -196,12 +500,12 @@ function diffBy(predicted, actual, idOf, valOf) {
196
500
  return out.sort((x, y) => x.id.localeCompare(y.id));
197
501
  }
198
502
  function compareStates(predicted, actual) {
199
- const itemLocation = diffBy(predicted.items, actual.items, (i) => i.epc, (i) => i.location);
200
- const itemDisposition = diffBy(predicted.items, actual.items, (i) => i.epc, (i) => i.disposition);
201
- const nodeOccupancy = diffBy(predicted.nodes, actual.nodes, (n) => n.id, (n) => n.occupancy);
503
+ const itemLocation = diffBy(predicted.items, actual.items, itemKeyOf, (i) => i.location);
504
+ const itemDisposition = diffBy(predicted.items, actual.items, itemKeyOf, (i) => i.disposition);
505
+ const locationOccupancy = diffBy(predicted.locations, actual.locations, (n) => n.id, (n) => n.occupancy);
202
506
  const orderStatus = diffBy(predicted.orders, actual.orders, (o) => o.id, (o) => o.status);
203
- const hasDrift = itemLocation.length > 0 || itemDisposition.length > 0 || nodeOccupancy.length > 0 || orderStatus.length > 0;
204
- return { hasDrift, itemLocation, itemDisposition, nodeOccupancy, orderStatus };
507
+ const hasDrift = itemLocation.length > 0 || itemDisposition.length > 0 || locationOccupancy.length > 0 || orderStatus.length > 0;
508
+ return { hasDrift, itemLocation, itemDisposition, locationOccupancy, orderStatus };
205
509
  }
206
510
 
207
511
  // src/counterfactual.ts
@@ -324,6 +628,12 @@ var DISP = {
324
628
  non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
325
629
  // 불량/scrap
326
630
  };
631
+ var CBV_BIZSTEP = {
632
+ /** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
633
+ consuming: "urn:epcglobal:cbv:bizstep:consuming",
634
+ /** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
635
+ commissioning: "urn:epcglobal:cbv:bizstep:commissioning"
636
+ };
327
637
  function ssccUri(companyPrefix, serial) {
328
638
  return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
329
639
  }
@@ -529,6 +839,12 @@ function validateEpcisEvent(e) {
529
839
 
530
840
  // src/observed-reducer.ts
531
841
  var UNKNOWN_TYPE = "unknown";
842
+ function effectiveOf(r) {
843
+ return {
844
+ ...r?.effectiveStart ? { effectiveStart: r.effectiveStart } : {},
845
+ ...r?.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}
846
+ };
847
+ }
532
848
  var ObservedReducer = class {
533
849
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
534
850
  master = /* @__PURE__ */ new Map();
@@ -538,34 +854,109 @@ var ObservedReducer = class {
538
854
  pendingParent = /* @__PURE__ */ new Map();
539
855
  // 자식 EPC → 부모(물류단위)
540
856
  tasks = /* @__PURE__ */ new Map();
541
- movers = /* @__PURE__ */ new Map();
857
+ equipment = /* @__PURE__ */ new Map();
542
858
  persons = /* @__PURE__ */ new Map();
543
859
  assets = /* @__PURE__ */ new Map();
544
860
  orders = /* @__PURE__ */ new Map();
545
861
  revision = 0;
546
862
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
547
863
  corrections = [];
864
+ /**
865
+ * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
866
+ *
867
+ * 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
868
+ * 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
869
+ */
870
+ shifts = /* @__PURE__ */ new Map();
871
+ /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
872
+ utcOffsetMinutes;
548
873
  constructor(board) {
549
- for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
550
- for (const m of board.movers) this.movers.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeNode, origin: "master" });
551
- for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle" });
552
- for (const a of board.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle" });
874
+ this.utcOffsetMinutes = board.utcOffsetMinutes;
875
+ for (const n of readBoardLocations(board)) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
876
+ for (const m of readBoardEquipment(board)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...effectiveOf(m), origin: "master" });
877
+ for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...effectiveOf(p) });
878
+ for (const m of readBoardEquipment(board)) this.rememberShift(`eq:${m.id}`, m);
879
+ for (const p of board.persons ?? []) this.rememberShift(`person:${p.id}`, p);
880
+ for (const a of readBoardAssets(board)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...effectiveOf(a) });
881
+ }
882
+ /**
883
+ * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
884
+ *
885
+ * 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
886
+ * **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
887
+ * 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
888
+ *
889
+ * 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
890
+ * 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
891
+ *
892
+ * ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
893
+ * **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
894
+ * **사라진 자원은 버린다** — 없어진 설비의 상태를 계속 들고 있으면 화면이 없는 설비를 그린다.
895
+ * 다만 **몇 개를 버렸는지 돌려준다.** 조용히 사라지면 사용자는 수가 줄어든 것을 눈치채지 못한다.
896
+ *
897
+ * 관측으로 알게 된 자리(`origin: 'observed'`)는 **새 마스터에 없어도 남긴다** — 마스터가 모르는
898
+ * 자리에서 물건이 실제로 보였다는 사실은 구조를 바꾼다고 사라지지 않는다.
899
+ */
900
+ adoptStructure(board) {
901
+ this.utcOffsetMinutes = board.utcOffsetMinutes;
902
+ const locations = readBoardLocations(board);
903
+ const declaredLocationIds = new Set(locations.map((n) => n.id));
904
+ let locationsAdded = 0;
905
+ let locationsDropped = 0;
906
+ for (const [id, cur] of [...this.master]) {
907
+ if (declaredLocationIds.has(id) || cur.origin === "observed") continue;
908
+ this.master.delete(id);
909
+ locationsDropped++;
910
+ }
911
+ for (const n of locations) {
912
+ if (!this.master.has(n.id)) locationsAdded++;
913
+ this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
914
+ }
915
+ const equipment = readBoardEquipment(board);
916
+ const assets = readBoardAssets(board);
917
+ const persons = board.persons ?? [];
918
+ const dropMissing = (map, declared) => {
919
+ const ids = new Set(declared.map((d) => d.id));
920
+ let dropped = 0;
921
+ for (const id of [...map.keys()]) if (!ids.has(id)) {
922
+ map.delete(id);
923
+ dropped++;
924
+ }
925
+ return dropped;
926
+ };
927
+ const equipmentDropped = dropMissing(this.equipment, equipment);
928
+ const personsDropped = dropMissing(this.persons, persons);
929
+ const assetsDropped = dropMissing(this.assets, assets);
930
+ for (const m of equipment) {
931
+ if (!this.equipment.has(m.id))
932
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...effectiveOf(m), origin: "master" });
933
+ this.rememberShift(`eq:${m.id}`, m);
934
+ }
935
+ for (const p of persons) {
936
+ if (!this.persons.has(p.id))
937
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...effectiveOf(p) });
938
+ this.rememberShift(`person:${p.id}`, p);
939
+ }
940
+ for (const a of assets)
941
+ if (!this.assets.has(a.id))
942
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...effectiveOf(a) });
943
+ return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
553
944
  }
554
945
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
555
946
  applyMaster(u) {
556
947
  if (u.op === "remove") {
557
- this.master.delete(u.node.id);
948
+ this.master.delete(u.location.id);
558
949
  return;
559
950
  }
560
- const cur = this.master.get(u.node.id);
561
- const capacity = u.node.capacity ?? cur?.capacity;
562
- const parallelism = u.node.parallelism ?? cur?.parallelism;
563
- this.master.set(u.node.id, {
564
- id: u.node.id,
565
- type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
951
+ const cur = this.master.get(u.location.id);
952
+ const capacity = u.location.capacity ?? cur?.capacity;
953
+ const parallelism = u.location.parallelism ?? cur?.parallelism;
954
+ this.master.set(u.location.id, {
955
+ id: u.location.id,
956
+ type: u.location.type ?? cur?.type ?? UNKNOWN_TYPE,
566
957
  ...capacity === void 0 ? {} : { capacity },
567
958
  ...parallelism === void 0 ? {} : { parallelism },
568
- ...u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {},
959
+ ...u.location.parentId ?? cur?.parentId ? { parentId: u.location.parentId ?? cur?.parentId } : {},
569
960
  /* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
570
961
  origin: "master"
571
962
  });
@@ -573,7 +964,7 @@ var ObservedReducer = class {
573
964
  /**
574
965
  * 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
575
966
  *
576
- * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
967
+ * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `locations` 에는
577
968
  * 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
578
969
  * 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
579
970
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
@@ -597,6 +988,7 @@ var ObservedReducer = class {
597
988
  */
598
989
  stale(key, e) {
599
990
  const at = Date.parse(String(e.eventTime ?? ""));
991
+ if (Number.isFinite(at) && (this.observedAtMs === void 0 || at > this.observedAtMs)) this.observedAtMs = at;
600
992
  if (!Number.isFinite(at)) return false;
601
993
  const recorded = Date.parse(String(e.data?.recordTime ?? ""));
602
994
  const seen = this.lastAt.get(key);
@@ -611,6 +1003,14 @@ var ObservedReducer = class {
611
1003
  }
612
1004
  /** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
613
1005
  lastAt = /* @__PURE__ */ new Map();
1006
+ /**
1007
+ * **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
1008
+ *
1009
+ * 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
1010
+ * 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
1011
+ * 판정하지 않는다(모르면 단정하지 않는다).
1012
+ */
1013
+ observedAtMs;
614
1014
  /** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
615
1015
  apply(e) {
616
1016
  this.revision++;
@@ -640,27 +1040,42 @@ var ObservedReducer = class {
640
1040
  startedAtSimMs: d.startedAtSimMs,
641
1041
  ...d.personnel?.length ? { personnel: d.personnel.slice() } : {},
642
1042
  ...d.assets?.length ? { assets: d.assets.slice() } : {},
643
- ...d.resources?.length ? { resources: d.resources.slice() } : {}
1043
+ ...d.resources?.length ? { resources: d.resources.slice() } : {},
1044
+ /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다). */
1045
+ ...d.materialActual?.length ? { materialActual: d.materialActual.map((r) => ({ ...r })) } : {},
1046
+ ...d.priority !== void 0 ? { priority: d.priority } : {},
1047
+ ...d.startTime ? { startTime: d.startTime } : {},
1048
+ ...d.endTime ? { endTime: d.endTime } : {}
644
1049
  });
645
1050
  break;
646
1051
  }
647
1052
  case OP_EVENT.equipment: {
648
1053
  const d = e.data;
649
- if (this.stale(`mover:${d.moverId}`, e)) return;
1054
+ if (this.stale(`eq:${d.moverId}`, e)) return;
650
1055
  this.touchLocation(d.location);
651
- const known = this.movers.get(d.moverId);
652
- this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, taskId: d.taskId, motion: d.motion, origin: known?.origin ?? "observed" });
1056
+ const known = this.equipment.get(d.moverId);
1057
+ const homeLocation = d.homeLocation ?? known?.homeLocation;
1058
+ const eqPeriod = d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known);
1059
+ this.equipment.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, ...homeLocation ? { homeLocation } : {}, taskId: d.taskId, motion: d.motion, origin: known?.origin ?? "observed", ...eqPeriod, ...d.held ? { held: true } : {} });
653
1060
  break;
654
1061
  }
655
1062
  case OP_EVENT.person: {
656
1063
  const d = e.data;
657
1064
  if (this.stale(`person:${d.personId}`, e)) return;
1065
+ const known = this.persons.get(d.personId);
658
1066
  this.persons.set(d.personId, {
659
1067
  id: d.personId,
660
- personnelClass: d.personnelClass ?? this.persons.get(d.personId)?.personnelClass,
1068
+ personnelClassIds: d.personnelClassIds ?? known?.personnelClassIds,
661
1069
  status: d.status,
662
1070
  taskId: d.taskId,
663
- ...d.offShift ? { offShift: true } : {}
1071
+ ...d.location ?? known?.location ? { location: d.location ?? known?.location } : {},
1072
+ ...known?.properties ? { properties: known.properties } : {},
1073
+ ...known?.testSpecificationIds ? { testSpecificationIds: known.testSpecificationIds } : {},
1074
+ /* 델타의 `offShift` 는 **선언이 없을 때만** 받는다 — 선언이 있으면 `snapshot()` 이 시각으로
1075
+ 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가 남는다(두 출처 = 갈라짐).
1076
+ 남겨 두는 이유는 캘린더 없는 옛 저널 재생이다. */
1077
+ ...d.offShift && !this.shifts.has(`person:${d.personId}`) ? { offShift: true } : {},
1078
+ ...d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known)
664
1079
  });
665
1080
  break;
666
1081
  }
@@ -670,11 +1085,12 @@ var ObservedReducer = class {
670
1085
  const cur = this.assets.get(d.assetId);
671
1086
  this.assets.set(d.assetId, {
672
1087
  id: d.assetId,
673
- assetClass: d.assetClass ?? cur?.assetClass,
1088
+ assetClassIds: d.assetClassIds ?? cur?.assetClassIds,
674
1089
  location: d.location ?? cur?.location,
675
1090
  status: d.status,
676
1091
  taskId: d.taskId,
677
- ...d.carrying ? { carrying: d.carrying } : {}
1092
+ ...d.carrying ? { carrying: d.carrying } : {},
1093
+ ...d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(cur)
678
1094
  });
679
1095
  this.touchLocation(d.location);
680
1096
  break;
@@ -690,6 +1106,9 @@ var ObservedReducer = class {
690
1106
  requested: d.requested,
691
1107
  fulfilled: d.fulfilled,
692
1108
  ...d.lines?.length ? { lines: d.lines.map((l) => ({ ...l })) } : {},
1109
+ ...d.priority !== void 0 ? { priority: d.priority } : {},
1110
+ ...d.startTime ? { startTime: d.startTime } : {},
1111
+ ...d.endTime ? { endTime: d.endTime } : {},
693
1112
  held: d.held
694
1113
  });
695
1114
  break;
@@ -742,15 +1161,19 @@ var ObservedReducer = class {
742
1161
  return;
743
1162
  }
744
1163
  const loc = ev.readPoint?.id;
745
- const q = ev.quantityList?.[0];
1164
+ const all = ev.quantityList ?? [];
1165
+ const q = all[0];
746
1166
  this.touchLocation(loc);
747
1167
  for (const epc of ev.epcList) {
748
1168
  if (envelope && this.stale(`item:${epc}`, envelope)) continue;
749
- this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q));
1169
+ this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all));
750
1170
  }
751
1171
  if (!ev.epcList?.length) {
752
1172
  for (const qe of ev.quantityList ?? []) {
753
- if (qe?.epcClass) this.items.set(qe.epcClass, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe));
1173
+ if (!qe?.epcClass) continue;
1174
+ const mine = all.filter((x) => x.epcClass === qe.epcClass);
1175
+ const subLotId = `${qe.epcClass}@${loc}`;
1176
+ this.items.set(subLotId, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId));
754
1177
  }
755
1178
  }
756
1179
  }
@@ -773,13 +1196,14 @@ var ObservedReducer = class {
773
1196
  }
774
1197
  return void 0;
775
1198
  }
776
- mergeItem(epc, patch, q) {
777
- const cur = this.items.get(epc);
1199
+ mergeItem(epc, patch, q, all, subLotId) {
1200
+ const cur = this.items.get(subLotId ?? epc);
778
1201
  const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : void 0;
779
1202
  const parsedSelf = parseEpc(epc);
780
1203
  const classUri = q?.epcClass ?? (parsedSelf.instance ? void 0 : epc);
781
1204
  return {
782
1205
  epc,
1206
+ ...subLotId ? { subLotId } : {},
783
1207
  gtin: classUri ?? cur?.gtin,
784
1208
  gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
785
1209
  location: patch.location ?? cur?.location ?? "",
@@ -787,6 +1211,19 @@ var ObservedReducer = class {
787
1211
  parent: cur?.parent ?? this.pendingParent.get(epc),
788
1212
  qty: q?.quantity ?? cur?.qty,
789
1213
  uom: q?.uom ?? cur?.uom,
1214
+ /*
1215
+ * 선언된 수량 전부 — 값이 없는 항목은 담지 않는다(모름을 0 으로 만들지 않는다).
1216
+ *
1217
+ * **한 항목뿐이면 담지 않는다.** 그 하나는 `qty`/`uom` 이 이미 무손실로 들고 있고, 1개짜리
1218
+ * 배열을 더 두면 같은 사실이 두 곳에 생긴다(그리고 시뮬은 그 배열을 만들지 않아 두 구동이
1219
+ * 갈라진다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
1220
+ * 읽을 때는 단위를 가리지 않는 `quantityIn` 을 쓴다(단일이든 복수든 같은 답).
1221
+ */
1222
+ quantities: (() => {
1223
+ const declared = (all ?? []).filter((x) => typeof x.quantity === "number");
1224
+ if (declared.length < 2) return cur?.quantities;
1225
+ return declared.map((x) => ({ value: x.quantity, ...x.uom ? { uom: x.uom } : {} }));
1226
+ })(),
790
1227
  /* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
791
1228
  ilmd: patch.ilmd ?? cur?.ilmd,
792
1229
  expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
@@ -803,16 +1240,57 @@ var ObservedReducer = class {
803
1240
  for (const c of children) this.remove(c);
804
1241
  }
805
1242
  }
806
- /** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
1243
+ /** 현재 투영 State — 자리 점유는 아이템 위치 집계로 유도(pure projection). */
1244
+ /**
1245
+ * 마지막으로 들은 발생 시각(ms) — **관측 구동의 "지금".**
1246
+ *
1247
+ * 시각으로만 바뀌는 사실(납기 초과·유효 기간·교대)을 판정하려면 기준이 필요하고, 관측 모드에는
1248
+ * 굴러가는 시계가 없다. 정직한 기준은 "내가 마지막으로 들은 시점" 이다. 아무것도 못 들었으면
1249
+ * `undefined` — 없는 기준으로 단정하지 않는다.
1250
+ */
1251
+ get lastObservedMs() {
1252
+ return this.observedAtMs;
1253
+ }
1254
+ /** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
1255
+ rememberShift(key, r) {
1256
+ if (!r.workCalendar?.length && !r.window) return;
1257
+ this.shifts.set(key, {
1258
+ ...r.window ? { window: r.window } : {},
1259
+ ...r.workCalendar?.length ? { workCalendar: r.workCalendar } : {}
1260
+ });
1261
+ }
1262
+ /**
1263
+ * 지금 근무 시간 밖인가 — 시뮬과 **같은 함수**(`offCalendarAt`)를 미러의 시각으로 부른다.
1264
+ *
1265
+ * 판정을 델타로 받지 않는 이유는 유효 기간과 같다: 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로,
1266
+ * 유휴 상태로 경계를 지난 자원은 실어 보낸 옛 판정에 머문다. 아무것도 못 들었으면 판정하지 않는다.
1267
+ */
1268
+ offShiftPart(key) {
1269
+ const decl = this.shifts.get(key);
1270
+ if (!decl || this.observedAtMs === void 0) return {};
1271
+ if (offCalendarAt(decl, this.observedAtMs, this.utcOffsetMinutes)) {
1272
+ const why = offCalendarReasonAt(decl, this.observedAtMs, this.utcOffsetMinutes);
1273
+ return { offShift: true, ...why ? { offShiftReason: why } : {} };
1274
+ }
1275
+ const shift = activeShiftAt(decl.workCalendar, this.observedAtMs, this.utcOffsetMinutes);
1276
+ return shift ? { shift } : {};
1277
+ }
1278
+ /** 유효 기간 밖이면 그 이유를 붙인다 — 미러의 시각 기준은 `observedAtMs`(마지막으로 들은 시점). */
1279
+ effectivityPart(r) {
1280
+ if (!r.effectiveStart && !r.effectiveEnd) return {};
1281
+ if (this.observedAtMs === void 0) return {};
1282
+ const e = effectivityAt(r, new Date(this.observedAtMs).toISOString());
1283
+ return e ? { effectivity: e } : {};
1284
+ }
807
1285
  snapshot() {
808
1286
  const occ = /* @__PURE__ */ new Map();
809
1287
  for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
810
1288
  return {
811
1289
  revision: this.revision,
812
1290
  ...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
813
- nodes: [...this.master.values()].map((n) => {
1291
+ locations: [...this.master.values()].map((n) => {
814
1292
  const occupancy = occ.get(n.id) ?? 0;
815
- const status = nodeStatusOf({ occupancy, capacity: n.capacity });
1293
+ const status = locationStatusOf({ occupancy, capacity: n.capacity });
816
1294
  return {
817
1295
  id: n.id,
818
1296
  type: n.type,
@@ -827,10 +1305,12 @@ var ObservedReducer = class {
827
1305
  }),
828
1306
  /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
829
1307
  items: [...this.items.values()].map((i) => ({ ...i })),
830
- persons: [...this.persons.values()].map((p) => ({ ...p })),
831
- assets: [...this.assets.values()].map((a) => ({ ...a })),
1308
+ /* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
1309
+ 만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
1310
+ persons: [...this.persons.values()].map((p) => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`) })),
1311
+ assets: [...this.assets.values()].map((a) => ({ ...a, ...this.effectivityPart(a) })),
832
1312
  tasks: [...this.tasks.values()].map((t) => ({ ...t })),
833
- movers: [...this.movers.values()].map((m) => ({ ...m })),
1313
+ equipment: [...this.equipment.values()].map((m) => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`) })),
834
1314
  orders: [...this.orders.values()].map((o) => ({ ...o }))
835
1315
  };
836
1316
  }
@@ -858,8 +1338,8 @@ var EventJournal = class {
858
1338
  return this.events.slice(revision);
859
1339
  }
860
1340
  /** sim 시각(ISO eventTime) 이하 이벤트. */
861
- untilSimTime(iso) {
862
- return this.events.filter((e) => e.eventTime <= iso);
1341
+ untilSimTime(iso2) {
1342
+ return this.events.filter((e) => e.eventTime <= iso2);
863
1343
  }
864
1344
  };
865
1345
  function replay(board, events) {
@@ -867,6 +1347,16 @@ function replay(board, events) {
867
1347
  for (const e of events) proj.apply(e);
868
1348
  return proj.snapshot();
869
1349
  }
1350
+ function replaySegments(segments) {
1351
+ if (!segments.length) throw new Error("\uC7AC\uC0DD\uD560 \uB9C8\uB514\uAC00 \uC5C6\uB2E4 \u2014 \uAD6C\uC870\uB97C \uD558\uB098\uB3C4 \uC8FC\uC9C0 \uC54A\uC558\uB2E4");
1352
+ const proj = new ObservedReducer(segments[0].board);
1353
+ const shifts = [];
1354
+ for (let i = 0; i < segments.length; i++) {
1355
+ if (i > 0) shifts.push({ index: i, ...proj.adoptStructure(segments[i].board) });
1356
+ for (const e of segments[i].events) proj.apply(e);
1357
+ }
1358
+ return { state: proj.snapshot(), shifts };
1359
+ }
870
1360
 
871
1361
  // src/wms-profile.ts
872
1362
  var BIZSTEP = {
@@ -882,10 +1372,10 @@ var BTT = {
882
1372
  po: "urn:epcglobal:cbv:btt:po",
883
1373
  so: "urn:epcglobal:cbv:btt:so"
884
1374
  };
885
- var WMS_NODE_TYPES = ["dock", "storage", "staging", "dock-ship"];
1375
+ var WMS_LOCATION_TYPES = ["dock", "storage", "staging", "dock-ship"];
886
1376
  var WMS_TYPES = [
887
- ...WMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
888
- { key: "forklift", role: "mover", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
1377
+ ...WMS_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1378
+ { key: "forklift", role: "equipment", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
889
1379
  ];
890
1380
 
891
1381
  // src/capability.ts
@@ -908,7 +1398,7 @@ var CAPABILITIES = {
908
1398
  mobile: {
909
1399
  key: "mobile",
910
1400
  label: "\uC774\uB3D9",
911
- semantics: "\uC790\uC6D0 \uC790\uC2E0\uC774 \uB178\uB4DC \uAC04 \uC774\uB3D9. Transferable(\uC544\uC774\uD15C \uC774\uB3D9)\uACFC \uB2E4\uB984. (\uC52C \uAE30\uC81C: CarrierLine)",
1401
+ semantics: "\uC790\uC6D0 \uC790\uC2E0\uC774 \uC790\uB9AC \uAC04 \uC774\uB3D9. Transferable(\uC544\uC774\uD15C \uC774\uB3D9)\uACFC \uB2E4\uB984. (\uC52C \uAE30\uC81C: CarrierLine)",
912
1402
  stateFields: ["location", "motion"],
913
1403
  models: ["Motion"],
914
1404
  results: ["moved", "motionTick"]
@@ -952,10 +1442,10 @@ var BTT_DELIVERY = "urn:epcglobal:cbv:btt:deliv";
952
1442
  function graiUri(companyPrefix, assetType, serial) {
953
1443
  return `urn:epc:id:grai:${companyPrefix}.${assetType}.${String(serial).padStart(6, "0")}`;
954
1444
  }
955
- var YMS_NODE_TYPES = ["gate", "yard-slot", "dock-door", "staging"];
1445
+ var YMS_LOCATION_TYPES = ["gate", "yard-slot", "dock-door", "staging"];
956
1446
  var YMS_TYPES = [
957
- ...YMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
958
- { key: "hostler", role: "mover", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
1447
+ ...YMS_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1448
+ { key: "hostler", role: "equipment", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
959
1449
  ];
960
1450
 
961
1451
  // src/mes-profile.ts
@@ -971,8 +1461,8 @@ var BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
971
1461
  function sgtinUri(companyPrefix, itemRef, serial) {
972
1462
  return `urn:epc:id:sgtin:${companyPrefix}.${itemRef}.${serial}`;
973
1463
  }
974
- var MES_NODE_TYPES = ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
975
- var MES_NODE_CLS = {
1464
+ var MES_LOCATION_TYPES = ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
1465
+ var MES_LOCATION_CLS = {
976
1466
  "raw-store": { epcis: "bizLocation" },
977
1467
  "cut-station": { isa95: "WorkCenter", epcis: "bizLocation" },
978
1468
  "weld-station": { isa95: "WorkCenter", epcis: "bizLocation" },
@@ -981,20 +1471,20 @@ var MES_NODE_CLS = {
981
1471
  "fg-store": { epcis: "bizLocation" }
982
1472
  };
983
1473
  var MES_TYPES = [
984
- ...MES_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: MES_NODE_CLS[k] ?? {}, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
985
- { key: "cutter", role: "mover", label: "twin.type.cutter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
986
- { key: "welder", role: "mover", label: "twin.type.welder", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
987
- { key: "painter", role: "mover", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
988
- { key: "assembler", role: "mover", label: "twin.type.assembler", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
1474
+ ...MES_LOCATION_TYPES.map((k) => ({ key: k, role: "location", label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
1475
+ { key: "cutter", role: "equipment", label: "twin.type.cutter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1476
+ { key: "welder", role: "equipment", label: "twin.type.welder", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1477
+ { key: "painter", role: "equipment", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
1478
+ { key: "assembler", role: "equipment", label: "twin.type.assembler", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
989
1479
  ];
990
1480
 
991
1481
  // src/domain-catalog.ts
992
- var nodeKeys = (types) => types.filter((t) => t.role === "node").map((t) => t.key);
1482
+ var locationKeys = (types) => types.filter((t) => t.role === "location").map((t) => t.key);
993
1483
  var DOMAIN_CATALOG = {
994
1484
  // label 은 언어 중립 i18n 키(twin.system.<code>) — 사람 언어는 표현계층이 렌더(L2).
995
- wms: { system: "wms", label: "twin.system.wms", types: WMS_TYPES, nodeTypes: nodeKeys(WMS_TYPES) },
996
- yms: { system: "yms", label: "twin.system.yms", types: YMS_TYPES, nodeTypes: nodeKeys(YMS_TYPES) },
997
- mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, nodeTypes: nodeKeys(MES_TYPES) }
1485
+ wms: { system: "wms", label: "twin.system.wms", types: WMS_TYPES, locationTypes: locationKeys(WMS_TYPES) },
1486
+ yms: { system: "yms", label: "twin.system.yms", types: YMS_TYPES, locationTypes: locationKeys(YMS_TYPES) },
1487
+ mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) }
998
1488
  };
999
1489
  var DOMAIN_SYSTEMS = ["wms", "yms", "mes"];
1000
1490
  function capabilitiesForType(system, typeKey) {
@@ -1083,8 +1573,8 @@ function foldTaskRecords(rows) {
1083
1573
  if (f.resource === void 0 && d.resourceRef) f.resource = d.resourceRef;
1084
1574
  if (f.kind === void 0 && d.kind) f.kind = d.kind;
1085
1575
  if (f.order === void 0 && d.orderId) f.order = d.orderId;
1086
- const node = d.toNode ?? d.fromNode;
1087
- if (node) f.node = node;
1576
+ const loc = d.toNode ?? d.fromNode;
1577
+ if (loc) f.location = loc;
1088
1578
  if (d.status === "created") rec.createdMs = at;
1089
1579
  else if (d.status === "in-progress") rec.startedMs = rec.startedMs != null ? Math.min(rec.startedMs, at) : at;
1090
1580
  else if (d.status === "completed") rec.completedMs = rec.completedMs != null ? Math.max(rec.completedMs, at) : at;
@@ -1092,6 +1582,46 @@ function foldTaskRecords(rows) {
1092
1582
  return { records: [...byTask.values()], rowsRead, latestMs };
1093
1583
  }
1094
1584
 
1585
+ // src/job-response.ts
1586
+ var iso = (ms2) => typeof ms2 === "number" ? new Date(ms2).toISOString() : void 0;
1587
+ var dataOf = (r) => {
1588
+ const p = r.payload;
1589
+ return r.data ?? p?.data ?? p ?? {};
1590
+ };
1591
+ function foldJobResponses(rows) {
1592
+ const { records } = foldTaskRecords(rows);
1593
+ const extra = /* @__PURE__ */ new Map();
1594
+ for (const r of rows) {
1595
+ const d = dataOf(r);
1596
+ const id = typeof d.taskId === "string" ? d.taskId : void 0;
1597
+ if (!id) continue;
1598
+ const cur = extra.get(id) ?? {};
1599
+ if (Array.isArray(d.personnel)) cur.personnel = d.personnel;
1600
+ if (Array.isArray(d.resources)) cur.resources = d.resources;
1601
+ else if (typeof d.resourceRef === "string" && !cur.resources) cur.resources = [d.resourceRef];
1602
+ if (Array.isArray(d.assets)) cur.assets = d.assets;
1603
+ if (Array.isArray(d.materialActual)) cur.material = d.materialActual;
1604
+ extra.set(id, cur);
1605
+ }
1606
+ return records.map((rec) => {
1607
+ const x = extra.get(rec.taskId) ?? {};
1608
+ const jobState = rec.completedMs !== void 0 ? "completed" : rec.startedMs !== void 0 ? "running" : "ready";
1609
+ const one = (ids) => ids?.length ? ids.map((id) => ({ id, quantity: 1 })) : void 0;
1610
+ return {
1611
+ id: rec.taskId,
1612
+ ...rec.facets.order ? { jobOrderId: rec.facets.order } : {},
1613
+ ...rec.facets.kind ? { workType: rec.facets.kind } : {},
1614
+ jobState,
1615
+ ...iso(rec.startedMs) ? { startTime: iso(rec.startedMs) } : {},
1616
+ ...iso(rec.completedMs) ? { endTime: iso(rec.completedMs) } : {},
1617
+ ...one(x.personnel) ? { personnelActual: one(x.personnel) } : {},
1618
+ ...one(x.resources) ? { equipmentActual: one(x.resources) } : {},
1619
+ ...one(x.assets) ? { physicalAssetActual: one(x.assets) } : {},
1620
+ ...x.material?.length ? { materialActual: x.material.map((m) => ({ ...m })) } : {}
1621
+ };
1622
+ });
1623
+ }
1624
+
1095
1625
  // src/face2-adapter.ts
1096
1626
  function get(obj, path) {
1097
1627
  return path.split(".").reduce((o, k) => o == null ? o : o[k], obj);
@@ -1192,8 +1722,108 @@ var TwinRuntime = class {
1192
1722
  }
1193
1723
  };
1194
1724
 
1725
+ // src/capacity.ts
1726
+ function isoDurationHours(iso2) {
1727
+ if (!iso2) return 0;
1728
+ const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/.exec(iso2);
1729
+ if (!m) throw new Error(`\uAE30\uAC04 \uD45C\uAE30\uB97C \uC77D\uC744 \uC218 \uC5C6\uB2E4: ${iso2}`);
1730
+ return Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0) + Number(m[3] ?? 0) / 60 + Number(m[4] ?? 0) / 3600;
1731
+ }
1732
+ function paramOf(op, id) {
1733
+ return op.parameters?.find((p) => p.id === id)?.value;
1734
+ }
1735
+ function workingTimeOfWeek(calendar, weekStartMs, utcOffsetMinutes) {
1736
+ if (!calendar?.length) return { hoursPerWeek: 7 * 24, daysPerWeek: 7 };
1737
+ let minutes = 0;
1738
+ const touched = /* @__PURE__ */ new Set();
1739
+ for (let m = 0; m < 7 * 24 * 60; m++) {
1740
+ if (!inWorkCalendarAt(calendar, weekStartMs + m * 6e4, utcOffsetMinutes)) continue;
1741
+ minutes++;
1742
+ touched.add(Math.floor((m + (utcOffsetMinutes ?? 0)) / (24 * 60)));
1743
+ }
1744
+ const declared = /* @__PURE__ */ new Set();
1745
+ for (const e of calendar) if (e.entryType !== "non-working") for (const d of e.daysOfWeek ?? []) declared.add(d);
1746
+ return { hoursPerWeek: minutes / 60, daysPerWeek: declared.size || touched.size };
1747
+ }
1748
+ function availabilityOf(records) {
1749
+ const ratios = records.map((r) => r.mtbfMs && r.mttrMs ? r.mtbfMs / (r.mtbfMs + r.mttrMs) : 1);
1750
+ return ratios.length ? ratios.reduce((a, b) => a + b, 0) / ratios.length : 1;
1751
+ }
1752
+ function analyzeCapacity(input) {
1753
+ const { hoursPerWeek, daysPerWeek } = workingTimeOfWeek(input.calendar, input.sampleWeekStartMs, input.utcOffsetMinutes);
1754
+ const demandPerHour = hoursPerWeek > 0 ? input.unitsPerDay * daysPerWeek / hoursPerWeek : 0;
1755
+ const equipmentByKind = /* @__PURE__ */ new Map();
1756
+ for (const e of input.equipment ?? []) {
1757
+ const list = equipmentByKind.get(e.kind) ?? [];
1758
+ list.push(e);
1759
+ equipmentByKind.set(e.kind, list);
1760
+ }
1761
+ const countByClass = (rows, field) => {
1762
+ const out = /* @__PURE__ */ new Map();
1763
+ for (const r of rows ?? []) for (const c of r[field] ?? []) out.set(c, (out.get(c) ?? 0) + 1);
1764
+ return out;
1765
+ };
1766
+ const personsByClass = countByClass(input.persons, "personnelClassIds");
1767
+ const assetsByClass = countByClass(input.assets, "assetClassIds");
1768
+ const slotsByLocationType = /* @__PURE__ */ new Map();
1769
+ for (const l of input.locations ?? [])
1770
+ if (l.type) slotsByLocationType.set(l.type, (slotsByLocationType.get(l.type) ?? 0) + Math.max(l.capacity ?? 1, 1));
1771
+ const order = input.route?.length ? input.route.map((k) => input.operations.find((o) => o.key === k)).filter((o) => !!o) : [...input.operations];
1772
+ let downstream = 1;
1773
+ const reversed = [];
1774
+ for (const op of [...order].reverse()) {
1775
+ const cycleHours = isoDurationHours(op.duration) + isoDurationHours(paramOf(op, "setupDuration"));
1776
+ downstream *= Number(paramOf(op, "yield") ?? 1);
1777
+ const requiredPerHour = downstream > 0 ? demandPerHour / downstream : Infinity;
1778
+ const requirements = [];
1779
+ const add = (axis, className, quantity, available, availability) => {
1780
+ const q = Math.max(quantity, 1);
1781
+ requirements.push({
1782
+ axis,
1783
+ className,
1784
+ quantity: q,
1785
+ available,
1786
+ availability,
1787
+ perHour: cycleHours > 0 ? Math.floor(available / q) / cycleHours * availability : Infinity
1788
+ });
1789
+ };
1790
+ const equipSpecs = op.equipmentSpecification?.length ? op.equipmentSpecification.map((s) => ({ className: s.equipmentClass ?? op.resourceType ?? "", quantity: s.quantity })) : op.resourceType ? [{ className: op.resourceType, quantity: 1 }] : [];
1791
+ for (const s of equipSpecs) {
1792
+ if (!s.className) continue;
1793
+ const records = equipmentByKind.get(s.className) ?? [];
1794
+ add("equipment", s.className, s.quantity, records.length, availabilityOf(records));
1795
+ }
1796
+ for (const s of op.personnelSpecification ?? [])
1797
+ if (s.personnelClass) add("personnel", s.personnelClass, s.quantity, personsByClass.get(s.personnelClass) ?? 0, 1);
1798
+ for (const s of op.physicalAssetSpecification ?? [])
1799
+ if (s.assetClass) add("asset", s.assetClass, s.quantity, assetsByClass.get(s.assetClass) ?? 0, 1);
1800
+ if (op.locationType) add("location", op.locationType, 1, slotsByLocationType.get(op.locationType) ?? 0, 1);
1801
+ const constraint = requirements.length ? requirements.reduce((a, b) => a.perHour <= b.perHour ? a : b) : void 0;
1802
+ const perHour = constraint ? constraint.perHour : Infinity;
1803
+ reversed.push({ operation: op.key, cycleHours, perHour, requiredPerHour, ok: perHour >= requiredPerHour, constraint, requirements });
1804
+ }
1805
+ const operations = reversed.reverse();
1806
+ const tightest = operations.length ? operations.reduce((a, b) => a.perHour / a.requiredPerHour <= b.perHour / b.requiredPerHour ? a : b) : void 0;
1807
+ const headroom = operations.length ? Math.min(...operations.map((o) => o.perHour / o.requiredPerHour)) : Infinity;
1808
+ return {
1809
+ workingHoursPerWeek: hoursPerWeek,
1810
+ workingDaysPerWeek: daysPerWeek,
1811
+ demandPerHour,
1812
+ operations,
1813
+ ...tightest ? { bottleneck: { operation: tightest.operation, axis: tightest.constraint?.axis, className: tightest.constraint?.className } } : {},
1814
+ maxUnitsPerDay: input.unitsPerDay * headroom,
1815
+ ok: operations.every((o) => o.ok)
1816
+ };
1817
+ }
1818
+
1195
1819
  // src/flow-engine.ts
1196
1820
  var BASE_EPOCH = Date.parse("2026-01-01T00:00:00Z");
1821
+ function effectiveOnly(r) {
1822
+ return {
1823
+ ...r.effectiveStart ? { effectiveStart: r.effectiveStart } : {},
1824
+ ...r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {}
1825
+ };
1826
+ }
1197
1827
  function mulberry32(seed) {
1198
1828
  let a = seed >>> 0;
1199
1829
  const fn = () => {
@@ -1207,16 +1837,16 @@ function mulberry32(seed) {
1207
1837
  } });
1208
1838
  return fn;
1209
1839
  }
1210
- function deriveAttentions(view, acked) {
1840
+ function deriveAttentions(view, acked, nowIso) {
1211
1841
  const out = [];
1212
- for (const m of view.movers) {
1842
+ for (const m of view.equipment) {
1213
1843
  if (m.status === "down") {
1214
1844
  out.push({
1215
1845
  id: `breakdown:${m.id}`,
1216
1846
  kind: "breakdown",
1217
1847
  severity: "critical",
1218
- anchor: { moverId: m.id, nodeId: m.location },
1219
- params: { moverId: m.id, ...m.location ? { nodeId: m.location } : {} },
1848
+ anchor: { moverId: m.id, locationId: m.location },
1849
+ params: { moverId: m.id, ...m.location ? { locationId: m.location } : {} },
1220
1850
  recommendedActions: [
1221
1851
  { code: "act.repair", command: CMD.resourceRepair, args: { resourceId: m.id } },
1222
1852
  { code: "act.hold-until-repair", command: CMD.resourceHold, args: { resourceId: m.id } }
@@ -1225,7 +1855,7 @@ function deriveAttentions(view, acked) {
1225
1855
  });
1226
1856
  }
1227
1857
  }
1228
- for (const n of view.nodes) {
1858
+ for (const n of view.locations) {
1229
1859
  if ((n.capacity ?? 0) > 0) {
1230
1860
  const r = (n.occupancy ?? 0) / n.capacity;
1231
1861
  if (r >= 0.9) {
@@ -1234,15 +1864,39 @@ function deriveAttentions(view, acked) {
1234
1864
  id: `bottleneck:${n.id}`,
1235
1865
  kind: "bottleneck",
1236
1866
  severity: saturated ? "high" : "medium",
1237
- anchor: { nodeId: n.id },
1238
- params: { nodeId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
1867
+ anchor: { locationId: n.id },
1868
+ params: { locationId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
1239
1869
  recommendedActions: [{ code: "advice.add-resource" }, { code: "advice.downstream-priority" }]
1240
1870
  // 권고만(command 없음) — 표현계층이 advice.* 코드로 렌더
1241
1871
  });
1242
1872
  }
1243
1873
  }
1244
1874
  }
1245
- for (const m of view.movers) {
1875
+ const QUEUE_FLOOR = 5;
1876
+ const shutdown = (view.equipment?.length ?? 0) > 0 && view.equipment.every((m) => m.offShift);
1877
+ if (view.tasks?.length && !shutdown) {
1878
+ const waiting = /* @__PURE__ */ new Map();
1879
+ const working = /* @__PURE__ */ new Map();
1880
+ for (const t of view.tasks) {
1881
+ const bucket = t.status === "created" || t.status === "assigned" ? waiting : t.status === "in-progress" ? working : void 0;
1882
+ if (bucket) bucket.set(t.kind, (bucket.get(t.kind) ?? 0) + 1);
1883
+ }
1884
+ for (const [operation, queued] of waiting) {
1885
+ if (queued < QUEUE_FLOOR) continue;
1886
+ const active = working.get(operation) ?? 0;
1887
+ const ratio = queued / Math.max(active, 1);
1888
+ if (active > 0 && ratio < 3) continue;
1889
+ out.push({
1890
+ id: `work-backlog:${operation}`,
1891
+ kind: "work-backlog",
1892
+ severity: active === 0 || ratio >= 6 ? "high" : "medium",
1893
+ anchor: { operation },
1894
+ params: { operation, queued, active, ratio: Math.round(ratio * 10) / 10 },
1895
+ recommendedActions: [{ code: "advice.add-resource" }, { code: "advice.downstream-priority" }]
1896
+ });
1897
+ }
1898
+ }
1899
+ for (const m of view.equipment) {
1246
1900
  const total = (m.goodCount ?? 0) + (m.scrapCount ?? 0);
1247
1901
  if (total >= 10) {
1248
1902
  const rate = (m.scrapCount ?? 0) / total;
@@ -1250,7 +1904,7 @@ function deriveAttentions(view, acked) {
1250
1904
  id: `scrap:${m.id}`,
1251
1905
  kind: "scrap-high",
1252
1906
  severity: rate >= 0.3 ? "high" : "medium",
1253
- anchor: { moverId: m.id, nodeId: m.location },
1907
+ anchor: { moverId: m.id, locationId: m.location },
1254
1908
  params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
1255
1909
  recommendedActions: [
1256
1910
  { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } },
@@ -1271,6 +1925,21 @@ function deriveAttentions(view, acked) {
1271
1925
  suggestedAction: { code: "act.resume-order", command: CMD.orderResume, args: { orderId: o.id } }
1272
1926
  });
1273
1927
  }
1928
+ for (const o of view.orders) {
1929
+ if (o.status === "completed" || o.status === "fulfilled") continue;
1930
+ if (dueStatusOf(o, nowIso) !== "late") continue;
1931
+ const overdueMs = Date.parse(nowIso) - Date.parse(o.endTime);
1932
+ out.push({
1933
+ id: `late:${o.id}`,
1934
+ kind: "order-late",
1935
+ severity: overdueMs >= 36e5 ? "critical" : "high",
1936
+ anchor: { orderId: o.id },
1937
+ params: { orderId: o.id, endTime: o.endTime, overdueMs, ...o.priority !== void 0 ? { priority: o.priority } : {} },
1938
+ /* 조치는 권고만 둔다 — 지연을 커널이 스스로 해소할 수단이 없다(자원을 늘리거나 우선순위를
1939
+ 올리는 것은 사람의 결정이다). 없는 조치를 만들어 붙이지 않는다. */
1940
+ recommendedActions: [{ code: "advice.raise-priority" }, { code: "advice.add-resource" }]
1941
+ });
1942
+ }
1274
1943
  if (acked) {
1275
1944
  for (const a of out) if (acked.has(a.id)) a.state = "acknowledged";
1276
1945
  }
@@ -1298,9 +1967,16 @@ function computeOee(c, nowMs) {
1298
1967
  }
1299
1968
  var FlowEngine = class {
1300
1969
  tenantId;
1301
- nodes = /* @__PURE__ */ new Map();
1970
+ locations = /* @__PURE__ */ new Map();
1302
1971
  items = /* @__PURE__ */ new Map();
1303
- movers = /* @__PURE__ */ new Map();
1972
+ equipment = /* @__PURE__ */ new Map();
1973
+ /** 등급 정의(표준 `<X>Class`) — 상속·유효기간 판정의 재료. 선언 안 하면 비어 있고, 소속 그대로 판정한다. */
1974
+ classDefs = {};
1975
+ /**
1976
+ * 품목 정의 색인(표준 `MaterialDefinition`) — 단위 환산의 **유일한 근거**.
1977
+ * 비어 있으면 환산하지 않는다(계수를 모르는데 값을 만들지 않는다).
1978
+ */
1979
+ materialDefs = /* @__PURE__ */ new Map();
1304
1980
  /** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
1305
1981
  persons = /* @__PURE__ */ new Map();
1306
1982
  /** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
@@ -1347,88 +2023,191 @@ var FlowEngine = class {
1347
2023
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
1348
2024
  loadBoard(def) {
1349
2025
  this.boardDef = def;
1350
- for (const n of def.nodes) this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId });
1351
- for (const p of def.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null, window: p.window });
1352
- for (const a of def.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle", taskId: null });
1353
- for (const m of def.movers) {
1354
- 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 };
2026
+ for (const n of readBoardLocations(def)) this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId });
2027
+ this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
2028
+ this.materialDefs = new Map((def.materialDefinitions ?? []).filter((d) => d?.id).map((d) => [d.id, d]));
2029
+ for (const p of def.persons ?? []) 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) });
2030
+ for (const a of readBoardAssets(def)) 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) });
2031
+ for (const m of readBoardEquipment(def)) {
2032
+ 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) };
1355
2033
  if (m.mtbfMs !== void 0) {
1356
- mover.mtbfMs = m.mtbfMs;
1357
- mover.mttrMs = m.mttrMs;
1358
- mover.nextFailureMs = this.sampleExp(m.mtbfMs);
2034
+ eq.mtbfMs = m.mtbfMs;
2035
+ eq.mttrMs = m.mttrMs;
2036
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
1359
2037
  }
1360
- this.movers.set(m.id, mover);
2038
+ this.equipment.set(m.id, eq);
1361
2039
  }
1362
2040
  }
1363
2041
  /**
1364
- * what-if 구성 변주 — fork(또는 실행 중) 엔진에 무버 추가. loadBoard 무버 삽입과 동일 규약.
2042
+ * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadBoard 설비 삽입과 동일 규약.
1365
2043
  * 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
1366
2044
  */
1367
- addMover(m) {
1368
- if (this.movers.has(m.id)) return;
1369
- 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 };
2045
+ addEquipment(m) {
2046
+ if (this.equipment.has(m.id)) return;
2047
+ 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 };
1370
2048
  if (m.mtbfMs !== void 0) {
1371
- mover.mtbfMs = m.mtbfMs;
1372
- mover.mttrMs = m.mttrMs;
1373
- mover.nextFailureMs = this.sampleExp(m.mtbfMs);
2049
+ eq.mtbfMs = m.mtbfMs;
2050
+ eq.mttrMs = m.mttrMs;
2051
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
1374
2052
  }
1375
- this.movers.set(m.id, mover);
2053
+ this.equipment.set(m.id, eq);
1376
2054
  }
1377
2055
  /**
1378
- * 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(재고·무버·노드)과
2056
+ * 관측 상태 주입(라이브 예측용, kernel-unification P1) — 외부 관측 스냅샷(재고·설비·자리)과
1379
2057
  * 저널 오더(원값+라인)로 이 커널의 맵을 채운다. tick 으로 만든 게 아니라 "현재 관측된 현실"을 심어
1380
2058
  * 이후 fork/tick 으로 예측한다. 라이브 런타임은 여전히 projector 미러 — 이 커널은 예측용 임시본.
1381
2059
  * 오더는 남은 데맨드(라인별 requested-fulfilled)를 'created' 로 복원(현재 재고에서 재계획).
1382
2060
  * 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
1383
2061
  */
2062
+ /**
2063
+ * **이어서 센다** — 재기동 뒤 저널을 이어 쓸 때.
2064
+ *
2065
+ * 커널은 리비전을 0부터 센다. 새로 시작하는 우주라면 맞지만, **이어지는 현실**(sim-world)에서는
2066
+ * 그 번호가 이미 저널에 있는 행과 겹친다 — 같은 트윈에 revision 7이 두 개 생기고, 재생 순서가
2067
+ * 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다. 조용히 깨지는 종류다(오류가 안 난다).
2068
+ *
2069
+ * 그래서 이어 쓰기 전에 마지막 번호를 알려 준다. **뒤로는 못 간다** — 뒤로 가는 것은 곧 겹치는
2070
+ * 것이고, 그것을 허용하면 이 함수가 막으려던 일이 이 함수를 통해 일어난다.
2071
+ */
2072
+ resumeRevision(from) {
2073
+ if (!Number.isFinite(from) || from < 0) throw new Error(`\uC774\uC5B4 \uC140 \uB9AC\uBE44\uC804\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4: ${from}`);
2074
+ if (from < this.revision) throw new Error(`\uB9AC\uBE44\uC804\uC744 \uB4A4\uB85C \uB418\uB3CC\uB9B4 \uC218 \uC5C6\uB2E4: ${this.revision} \u2192 ${from} (\uACB9\uCE58\uB294 \uBC88\uD638\uAC00 \uC0DD\uAE34\uB2E4)`);
2075
+ this.revision = from;
2076
+ }
1384
2077
  hydrateObserved(snap, orders = []) {
1385
- for (const n of snap.nodes) {
1386
- 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 });
2078
+ for (const n of snap.locations) {
2079
+ 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 });
1387
2080
  }
1388
2081
  this.items.clear();
1389
2082
  for (const it of snap.items) {
1390
- this.items.set(it.epc, {
2083
+ this.items.set(itemKeyOf(it), {
1391
2084
  /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
1392
2085
  epc: it.epc,
1393
2086
  location: it.location,
1394
2087
  disposition: it.disposition ?? DISP.sellable,
2088
+ ...it.subLotId ? { subLotId: it.subLotId } : {},
2089
+ ...it.definitionId ? { definitionId: it.definitionId } : {},
1395
2090
  gtin: it.gtin,
1396
2091
  qty: it.qty ?? 1,
1397
2092
  uom: it.uom,
2093
+ ...it.quantities?.length ? { quantities: it.quantities } : {},
1398
2094
  parent: it.parent,
1399
2095
  carriedBy: it.carriedBy,
1400
2096
  expiry: it.expiry,
1401
2097
  ilmd: it.ilmd
1402
2098
  });
1403
2099
  }
1404
- for (const m of snap.movers) {
2100
+ for (const m of snap.equipment) {
1405
2101
  const oee = m.oee;
1406
- this.movers.set(m.id, {
2102
+ const prev = this.equipment.get(m.id);
2103
+ this.equipment.set(m.id, {
2104
+ ...prev ?? {},
1407
2105
  id: m.id,
1408
2106
  kind: m.kind,
1409
2107
  location: m.location ?? "",
2108
+ ...m.homeLocation ? { homeLocation: m.homeLocation } : {},
1410
2109
  status: m.status ?? "idle",
1411
2110
  taskId: null,
1412
2111
  runMs: oee?.runMs ?? 0,
1413
2112
  setupMs: oee?.setupMs ?? 0,
1414
2113
  downMs: oee?.downMs ?? 0,
1415
2114
  goodCount: oee?.goodCount ?? 0,
1416
- scrapCount: oee?.scrapCount ?? 0
2115
+ scrapCount: oee?.scrapCount ?? 0,
2116
+ /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 굴린다
2117
+ (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
2118
+ ...m.held ? { held: true } : {},
2119
+ /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
2120
+ ...m.properties ? { properties: m.properties } : {},
2121
+ ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {},
2122
+ ...effectiveOnly(m)
1417
2123
  });
1418
2124
  }
1419
2125
  for (const p of snap.persons ?? []) {
1420
- this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null });
2126
+ const prev = this.persons.get(p.id);
2127
+ this.persons.set(p.id, {
2128
+ ...prev ?? {},
2129
+ id: p.id,
2130
+ personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
2131
+ status: "idle",
2132
+ taskId: null,
2133
+ ...p.location ? { location: p.location } : {},
2134
+ ...p.properties ? { properties: p.properties } : {},
2135
+ ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
2136
+ ...effectiveOnly(p)
2137
+ });
1421
2138
  }
1422
2139
  for (const a of snap.assets ?? []) {
1423
- this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.location, status: "idle", taskId: null, carrying: a.carrying });
2140
+ const prev = this.assets.get(a.id);
2141
+ this.assets.set(a.id, {
2142
+ ...prev ?? {},
2143
+ id: a.id,
2144
+ assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
2145
+ location: a.location,
2146
+ status: "idle",
2147
+ taskId: null,
2148
+ carrying: a.carrying,
2149
+ ...a.properties ? { properties: a.properties } : {},
2150
+ ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
2151
+ ...effectiveOnly(a)
2152
+ });
2153
+ }
2154
+ const observedOrders = orders.length ? orders : (snap.orders ?? []).filter((o) => o.requested !== void 0).map((o) => ({
2155
+ orderId: o.id,
2156
+ kind: o.kind,
2157
+ status: o.status,
2158
+ requested: o.requested,
2159
+ fulfilled: o.fulfilled ?? 0,
2160
+ held: o.held,
2161
+ lines: o.lines,
2162
+ /* 약속한 시각과 우선순위를 함께 옮긴다 — 없으면 관측 커널이 **"늦었나" 를 판단할 재료가
2163
+ 없다.** 라이브 트윈이 납기 초과를 한 번도 보고하지 못한 원인 중 하나였다. */
2164
+ ...o.endTime ? { endTime: o.endTime } : {},
2165
+ ...o.startTime ? { startTime: o.startTime } : {},
2166
+ ...o.priority !== void 0 ? { priority: o.priority } : {}
2167
+ }));
2168
+ for (const o of observedOrders) {
2169
+ const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
2170
+ const remaining = lines.length ? lines.reduce((s, l) => s + l.requested, 0) : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
2171
+ if (remaining <= 0) continue;
2172
+ this.orders.set(o.orderId, {
2173
+ id: o.orderId,
2174
+ kind: o.kind,
2175
+ status: "created",
2176
+ requested: remaining,
2177
+ fulfilled: 0,
2178
+ bizTransaction: "",
2179
+ allocated: [],
2180
+ picked: [],
2181
+ shipmentEpc: null,
2182
+ lines,
2183
+ /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
2184
+ 씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
2185
+ ...o.held ? { held: true } : {},
2186
+ ...o.endTime ? { endTime: o.endTime } : {},
2187
+ ...o.startTime ? { startTime: o.startTime } : {},
2188
+ ...o.priority !== void 0 ? { priority: o.priority } : {}
2189
+ });
1424
2190
  }
2191
+ const seededOrderIds = new Set(this.orders.keys());
2192
+ let orphaned = 0;
1425
2193
  for (const t of snap.tasks ?? []) {
1426
2194
  if (t.status === "completed") continue;
2195
+ const ref = t.itemRefs?.[0];
2196
+ if (ref && !this.itemByRef(ref)) {
2197
+ orphaned++;
2198
+ continue;
2199
+ }
2200
+ if (t.orderId && !seededOrderIds.has(t.orderId)) {
2201
+ orphaned++;
2202
+ continue;
2203
+ }
1427
2204
  const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
1428
2205
  this.tasks.set(t.id, {
1429
2206
  id: t.id,
1430
2207
  kind: t.kind,
1431
2208
  status: known && t.status === "in-progress" ? "in-progress" : "created",
2209
+ /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
2210
+ ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
1432
2211
  itemEpc: t.itemRefs?.[0] ?? "",
1433
2212
  fromNode: t.fromNode ?? "",
1434
2213
  toNode: t.toNode ?? "",
@@ -1440,7 +2219,7 @@ var FlowEngine = class {
1440
2219
  intent: t.intent
1441
2220
  });
1442
2221
  if (known && t.status === "in-progress" && t.resourceRef) {
1443
- const mv = this.movers.get(t.resourceRef);
2222
+ const mv = this.equipment.get(t.resourceRef);
1444
2223
  if (mv) {
1445
2224
  mv.status = "busy";
1446
2225
  mv.taskId = t.id;
@@ -1458,17 +2237,15 @@ var FlowEngine = class {
1458
2237
  }
1459
2238
  }
1460
2239
  }
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) {
1463
- const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
1464
- const remaining = lines.reduce((s, l) => s + l.requested, 0);
1465
- if (remaining <= 0) continue;
1466
- this.orders.set(o.orderId, { id: o.orderId, kind: o.kind, status: "created", requested: remaining, fulfilled: 0, bizTransaction: "", allocated: [], picked: [], shipmentEpc: null, lines });
2240
+ if (orphaned) {
2241
+ console.warn(
2242
+ `[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state \u2014 they cannot be continued (the item was consumed/shipped, or the observation did not carry it).`
2243
+ );
1467
2244
  }
1468
2245
  }
1469
- /** what-if 구성 변주 — 노드 용량 변경(fork 대상). 존재하면 true. */
1470
- setNodeCapacity(nodeId, capacity) {
1471
- const n = this.nodes.get(nodeId);
2246
+ /** what-if 구성 변주 — 자리 용량 변경(fork 대상). 존재하면 true. */
2247
+ setLocationCapacity(locationId, capacity) {
2248
+ const n = this.locations.get(locationId);
1472
2249
  if (!n) return false;
1473
2250
  n.capacity = Math.max(0, capacity);
1474
2251
  return true;
@@ -1494,6 +2271,15 @@ var FlowEngine = class {
1494
2271
  * State 델타를 유발한다(command → 행위 → 관측 폐루프).
1495
2272
  */
1496
2273
  _acked = /* @__PURE__ */ new Set();
2274
+ /**
2275
+ * 완료 시점에 **주체가 사라져 접은 작업 수** — 조용한 누락이 되지 않게 센다.
2276
+ *
2277
+ * 물품이 포장·출하·소비로 없어지는 것은 정상이지만, 그 물품을 향한 작업이 남아 있었다는 것은
2278
+ * 상류에 어긋남이 있다는 신호다. 0 이 아니면 그 사실을 소비처가 볼 수 있어야 한다.
2279
+ */
2280
+ abandonedTasks = 0;
2281
+ /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
2282
+ _attentionSince = /* @__PURE__ */ new Map();
1497
2283
  // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
1498
2284
  dispatch(cmd) {
1499
2285
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
@@ -1513,39 +2299,39 @@ var FlowEngine = class {
1513
2299
  if (id) this._acked.add(id);
1514
2300
  return ok();
1515
2301
  }
1516
- // Operable 코어 — 자원(설비·무버) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
2302
+ // Operable 코어 — 자원(설비·설비) 제어. capability-keyed(resourceId), 모든 operable 자원 공통.
1517
2303
  case CMD.resourceHold:
1518
2304
  case CMD.resourceResume: {
1519
- const m = this.movers.get(cmd.args?.resourceId ?? "");
2305
+ const m = this.equipment.get(cmd.args?.resourceId ?? "");
1520
2306
  if (!m) return fail("resource-not-found");
1521
2307
  m.held = cmd.type === CMD.resourceHold;
1522
- this.emitMover(m);
2308
+ this.emitEquipment(m);
1523
2309
  return ok();
1524
2310
  }
1525
2311
  case CMD.resourceDown: {
1526
2312
  const a = cmd.args;
1527
- const m = this.movers.get(a?.resourceId ?? "");
2313
+ const m = this.equipment.get(a?.resourceId ?? "");
1528
2314
  if (!m) return fail("resource-not-found");
1529
2315
  if (m.status !== "down") {
1530
2316
  m.status = "down";
1531
2317
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 6e4);
1532
- this.emitMover(m);
2318
+ this.emitEquipment(m);
1533
2319
  }
1534
2320
  return ok();
1535
2321
  }
1536
2322
  case CMD.resourceRepair: {
1537
- const m = this.movers.get(cmd.args?.resourceId ?? "");
2323
+ const m = this.equipment.get(cmd.args?.resourceId ?? "");
1538
2324
  if (!m) return fail("resource-not-found");
1539
2325
  if (m.status === "down") {
1540
2326
  m.status = m.taskId ? "busy" : "idle";
1541
2327
  m.repairUntilMs = void 0;
1542
2328
  if (m.mtbfMs !== void 0) m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
1543
- this.emitMover(m);
2329
+ this.emitEquipment(m);
1544
2330
  }
1545
2331
  return ok();
1546
2332
  }
1547
2333
  case CMD.resourceResetMetrics: {
1548
- const m = this.movers.get(cmd.args?.resourceId ?? "");
2334
+ const m = this.equipment.get(cmd.args?.resourceId ?? "");
1549
2335
  if (!m) return fail("resource-not-found");
1550
2336
  m.runMs = 0;
1551
2337
  m.setupMs = 0;
@@ -1554,21 +2340,21 @@ var FlowEngine = class {
1554
2340
  m.scrapCount = 0;
1555
2341
  m.holdMs = 0;
1556
2342
  m.metricsSinceMs = this.clockMs;
1557
- this.emitMover(m);
2343
+ this.emitEquipment(m);
1558
2344
  return ok();
1559
2345
  }
1560
2346
  case CMD.resourceAdd: {
1561
2347
  const a = cmd.args;
1562
2348
  if (!a?.kind) return fail("kind-required");
1563
- if (!a?.homeNode || !this.nodes.has(a.homeNode)) return fail("home-node-not-found", { homeNode: a?.homeNode ?? "" });
2349
+ if (!a?.homeLocation || !this.locations.has(a.homeLocation)) return fail("home-location-not-found", { homeLocation: a?.homeLocation ?? "" });
1564
2350
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
1565
- let seq = this.movers.size;
2351
+ let seq = this.equipment.size;
1566
2352
  for (let i = 0; i < count; i++) {
1567
2353
  let id = `${a.kind}-${++seq}`;
1568
- while (this.movers.has(id)) id = `${a.kind}-${++seq}`;
1569
- this.addMover({ id, kind: a.kind, homeNode: a.homeNode });
1570
- const m = this.movers.get(id);
1571
- if (m) this.emitMover(m);
2354
+ while (this.equipment.has(id)) id = `${a.kind}-${++seq}`;
2355
+ this.addEquipment({ id, kind: a.kind, homeLocation: a.homeLocation });
2356
+ const m = this.equipment.get(id);
2357
+ if (m) this.emitEquipment(m);
1572
2358
  }
1573
2359
  return ok();
1574
2360
  }
@@ -1615,28 +2401,42 @@ var FlowEngine = class {
1615
2401
  return {
1616
2402
  revision: this.revision,
1617
2403
  simClockMs: this.clockMs,
2404
+ nowTime: this.now(),
2405
+ // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
1618
2406
  /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
1619
2407
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
1620
- nodes: [...this.nodes.values()].map((n) => {
2408
+ locations: [...this.locations.values()].map((n) => {
1621
2409
  const { status, ...rest } = n;
1622
- const derived = nodeStatusOf(n);
2410
+ const derived = locationStatusOf(n);
1623
2411
  return { ...rest, ...derived ? { status: derived } : {}, origin: "master" };
1624
2412
  }),
1625
2413
  items: [...this.items.values()].map((i) => this.itemState(i)),
1626
- movers: [...this.movers.values()].map((m) => {
1627
- const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, origin: "master", ...this.offShift(m) ? { offShift: true } : {} };
2414
+ equipment: [...this.equipment.values()].map((m) => {
2415
+ const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, 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) } : {} };
1628
2416
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
1629
2417
  if (t && t.status === "in-progress" && t.intent !== "process") 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 };
1630
2418
  return s;
1631
2419
  }),
1632
2420
  assets: [...this.assets.values()].map((a) => {
1633
- const st = { id: a.id, assetClass: a.assetClass, location: a.location, status: a.status, taskId: a.taskId ?? void 0 };
2421
+ const st = { id: a.id, assetClassIds: a.assetClassIds, location: a.location, status: a.status, taskId: a.taskId ?? void 0 };
1634
2422
  if (a.carrying) st.carrying = a.carrying;
2423
+ if (a.properties) st.properties = a.properties;
2424
+ if (a.testSpecificationIds) st.testSpecificationIds = a.testSpecificationIds;
2425
+ Object.assign(st, this.effectivePart(a));
1635
2426
  return st;
1636
2427
  }),
1637
2428
  persons: [...this.persons.values()].map((p) => {
1638
- const st = { id: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0 };
1639
- if (this.personOffShift(p)) st.offShift = true;
2429
+ const st = { id: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? void 0 };
2430
+ if (p.location) st.location = p.location;
2431
+ if (p.properties) st.properties = p.properties;
2432
+ if (p.testSpecificationIds) st.testSpecificationIds = p.testSpecificationIds;
2433
+ if (this.personOffShift(p)) {
2434
+ st.offShift = true;
2435
+ Object.assign(st, this.offReason(p));
2436
+ }
2437
+ const sh = this.shiftOf(p);
2438
+ if (sh) st.shift = sh;
2439
+ Object.assign(st, this.effectivePart(p));
1640
2440
  return st;
1641
2441
  }),
1642
2442
  /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
@@ -1656,7 +2456,11 @@ var FlowEngine = class {
1656
2456
  ...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
1657
2457
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
1658
2458
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
1659
- ...t.resources?.length ? { resources: t.resources.slice() } : {}
2459
+ ...t.resources?.length ? { resources: t.resources.slice() } : {},
2460
+ ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
2461
+ ...t.priority !== void 0 ? { priority: t.priority } : {},
2462
+ ...t.startTime ? { startTime: t.startTime } : {},
2463
+ ...t.endTime ? { endTime: t.endTime } : {}
1660
2464
  })),
1661
2465
  orders: [...this.orders.values()].map((o) => ({
1662
2466
  id: o.id,
@@ -1666,23 +2470,40 @@ var FlowEngine = class {
1666
2470
  requested: o.requested,
1667
2471
  fulfilled: o.fulfilled,
1668
2472
  ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {},
2473
+ ...o.priority !== void 0 ? { priority: o.priority } : {},
2474
+ ...o.startTime ? { startTime: o.startTime } : {},
2475
+ ...o.endTime ? { endTime: o.endTime } : {},
1669
2476
  held: o.held
1670
2477
  })),
1671
2478
  attentions: this.computeAttentions()
1672
2479
  };
1673
2480
  }
1674
2481
  /*
1675
- * 주목 신호 판단 — 상태(노드·무버·오더)에서 도메인 조건을 평가해 Attention 방출.
2482
+ * 주목 신호 판단 — 상태(자리·설비·오더)에서 도메인 조건을 평가해 Attention 방출.
1676
2483
  * severity=ISA-18.2 우선순위 계열, kind=도메인 라벨. UI 는 판단 안 함(임계값 여기 소유).
1677
2484
  * 도메인별 추가 판단은 서브클래스가 override 로 확장(super.computeAttentions() 합성).
1678
2485
  */
1679
2486
  computeAttentions() {
2487
+ const now = this.now();
1680
2488
  const out = deriveAttentions(
1681
- { movers: [...this.movers.values()], nodes: [...this.nodes.values()], orders: [...this.orders.values()] },
1682
- this._acked
2489
+ {
2490
+ equipment: [...this.equipment.values()],
2491
+ locations: [...this.locations.values()],
2492
+ orders: [...this.orders.values()],
2493
+ tasks: [...this.tasks.values()]
2494
+ },
2495
+ this._acked,
2496
+ now
2497
+ // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
1683
2498
  );
1684
2499
  const present = new Set(out.map((a) => a.id));
2500
+ for (const a of out) {
2501
+ const first = this._attentionSince.get(a.id) ?? now;
2502
+ this._attentionSince.set(a.id, first);
2503
+ a.timeStamp = first;
2504
+ }
1685
2505
  for (const id of [...this._acked]) if (!present.has(id)) this._acked.delete(id);
2506
+ for (const id of [...this._attentionSince.keys()]) if (!present.has(id)) this._attentionSince.delete(id);
1686
2507
  return out;
1687
2508
  }
1688
2509
  /**
@@ -1709,8 +2530,42 @@ var FlowEngine = class {
1709
2530
  return clone;
1710
2531
  }
1711
2532
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
2533
+ /**
2534
+ * 이 커널의 **지금**(ms) — 시각으로 바뀌는 모든 판정의 단일 기준.
2535
+ *
2536
+ * ── 관측 모드에서 시계가 멈춰 있었다 ────────────────────────────────────
2537
+ * `apply()` 는 `clockMs` 를 밀지 않는다(관측은 시간을 굴리지 않는다). 그래서 라이브 트윈의 "지금" 이
2538
+ * **BASE_EPOCH(2026-01-01)에 얼어 있었다.** 실 이벤트는 실제 시각을 달고 오므로 결과가 이렇게 된다:
2539
+ * · **납기 초과를 영원히 보고하지 않는다** — 실 납기가 항상 미래로 보인다
2540
+ * · 폐기·도입예정 판정이 틀린다(§EffectivePeriod)
2541
+ * · 교대 판정이 틀린다(§offCalendarAt)
2542
+ * 셋 다 "시각으로만 바뀌는 사실" 이라 같은 결함이었다. 그래서 기준을 **한 곳**으로 모은다.
2543
+ *
2544
+ * 관측 중이면 **마지막으로 들은 발생 시각**을 쓴다(미러의 `lastObservedMs` 와 같은 기준 — 규칙 한 벌).
2545
+ * 아직 아무것도 못 들었으면 시뮬 기준으로 떨어진다(그때는 판정할 사실도 없다).
2546
+ */
2547
+ nowMs() {
2548
+ const observed = this.observeMode ? this.observer?.lastObservedMs : void 0;
2549
+ return observed ?? BASE_EPOCH + this.clockMs;
2550
+ }
1712
2551
  now() {
1713
- return new Date(BASE_EPOCH + this.clockMs).toISOString();
2552
+ return new Date(this.nowMs()).toISOString();
2553
+ }
2554
+ /**
2555
+ * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
2556
+ *
2557
+ * **base 에 한 곳만 둔다.** 오더를 만드는 곳이 도메인마다 있어(WMS 판매오더·MES 작업지시·YMS
2558
+ * 어포인트먼트) 각자 계산하면 시간축이나 규약이 갈린다 — 실제로 한 번 갈렸다(납기를 `clockMs` 로
2559
+ * 재고 `now()` 와 비교해 갓 만든 오더가 56년 늦은 것으로 판정됐다).
2560
+ *
2561
+ * 선언이 없으면 **빈 객체**를 준다 — 없는 약속을 만들지 않는다.
2562
+ */
2563
+ promiseOf(spec) {
2564
+ const at = this.now();
2565
+ return {
2566
+ ...spec.priority !== void 0 ? { priority: spec.priority } : {},
2567
+ ...spec.promisedLeadMinutes !== void 0 ? { startTime: at, endTime: new Date(Date.parse(at) + spec.promisedLeadMinutes * 6e4).toISOString() } : {}
2568
+ };
1714
2569
  }
1715
2570
  randInt(min, max) {
1716
2571
  return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
@@ -1733,7 +2588,7 @@ var FlowEngine = class {
1733
2588
  */
1734
2589
  apply(envelope) {
1735
2590
  if (!this.observer) {
1736
- this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
2591
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
1737
2592
  this.observeMode = true;
1738
2593
  }
1739
2594
  this.observer.apply(envelope);
@@ -1762,6 +2617,41 @@ var FlowEngine = class {
1762
2617
  loadOperations(ops = []) {
1763
2618
  for (const o of ops) if (o?.key) this.operationSpecs.set(o.key, o);
1764
2619
  }
2620
+ /**
2621
+ * 라우트(공정 순서) — 수율을 거슬러 올릴 때 필요하다. 기본은 모른다(선언 순서를 쓴다).
2622
+ * 생산 정의를 가진 커널이 override 해서 자기 라우트를 답한다.
2623
+ */
2624
+ routeKeys() {
2625
+ return void 0;
2626
+ }
2627
+ /**
2628
+ * **이 공장이 하루 몇 대를 낼 수 있는가** — 굴려 보지 않고 답한다.
2629
+ *
2630
+ * 커널이 직접 답하는 이유: 필요한 사실이 전부 여기 있다(공정 명세·설비와 신뢰도·인원·물리자산·
2631
+ * 자리·근무 달력·시각 기준). 밖에서 모으면 그 값을 옮겨 적게 되고, 한쪽만 바뀌는 순간 "충분하다"
2632
+ * 가 조용히 거짓이 된다. 계산 자체는 순수 함수(`analyzeCapacity`)에 맡긴다.
2633
+ *
2634
+ * 달력은 **자원이 선언한 것**을 쓴다. 자원마다 다른 달력을 쓰는 현장이면 대표를 고를 수 없으므로
2635
+ * 그 사실을 결과에 실어 보낸다(`mixedCalendars`) — 조용히 하나를 골라 계산하면 천장이 틀린 채로
2636
+ * 그럴듯해 보인다.
2637
+ */
2638
+ capacity(opts) {
2639
+ const calendars = /* @__PURE__ */ new Map();
2640
+ for (const m of this.equipment.values()) if (m.workCalendar?.length) calendars.set(JSON.stringify(m.workCalendar), m.workCalendar);
2641
+ const analysis = analyzeCapacity({
2642
+ operations: [...this.operationSpecs.values()],
2643
+ ...this.routeKeys() ? { route: this.routeKeys() } : {},
2644
+ equipment: [...this.equipment.values()].map((m) => ({ kind: m.kind, mtbfMs: m.mtbfMs, mttrMs: m.mttrMs })),
2645
+ persons: [...this.persons.values()],
2646
+ assets: [...this.assets.values()],
2647
+ locations: [...this.locations.values()],
2648
+ ...calendars.size === 1 ? { calendar: [...calendars.values()][0] } : {},
2649
+ ...this.boardDef?.utcOffsetMinutes !== void 0 ? { utcOffsetMinutes: this.boardDef.utcOffsetMinutes } : {},
2650
+ sampleWeekStartMs: opts.sampleWeekStartMs,
2651
+ unitsPerDay: opts.unitsPerDay
2652
+ });
2653
+ return { ...analysis, mixedCalendars: calendars.size > 1 };
2654
+ }
1765
2655
  /**
1766
2656
  * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
1767
2657
  *
@@ -1871,8 +2761,18 @@ var FlowEngine = class {
1871
2761
  defaultDurations: operations.filter((o) => o.duration === "default").length
1872
2762
  };
1873
2763
  }
1874
- /** skuMix 에서 weight 로 gtin 선택(rng) — 도착/오더 자극의 품목 결정. */
2764
+ /**
2765
+ * `skuMix` 에서 weight 로 gtin 선택(rng) — 도착·오더 자극의 품목 결정.
2766
+ *
2767
+ * **빈 목록이면 고르지 않는다**(`undefined`). 예전에는 `mix[mix.length - 1].gtin` 으로 떨어져
2768
+ * `mix[-1]` 이 undefined 가 되고 거기서 던졌다 — 그 예외가 서버까지 올라가 **정확도 추세 전체를
2769
+ * 죽였다**(품목 구성을 선언하지 않은 자극 하나가 예측 전체를 껐다).
2770
+ *
2771
+ * 없는 품목을 지어내지 않는다: 무엇을 만들지 모르면 **만들지 않는 것**이 맞고, 부르는 쪽이
2772
+ * 그 사실을 알고 건너뛴다.
2773
+ */
1875
2774
  pickGtin(mix) {
2775
+ if (!mix?.length) return void 0;
1876
2776
  const total = mix.reduce((s, m) => s + m.weight, 0);
1877
2777
  let r = this.rng() * total;
1878
2778
  for (const m of mix) {
@@ -1881,28 +2781,28 @@ var FlowEngine = class {
1881
2781
  }
1882
2782
  return mix[mix.length - 1].gtin;
1883
2783
  }
1884
- nodeByType(type) {
1885
- for (const n of this.nodes.values()) if (n.type === type) return n;
2784
+ locationByType(type) {
2785
+ for (const n of this.locations.values()) if (n.type === type) return n;
1886
2786
  return void 0;
1887
2787
  }
1888
2788
  /**
1889
2789
  * process 변화 대수(EPCIS TransformationEvent · ISA-95 Material Consumed/Produced · 씬 Processable.transform).
1890
2790
  * inputs 소비 → outputs 생산. 입출력 arity 가 곧 대수:
1891
2791
  * merge N→1(조립) · split 1→N(분해) · transform 1→1(타입변경) · loss N→0(소실) · gain 0→N(부산물·생성).
1892
- * 아이템 상태(소비/생산)와 노드 점유를 갱신하고 계보(TransformationEvent)를 방출한다.
2792
+ * 아이템 상태(소비/생산)와 자리 점유를 갱신하고 계보(TransformationEvent)를 방출한다.
1893
2793
  * 도메인은 이 원시만 호출 — onTaskComplete 의 소비/생산/EPCIS 손코딩을 대체.
1894
2794
  */
1895
2795
  transform(inputs, outputs, opts) {
1896
2796
  for (const epc of inputs) {
1897
2797
  const it = this.items.get(epc);
1898
2798
  if (!it) continue;
1899
- const n = this.nodes.get(it.location);
2799
+ const n = this.locations.get(it.location);
1900
2800
  if (n) n.occupancy--;
1901
2801
  this.items.delete(epc);
1902
2802
  }
1903
2803
  for (const o of outputs) {
1904
2804
  this.items.set(o.epc, { epc: o.epc, gtin: o.gtin, qty: o.qty, location: o.location, disposition: o.disposition });
1905
- const n = this.nodes.get(o.location);
2805
+ const n = this.locations.get(o.location);
1906
2806
  if (n) n.occupancy++;
1907
2807
  }
1908
2808
  this.emit(transformationEvent({
@@ -1976,7 +2876,7 @@ var FlowEngine = class {
1976
2876
  for (const c of children) {
1977
2877
  const it = this.items.get(c);
1978
2878
  if (it) {
1979
- const n = this.nodes.get(it.location);
2879
+ const n = this.locations.get(it.location);
1980
2880
  if (n) n.occupancy--;
1981
2881
  this.items.delete(c);
1982
2882
  }
@@ -1990,7 +2890,7 @@ var FlowEngine = class {
1990
2890
  }
1991
2891
  /**
1992
2892
  * containment 분해(EPCIS AggregationEvent DELETE) — 부모(용기)에서 자식들을 풀어냄.
1993
- * materialize 지정 시 자식이 독립 아이템으로 등장(materialize: 노드 배치 + occupancy + ObjectEvent ADD).
2893
+ * materialize 지정 시 자식이 독립 아이템으로 등장(materialize: 자리 배치 + occupancy + ObjectEvent ADD).
1994
2894
  * 미지정 시 이벤트만.
1995
2895
  */
1996
2896
  disaggregate(parent, children, opts) {
@@ -2003,7 +2903,7 @@ var FlowEngine = class {
2003
2903
  const m = opts.materialize;
2004
2904
  for (const c of children) {
2005
2905
  this.items.set(c, { epc: c, location: m.location, disposition: m.disposition });
2006
- const n = this.nodes.get(m.location);
2906
+ const n = this.locations.get(m.location);
2007
2907
  if (n) n.occupancy++;
2008
2908
  this.emit(objectEvent({ eventTime: this.now(), action: "ADD", bizStep: opts.bizStep, disposition: m.disposition, epcList: [c], readPoint: m.location, bizLocation: m.location }));
2009
2909
  }
@@ -2011,22 +2911,22 @@ var FlowEngine = class {
2011
2911
  }
2012
2912
  /** 품질 보고(OEE Quality) — 도메인이 완료 시 자원별 양품/불량 1건 계상(예: 용접 수율). */
2013
2913
  recordOutput(moverId, good) {
2014
- const m = moverId ? this.movers.get(moverId) : void 0;
2914
+ const m = moverId ? this.equipment.get(moverId) : void 0;
2015
2915
  if (!m) return;
2016
2916
  if (good) m.goodCount++;
2017
2917
  else m.scrapCount++;
2018
2918
  this.emitOp(OP_EVENT.quality, { moverId: m.id, good, goodCount: m.goodCount, scrapCount: m.scrapCount });
2019
2919
  }
2020
- /** 무버 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
2920
+ /** 설비 OEE(스냅샷 파생) — Availability×Performance×Quality. planned = 설비 존재 sim 시간(clockMs). */
2021
2921
  oeeOf(m) {
2022
2922
  return computeOee(m, this.clockMs);
2023
2923
  }
2024
- /** 정책에 넘길 특정 타입 노드의 관측 뷰 — 예약(그 노드로 향하는 in-flight task) 포함. */
2025
- slotViews(nodeType) {
2924
+ /** 정책에 넘길 특정 타입 자리의 관측 뷰 — 예약(그 자리로 향하는 in-flight task) 포함. */
2925
+ slotViews(locationType) {
2026
2926
  const reserved = /* @__PURE__ */ new Map();
2027
2927
  for (const t of this.tasks.values()) if (t.status !== "completed") reserved.set(t.toNode, (reserved.get(t.toNode) ?? 0) + 1);
2028
2928
  const views = [];
2029
- for (const n of this.nodes.values()) if (n.type === nodeType) views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
2929
+ for (const n of this.locations.values()) if (n.type === locationType) views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
2030
2930
  return views;
2031
2931
  }
2032
2932
  emit(event) {
@@ -2062,21 +2962,26 @@ var FlowEngine = class {
2062
2962
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
2063
2963
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
2064
2964
  ...t.resources?.length ? { resources: t.resources.slice() } : {},
2965
+ /* 실제 자재 이동 — 인원·설비와 같은 채널(실적을 한 곳에서 읽는다). */
2966
+ ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
2065
2967
  ...t.durationMs ? { durationMs: t.durationMs } : {},
2968
+ ...t.priority !== void 0 ? { priority: t.priority } : {},
2969
+ ...t.startTime ? { startTime: t.startTime } : {},
2970
+ ...t.endTime ? { endTime: t.endTime } : {},
2066
2971
  ...inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
2067
2972
  });
2068
2973
  }
2069
2974
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
2070
2975
  emitAsset(a) {
2071
- this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClass: a.assetClass, status: a.status, location: a.location, taskId: a.taskId ?? void 0, carrying: a.carrying });
2976
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClassIds: a.assetClassIds, status: a.status, location: a.location, taskId: a.taskId ?? void 0, carrying: a.carrying, ...effectiveOnly(a) });
2072
2977
  }
2073
2978
  emitPerson(p) {
2074
- this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0, ...this.personOffShift(p) ? { offShift: true } : {} });
2979
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClassIds: p.personnelClassIds, status: p.status, taskId: p.taskId ?? void 0, ...p.location ? { location: p.location } : {}, ...effectiveOnly(p) });
2075
2980
  }
2076
2981
  /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
2077
2982
  * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
2078
- emitMover(m, motion) {
2079
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? void 0, motion });
2983
+ emitEquipment(m, motion) {
2984
+ 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 ?? void 0, motion, ...m.held ? { held: true } : {}, ...effectiveOnly(m) });
2080
2985
  }
2081
2986
  /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
2082
2987
  emitOrder(o) {
@@ -2087,6 +2992,9 @@ var FlowEngine = class {
2087
2992
  requested: o.requested,
2088
2993
  fulfilled: o.fulfilled,
2089
2994
  held: o.held,
2995
+ ...o.priority !== void 0 ? { priority: o.priority } : {},
2996
+ ...o.startTime ? { startTime: o.startTime } : {},
2997
+ ...o.endTime ? { endTime: o.endTime } : {},
2090
2998
  ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {}
2091
2999
  });
2092
3000
  }
@@ -2156,7 +3064,9 @@ var FlowEngine = class {
2156
3064
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
2157
3065
  if (!want) continue;
2158
3066
  const avail = [...this.assets.values()].filter(
2159
- (a) => a.status === "idle" && !picked.includes(a.id) && (req.assetClass === void 0 || a.assetClass === req.assetClass)
3067
+ /* 인원과 같은 규칙 상속을 타고 닫아 판정하고 유효기간 밖은 제외한다. 요구는 등급 하나+수량. */
3068
+ (a) => a.status === "idle" && !picked.includes(a.id) && !this.outOfEffect(a) && // 폐기한 팔레트는 풀에서 빠진다
3069
+ (req.assetClass === void 0 || classClosure(a.assetClassIds, this.classDefs.asset, this.now()).has(req.assetClass))
2160
3070
  );
2161
3071
  if (avail.length < want) return null;
2162
3072
  for (let i = 0; i < want; i++) picked.push(avail[i].id);
@@ -2174,7 +3084,7 @@ var FlowEngine = class {
2174
3084
  a.taskId = t.id;
2175
3085
  if (t.itemEpc) {
2176
3086
  a.carrying = t.itemEpc;
2177
- const it = this.items.get(t.itemEpc);
3087
+ const it = this.itemByRef(t.itemEpc);
2178
3088
  if (it) it.carriedBy = a.id;
2179
3089
  }
2180
3090
  this.emitAsset(a);
@@ -2205,6 +3115,194 @@ var FlowEngine = class {
2205
3115
  * 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
2206
3116
  * (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
2207
3117
  */
3118
+ /**
3119
+ * 필요 자재를 확보한다 — **ISA-95 `OperationsSegment.MaterialSpecification`**(`use: 'consumed'`).
3120
+ *
3121
+ * ── 4대 자원 중 자재만 게이트가 없었다 ────────────────────────────────────
3122
+ * 인원·자산·설비는 모자라면 기다리는데 **자재는 없어도 작업이 시작됐다.** 그래서 부품이 떨어져
3123
+ * 라인이 서는 상황이 예측에 아예 나타나지 않았다 — 트레일러 조립 공장에서 그것은 가장 흔한 정지다.
3124
+ *
3125
+ * 인원·자산과 **같은 규칙**이다: 등급(또는 품목)으로 요구하고, **부분 확보 없이 전량 아니면 대기.**
3126
+ * 자재는 **작업이 일어나는 자리에 있어야** 한다(다른 창고의 부품은 지금 쓸 수 없다).
3127
+ *
3128
+ * 소비 시점은 **작업 시작**이다 — 자재는 들어가는 순간 없어진다(완료 시점에 빼면 그 사이 다른
3129
+ * 작업이 같은 부품을 또 잡는다). 산출(`produced`)은 여기서 다루지 않는다(완료 시점의 일이다).
3130
+ */
3131
+ claimMaterials(t) {
3132
+ const need = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter((m) => m.use === "consumed");
3133
+ if (!need.length) return [];
3134
+ const at = t.toNode;
3135
+ const picked = [];
3136
+ const takenSoFar = /* @__PURE__ */ new Map();
3137
+ for (const req of need) {
3138
+ let remaining = Math.max(0, req.quantity ?? 0);
3139
+ if (!remaining) continue;
3140
+ for (const it of this.items.values()) {
3141
+ if (remaining <= 0) break;
3142
+ if (it.location !== at) continue;
3143
+ if (!this.materialMatches(it, req)) continue;
3144
+ const already = takenSoFar.get(it.epc) ?? 0;
3145
+ const avail = Math.max(0, (it.qty ?? 1) - already);
3146
+ if (avail <= 0) continue;
3147
+ const take = Math.min(avail, remaining);
3148
+ picked.push({ epc: it.epc, take });
3149
+ takenSoFar.set(it.epc, already + take);
3150
+ remaining -= take;
3151
+ }
3152
+ if (remaining > 0) return null;
3153
+ }
3154
+ return picked;
3155
+ }
3156
+ /** 이 물품이 명세를 만족하나 — 품목 지목이 우선, 없으면 등급(상속을 타고 닫는다). */
3157
+ materialMatches(it, req) {
3158
+ const defId = it.definitionId ?? it.gtin;
3159
+ if (req.materialDefinition) return defId === req.materialDefinition;
3160
+ if (req.materialClass) {
3161
+ const def = defId ? this.materialDefs.get(defId) : void 0;
3162
+ return classClosure(def?.materialClassIds, this.classDefs.material, this.now()).has(req.materialClass);
3163
+ }
3164
+ return true;
3165
+ }
3166
+ /**
3167
+ * 산출 자재를 만든다 — ISA-95 `MaterialSpecification`(`use: 'produced'`).
3168
+ *
3169
+ * **소비의 짝이다**: 자재는 작업이 시작될 때 들어가고 끝날 때 나온다. 산출이 없으면 조립 공정이
3170
+ * 부품만 먹고 아무것도 내놓지 않아, 다음 공정이 영원히 재료를 기다린다(라인 전체가 한 칸에서 멈춘다).
3171
+ *
3172
+ * 산출물의 자리는 **작업이 끝난 자리**(`toNode`)다. 식별자는 품목 식별자에 작업 id 를 붙여 만든다 —
3173
+ * **EPC 처럼 보이게 꾸미지 않는다**(상류가 준 직렬번호와 구별돼야 한다). 상류가 진짜 식별자를
3174
+ * 말해 주면 그것이 들어올 자리는 관측 경로다.
3175
+ *
3176
+ * 같은 품목이 그 자리에 이미 있으면 **수량을 더한다** — 새 줄을 만들면 같은 자리의 같은 로트가
3177
+ * 둘로 갈려 재고가 부푼다(§MaterialSubLot 에서 겪은 것과 반대 방향의 같은 오류).
3178
+ */
3179
+ produceMaterials(t) {
3180
+ const made = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter((m) => m.use === "produced");
3181
+ if (!made.length) return;
3182
+ for (const spec of made) {
3183
+ const qty = Math.max(0, spec.quantity ?? 0);
3184
+ if (!qty) continue;
3185
+ const gtin = spec.materialDefinition;
3186
+ if (!gtin) continue;
3187
+ const at = t.toNode;
3188
+ const key = subLotIdOf(gtin, at);
3189
+ const existing = this.items.get(key);
3190
+ const total = (existing?.qty ?? 0) + qty;
3191
+ if (existing) existing.qty = total;
3192
+ else {
3193
+ this.items.set(key, { epc: gtin, subLotId: key, gtin, qty: total, location: at, disposition: DISP.in_progress, ...spec.uom ? { uom: spec.uom } : {} });
3194
+ const n = this.locations.get(at);
3195
+ if (n) n.occupancy++;
3196
+ }
3197
+ this.recordMaterialActual(t, gtin, "produced", qty, spec.uom);
3198
+ this.emit(objectEvent({
3199
+ eventTime: this.now(),
3200
+ action: "ADD",
3201
+ bizStep: CBV_BIZSTEP.commissioning,
3202
+ disposition: DISP.in_progress,
3203
+ epcList: [],
3204
+ quantityList: [{ epcClass: gtin, quantity: total, ...spec.uom ? { uom: spec.uom } : {} }],
3205
+ readPoint: at,
3206
+ bizLocation: at
3207
+ }));
3208
+ }
3209
+ }
3210
+ /**
3211
+ * 이 작업이 **딛고 선 것**이 아직 있나 — 없으면 무엇이 없는지 답한다.
3212
+ *
3213
+ * 작업은 혼자 서지 못한다: 옮길 **물품**과, (있다면) 그것을 시킨 **오더** 위에 선다. 진행 중에
3214
+ * 둘 중 하나가 사라질 수 있다 — 물품은 포장·출하·소비로, 오더는 이미 이행돼 씨앗이 심지 않아서.
3215
+ *
3216
+ * 그때 도메인 훅은 없는 것을 딛으려다 던진다(`order.gtin` · `item.location`). 그 예외 하나가
3217
+ * **예측 전체를 죽였다** — 사용자에게는 기능이 통째로 사라진 것으로 보였다. 그래서 완료 **전에**
3218
+ * 여기서 묻고, 없으면 그 작업만 접는다.
3219
+ */
3220
+ missingContextOf(t) {
3221
+ if (t.itemEpc && !this.itemByRef(t.itemEpc)) return "item";
3222
+ if (t.orderId && !this.orders.has(t.orderId)) return "order";
3223
+ if (!this.canComplete(t)) return "domain";
3224
+ return void 0;
3225
+ }
3226
+ /**
3227
+ * 이 도메인이 이 작업을 **끝맺을 수 있나** — 코어가 모르는 조건은 도메인이 답한다.
3228
+ *
3229
+ * 코어는 물품과 오더까지만 안다. 그런데 도메인은 더 필요할 수 있다 — MES 는 완료 시점에 **오더와
3230
+ * 제품 정의**를 딛고 서고, 그 중 하나만 없어도 던진다. 코어가 그 조건을 추측하면 도메인마다 다른
3231
+ * 가정을 코어에 박게 되므로(방언), **묻는다.**
3232
+ *
3233
+ * 기본은 `true` — 대부분의 작업은 코어가 확인한 것으로 충분하다.
3234
+ */
3235
+ canComplete(_t) {
3236
+ return true;
3237
+ }
3238
+ /**
3239
+ * 작업이 가리키는 물품을 찾는다 — **참조와 키가 다를 수 있다.**
3240
+ *
3241
+ * 물품 맵의 키는 `itemKeyOf`(직렬 물품은 `epc`, 로트의 부분은 `subLotId`)인데, 작업은 로트 식별자
3242
+ * (`itemEpc`)로 가리킨다. 비직렬 로트에서는 둘이 **다르다** — 그래서 그냥 `get` 하면 못 찾는다.
3243
+ * 실제로 그 회귀를 냈다: 예측(씨앗) 경로에서 `item.location = …` 이 `undefined` 위에서 던져
3244
+ * **정확도 추세 전체가 서버 오류로 죽었다.**
3245
+ *
3246
+ * **부분이 여럿이면 풀지 않는다** — 어느 부분을 가리키는지 알 수 없고, 아무거나 고르면 그 뒤
3247
+ * 이동·처분이 엉뚱한 자리에 적힌다. 그때는 참조가 부족한 것이고, 부르는 쪽이 그 사실을 말해야 한다.
3248
+ */
3249
+ itemByRef(ref) {
3250
+ const exact = this.items.get(ref);
3251
+ if (exact) return exact;
3252
+ let hit;
3253
+ for (const it of this.items.values()) {
3254
+ if (it.epc !== ref) continue;
3255
+ if (hit) return void 0;
3256
+ hit = it;
3257
+ }
3258
+ return hit;
3259
+ }
3260
+ /**
3261
+ * 실제 자재 이동을 작업에 적어 둔다 — ISA-95 `JobResponse.MaterialActual`.
3262
+ * 같은 품목·같은 쓰임은 **한 줄로 합친다**(줄을 늘리면 실적을 세는 쪽이 중복을 걷어내야 한다).
3263
+ */
3264
+ recordMaterialActual(t, definitionId, use, quantity, uom) {
3265
+ if (!definitionId || !(quantity > 0)) return;
3266
+ const rows = t.materialActual ??= [];
3267
+ const hit = rows.find((r) => r.definitionId === definitionId && r.use === use && r.uom === uom);
3268
+ if (hit) hit.quantity += quantity;
3269
+ else rows.push({ definitionId, use, quantity, ...uom ? { uom } : {} });
3270
+ }
3271
+ /** 확보한 자재를 **작업 시작 시점에 소비**한다 — 수량이 0 이 되면 물품 자체가 사라진다. */
3272
+ consumeMaterials(t, taken) {
3273
+ if (!taken.length) return;
3274
+ const whole = [];
3275
+ for (const { epc, take } of taken) {
3276
+ const it = this.items.get(epc);
3277
+ if (!it) continue;
3278
+ const left = (it.qty ?? 1) - take;
3279
+ if (left > 0) {
3280
+ it.qty = left;
3281
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom);
3282
+ this.emit(objectEvent({
3283
+ eventTime: this.now(),
3284
+ action: "OBSERVE",
3285
+ bizStep: CBV_BIZSTEP.consuming,
3286
+ disposition: DISP.in_progress,
3287
+ epcList: [],
3288
+ quantityList: [{ epcClass: it.gtin ?? it.epc, quantity: left, uom: it.uom }],
3289
+ readPoint: it.location,
3290
+ bizLocation: it.location
3291
+ }));
3292
+ continue;
3293
+ }
3294
+ this.recordMaterialActual(t, it.definitionId ?? it.gtin, "consumed", take, it.uom);
3295
+ whole.push(epc);
3296
+ }
3297
+ if (whole.length) {
3298
+ this.transform(whole, [], {
3299
+ bizStep: CBV_BIZSTEP.consuming,
3300
+ disposition: DISP.in_progress,
3301
+ transformationId: t.id,
3302
+ readPoint: this.items.get(whole[0])?.location ?? t.toNode
3303
+ });
3304
+ }
3305
+ }
2208
3306
  claimPersonnel(t) {
2209
3307
  const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
2210
3308
  if (!need?.length) return [];
@@ -2213,7 +3311,11 @@ var FlowEngine = class {
2213
3311
  const want = Math.max(0, Math.floor(req.quantity ?? 0));
2214
3312
  if (!want) continue;
2215
3313
  const avail = [...this.persons.values()].filter(
2216
- (p) => p.status === "idle" && !picked.includes(p.id) && !this.personOffShift(p) && (req.personnelClass === void 0 || p.personnelClass === req.personnelClass)
3314
+ (p) => p.status === "idle" && !picked.includes(p.id) && !this.personOffShift(p) && !this.outOfEffect(p) && // 입사 전·퇴사 후는 시각의 모델에 없다
3315
+ /* 자격은 **상속을 타고 닫아** 판정한다 — 사람은 여러 등급에 속할 수 있고(표준), 등급은
3316
+ 다른 등급을 상속한다. "생산직 2명" 요구를 "용접 자격자" 가 만족해야 한다.
3317
+ 유효기간 밖의 등급은 닫힘에서 빠진다(만료된 자격으로 배정되지 않는다). */
3318
+ (req.personnelClass === void 0 || classClosure(p.personnelClassIds, this.classDefs.personnel, this.now()).has(req.personnelClass))
2217
3319
  );
2218
3320
  if (avail.length < want) return null;
2219
3321
  for (let i = 0; i < want; i++) picked.push(avail[i].id);
@@ -2226,8 +3328,12 @@ var FlowEngine = class {
2226
3328
  * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
2227
3329
  */
2228
3330
  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)
3331
+ const free = (kind, picked2 = []) => [...this.equipment.values()].filter(
3332
+ (m) => m.status === "idle" && !m.held && !this.offShift(m) && !this.outOfEffect(m) && // 도입 전·폐기 후는 시각의 모델에 없다
3333
+ !picked2.includes(m.id) && /* 설비의 소속은 아직 단수(`kind`)다 — 저널에 기록이 쌓여 복수화를 별도 작업으로 두었다.
3334
+ 다만 등급 정의가 있으면 **상속은 지금도 탄다**: 'welder-6axis' 가 'welder' 를 상속하면
3335
+ 'welder' 요구를 만족한다. 단수/복수와 상속은 다른 축이므로 하나를 기다리지 않는다. */
3336
+ (kind === void 0 || classClosure(m.kind ? [m.kind] : [], this.classDefs.equipment, this.now()).has(kind))
2231
3337
  );
2232
3338
  const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
2233
3339
  if (!need?.length) {
@@ -2268,33 +3374,87 @@ var FlowEngine = class {
2268
3374
  }
2269
3375
  /** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
2270
3376
  personOffShift(p) {
2271
- const w = p.window;
2272
- if (!w) return false;
2273
- const h = this.hourOfDay();
2274
- return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
3377
+ return this.offCalendar(p);
2275
3378
  }
2276
3379
  /**
2277
3380
  * 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
2278
3381
  * 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
2279
3382
  */
2280
- stationFull(nodeId) {
2281
- if (!nodeId) return false;
2282
- const limit = this.nodes.get(nodeId)?.parallelism;
3383
+ stationFull(locationId) {
3384
+ if (!locationId) return false;
3385
+ const limit = this.locations.get(locationId)?.parallelism;
2283
3386
  if (!(typeof limit === "number" && limit > 0)) return false;
2284
3387
  let running = 0;
2285
- for (const t of this.tasks.values()) if (t.status === "in-progress" && t.toNode === nodeId) running++;
3388
+ for (const t of this.tasks.values()) if (t.status === "in-progress" && t.toNode === locationId) running++;
2286
3389
  return running >= limit;
2287
3390
  }
2288
3391
  /** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
2289
3392
  offShift(m) {
2290
- const w = m.window;
2291
- if (!w) return false;
2292
- const h = this.hourOfDay();
2293
- return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
3393
+ return this.offCalendar(m);
3394
+ }
3395
+ /** 쉬는가 — 휴일·휴게(non-working)인지 주말·교대 사이(off-hours)인지(§OffCalendarReason). */
3396
+ offReason(r) {
3397
+ const why = offCalendarReasonAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
3398
+ return why ? { offShiftReason: why } : {};
3399
+ }
3400
+ /**
3401
+ * 유효 기간 밖인가 — **네 번째 이유**(§Effectivity). 설비·사람·자산이 같은 규칙을 쓴다.
3402
+ *
3403
+ * 시뮬 시각을 ISO 로 풀어 계약의 `effectivityAt` 에 넘긴다. **판정은 커널에 두지 않는다** —
3404
+ * 호스트(라이브 관측)도 같은 판정을 해야 하고, 규칙이 두 벌이면 갈라진다.
3405
+ */
3406
+ effectivityOf(r) {
3407
+ if (!r.effectiveStart && !r.effectiveEnd) return void 0;
3408
+ return effectivityAt(r, this.now());
3409
+ }
3410
+ /**
3411
+ * 지금 어느 교대인가 — 계약의 `activeShiftOf` 에 위임한다(미러도 같은 함수를 쓴다).
3412
+ * 선언이 없으면 `undefined`: 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.
3413
+ */
3414
+ shiftOf(r) {
3415
+ if (!r.workCalendar?.length) return void 0;
3416
+ return activeShiftAt(r.workCalendar, this.nowMs(), this.boardDef?.utcOffsetMinutes);
3417
+ }
3418
+ /** 유효 기간 밖이라 이 시각의 모델에 참여하지 않는가 — 배정 게이트가 쓰는 형태. */
3419
+ outOfEffect(r) {
3420
+ return this.effectivityOf(r) !== void 0;
3421
+ }
3422
+ /**
3423
+ * 스냅샷·델타에 실을 조각 — **선언한 기간과 판정을 함께** 낸다.
3424
+ *
3425
+ * 판정만 내면 화면이 "왜" 를 말할 수 없고(언제 폐기됐나), 기간만 내면 소비처마다 다시 판정해 갈라진다.
3426
+ * 선언이 없으면 아무것도 붙이지 않는다(대부분의 자원이 그렇다 — 필드를 늘리지 않는다).
3427
+ */
3428
+ effectivePart(r) {
3429
+ if (!r.effectiveStart && !r.effectiveEnd) return {};
3430
+ const e = this.effectivityOf(r);
3431
+ return {
3432
+ ...r.effectiveStart ? { effectiveStart: r.effectiveStart } : {},
3433
+ ...r.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {},
3434
+ ...e ? { effectivity: e } : {}
3435
+ };
3436
+ }
3437
+ /**
3438
+ * 지금 근무 시간 밖인가 — **캘린더가 있으면 그것으로, 없으면 옛 `window` 로** 판정한다.
3439
+ *
3440
+ * `window`(시 단위 하나)는 캘린더의 특수한 경우다. 둘을 한 함수로 모아 두면 인원·설비가 같은 규칙을
3441
+ * 쓴다(예전에는 같은 식이 두 곳에 복사돼 있었다 — 한쪽만 고치면 조용히 갈라진다).
3442
+ */
3443
+ offCalendar(r) {
3444
+ return offCalendarAt(r, this.nowMs(), this.boardDef?.utcOffsetMinutes);
3445
+ }
3446
+ /**
3447
+ * 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
3448
+ *
3449
+ * **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
3450
+ * 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
3451
+ */
3452
+ minuteOfDay() {
3453
+ return minuteOfDayAt(this.nowMs(), this.boardDef?.utcOffsetMinutes);
2294
3454
  }
2295
3455
  /** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
2296
3456
  hourOfDay() {
2297
- return new Date(BASE_EPOCH + this.clockMs).getUTCHours();
3457
+ return new Date(this.nowMs()).getUTCHours();
2298
3458
  }
2299
3459
  /**
2300
3460
  * 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
@@ -2312,19 +3472,19 @@ var FlowEngine = class {
2312
3472
  return -Math.log(1 - this.rng()) * meanMs;
2313
3473
  }
2314
3474
  /**
2315
- * 확률적 설비 고장(MTBF/MTTR) — mtbf 지정 무버만 참여(미지정=고장 없음, rng 무소비 → byte-identical).
3475
+ * 확률적 설비 고장(MTBF/MTTR) — mtbf 지정 설비만 참여(미지정=고장 없음, rng 무소비 → byte-identical).
2316
3476
  * up: nextFailure 도래 시 down(수리까지 repairUntil). down: downMs 누적, repair 도래 시 up(다음 고장 예약).
2317
- * down 중 무버는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
3477
+ * down 중 설비는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
2318
3478
  */
2319
3479
  processFailures(dt) {
2320
- for (const m of this.movers.values()) {
3480
+ for (const m of this.equipment.values()) {
2321
3481
  if (m.status === "down") {
2322
3482
  m.downMs += dt;
2323
3483
  if (m.repairUntilMs != null && this.clockMs >= m.repairUntilMs) {
2324
3484
  m.status = m.taskId ? "busy" : "idle";
2325
3485
  m.repairUntilMs = void 0;
2326
3486
  if (m.mtbfMs !== void 0) m.nextFailureMs = this.clockMs + this.sampleExp(m.mtbfMs);
2327
- this.emitMover(m);
3487
+ this.emitEquipment(m);
2328
3488
  }
2329
3489
  continue;
2330
3490
  }
@@ -2336,7 +3496,7 @@ var FlowEngine = class {
2336
3496
  m.status = "down";
2337
3497
  m.repairUntilMs = this.clockMs + this.sampleExp(m.mttrMs ?? m.mtbfMs);
2338
3498
  m.nextFailureMs = void 0;
2339
- this.emitMover(m);
3499
+ this.emitEquipment(m);
2340
3500
  }
2341
3501
  }
2342
3502
  }
@@ -2354,6 +3514,8 @@ var FlowEngine = class {
2354
3514
  const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
2355
3515
  return {
2356
3516
  epc: i.epc,
3517
+ ...i.subLotId ? { subLotId: i.subLotId } : {},
3518
+ ...i.definitionId ? { definitionId: i.definitionId } : {},
2357
3519
  ...i.gtin ? { gtin: i.gtin } : {},
2358
3520
  ...gtinKey ? { gtinKey } : {},
2359
3521
  ...lot ? { lot } : {},
@@ -2363,6 +3525,7 @@ var FlowEngine = class {
2363
3525
  ...i.carriedBy ? { carriedBy: i.carriedBy } : {},
2364
3526
  ...i.qty !== void 0 ? { qty: i.qty } : {},
2365
3527
  ...i.uom ? { uom: i.uom } : {},
3528
+ ...i.quantities?.length ? { quantities: i.quantities } : {},
2366
3529
  ...i.expiry !== void 0 ? { expiry: i.expiry } : {},
2367
3530
  ...i.ilmd ? { ilmd: i.ilmd } : {}
2368
3531
  };
@@ -2390,7 +3553,9 @@ var FlowEngine = class {
2390
3553
  }
2391
3554
  }
2392
3555
  processOrders() {
2393
- for (const o of this.orders.values()) if (o.status === "created" && !o.held) this.allocate(o);
3556
+ const pending = [...this.orders.values()].filter((o) => o.status === "created" && !o.held);
3557
+ if (pending.some((o) => o.priority !== void 0)) pending.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
3558
+ for (const o of pending) this.allocate(o);
2394
3559
  }
2395
3560
  /**
2396
3561
  * 작업 진행 — **진행을 먼저, 배정을 나중에.**
@@ -2404,54 +3569,87 @@ var FlowEngine = class {
2404
3569
  this.assignTasks();
2405
3570
  }
2406
3571
  assignTasks() {
2407
- for (const t of this.tasks.values()) {
3572
+ const queue = [...this.tasks.values()];
3573
+ if (queue.some((t) => t.priority !== void 0)) queue.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
3574
+ for (const t of queue) {
2408
3575
  if (t.status !== "created") continue;
2409
3576
  const crew = this.claimPersonnel(t);
2410
3577
  if (crew === null) continue;
2411
3578
  const gear = this.claimAssets(t);
2412
3579
  if (gear === null) continue;
3580
+ const stock = this.claimMaterials(t);
3581
+ if (stock === null) continue;
2413
3582
  if (t.intent === "dwell") {
2414
3583
  t.status = "in-progress";
2415
3584
  t.remainingMs = t.durationMs;
2416
3585
  t.startedAtSimMs = this.clockMs;
2417
3586
  this.assignCrew(t, crew);
2418
3587
  this.assignAssets(t, gear);
3588
+ this.consumeMaterials(t, stock);
2419
3589
  this.emitTask(t);
2420
3590
  continue;
2421
3591
  }
2422
3592
  if (this.stationFull(t.toNode)) continue;
2423
3593
  const rigs = this.claimEquipment(t);
2424
3594
  if (!rigs) continue;
2425
- const mover = this.movers.get(rigs[0]);
2426
- if (t.setupMs && t.changeoverKey !== void 0 && mover.lastChangeoverKey !== void 0 && mover.lastChangeoverKey !== t.changeoverKey) {
3595
+ const eq = this.equipment.get(rigs[0]);
3596
+ if (t.setupMs && t.changeoverKey !== void 0 && eq.lastChangeoverKey !== void 0 && eq.lastChangeoverKey !== t.changeoverKey) {
2427
3597
  t.appliedSetupMs = t.setupMs;
2428
3598
  t.durationMs += t.setupMs;
2429
3599
  }
2430
- if (t.changeoverKey !== void 0) mover.lastChangeoverKey = t.changeoverKey;
3600
+ if (t.changeoverKey !== void 0) eq.lastChangeoverKey = t.changeoverKey;
2431
3601
  for (const id of rigs) {
2432
- const m = this.movers.get(id);
3602
+ const m = this.equipment.get(id);
2433
3603
  m.status = "busy";
2434
3604
  m.taskId = t.id;
2435
3605
  }
2436
3606
  t.status = "in-progress";
2437
- t.resource = mover.id;
3607
+ t.resource = eq.id;
2438
3608
  t.remainingMs = t.durationMs;
2439
3609
  t.startedAtSimMs = this.clockMs;
2440
3610
  if (rigs.length > 1) t.resources = rigs.slice();
2441
3611
  this.assignCrew(t, crew);
2442
3612
  this.assignAssets(t, gear);
3613
+ this.consumeMaterials(t, stock);
2443
3614
  this.emitTask(t);
2444
- if (t.intent === "process") this.emitMover(mover);
2445
- else this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
3615
+ if (t.intent === "process") this.emitEquipment(eq);
3616
+ else this.emitEquipment(eq, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
2446
3617
  }
2447
3618
  }
2448
3619
  /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
2449
3620
  advanceTasks(dt) {
2450
3621
  for (const t of this.tasks.values()) {
2451
3622
  if (t.status !== "in-progress") continue;
2452
- if (t.resource && this.movers.get(t.resource)?.status === "down") continue;
3623
+ if (t.resource && this.equipment.get(t.resource)?.status === "down") continue;
2453
3624
  t.remainingMs -= dt;
2454
3625
  if (t.remainingMs > 0) continue;
3626
+ const missing = this.missingContextOf(t);
3627
+ if (missing) {
3628
+ this.abandonedTasks++;
3629
+ if (this.abandonedTasks === 1) {
3630
+ const why = missing === "item" ? "the item it moves is no longer in state (consumed, shipped, or the observation did not carry it)" : missing === "order" ? "the order it belongs to is no longer in state (already fulfilled, so the seed did not restore it)" : "the domain cannot complete it (its order or product definition is missing)";
3631
+ console.warn(`[twin-kernel] "${this.tenantId}": folding task ${t.id} \u2014 ${why}. Further such tasks are counted, not logged.`);
3632
+ }
3633
+ this.tasks.delete(t.id);
3634
+ this.releaseCrew(t);
3635
+ this.releaseAssets(t);
3636
+ if (t.resource) {
3637
+ const rig = this.equipment.get(t.resource);
3638
+ if (rig) {
3639
+ rig.status = "idle";
3640
+ rig.taskId = null;
3641
+ }
3642
+ }
3643
+ for (const id of t.resources ?? []) {
3644
+ const m = this.equipment.get(id);
3645
+ if (m) {
3646
+ m.status = "idle";
3647
+ m.taskId = null;
3648
+ }
3649
+ }
3650
+ continue;
3651
+ }
3652
+ this.produceMaterials(t);
2455
3653
  this.onTaskComplete(t);
2456
3654
  t.status = "completed";
2457
3655
  this.releaseCrew(t);
@@ -2460,26 +3658,26 @@ var FlowEngine = class {
2460
3658
  this.emitTask(t);
2461
3659
  continue;
2462
3660
  }
2463
- const mover = this.movers.get(t.resource);
3661
+ const eq = this.equipment.get(t.resource);
2464
3662
  const setup = t.appliedSetupMs ?? 0;
2465
- mover.setupMs += setup;
2466
- mover.runMs += t.durationMs - setup;
2467
- mover.status = "idle";
2468
- mover.taskId = null;
2469
- if (t.intent !== "process") mover.location = t.toNode;
3663
+ eq.setupMs += setup;
3664
+ eq.runMs += t.durationMs - setup;
3665
+ eq.status = "idle";
3666
+ eq.taskId = null;
3667
+ if (t.intent !== "process") eq.location = t.toNode;
2470
3668
  for (const id of t.resources ?? []) {
2471
- if (id === mover.id) continue;
2472
- const m = this.movers.get(id);
3669
+ if (id === eq.id) continue;
3670
+ const m = this.equipment.get(id);
2473
3671
  if (!m) continue;
2474
3672
  m.setupMs += setup;
2475
3673
  m.runMs += t.durationMs - setup;
2476
3674
  m.status = "idle";
2477
3675
  m.taskId = null;
2478
3676
  if (t.intent !== "process") m.location = t.toNode;
2479
- this.emitMover(m);
3677
+ this.emitEquipment(m);
2480
3678
  }
2481
3679
  this.emitTask(t);
2482
- this.emitMover(mover);
3680
+ this.emitEquipment(eq);
2483
3681
  }
2484
3682
  }
2485
3683
  };
@@ -2496,10 +3694,11 @@ var WmsKernel = class extends FlowEngine {
2496
3694
  }
2497
3695
  /** 입고 도착 — §4 라이프사이클: ASN(PO) → 팔레트 조립 → 수령 → putaway task. */
2498
3696
  onArrival(spec) {
2499
- const dock = this.nodeByType("dock");
3697
+ const dock = this.locationByType("dock");
2500
3698
  if (!dock) return;
2501
3699
  const epc = ssccUri(COMPANY_PREFIX, ++this.epcSeq);
2502
3700
  const gtin = this.pickGtin(spec.content.skuMix);
3701
+ if (!gtin) return;
2503
3702
  const qty = this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max);
2504
3703
  const po = gdtiUri(COMPANY_PREFIX, "401", ++this.poSeq);
2505
3704
  const eventTime = this.now();
@@ -2537,7 +3736,9 @@ var WmsKernel = class extends FlowEngine {
2537
3736
  onOrder(spec) {
2538
3737
  const lpl = spec.content.linesPerOrder;
2539
3738
  if (!lpl) {
2540
- this.createSalesOrder([{ gtin: this.pickGtin(spec.content.skuMix), qty: this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max) }]);
3739
+ const only = this.pickGtin(spec.content.skuMix);
3740
+ if (!only) return;
3741
+ this.createSalesOrder([{ gtin: only, qty: this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max) }], spec);
2541
3742
  return;
2542
3743
  }
2543
3744
  const n = Math.min(this.randInt(lpl.min, lpl.max), spec.content.skuMix.length);
@@ -2545,10 +3746,12 @@ var WmsKernel = class extends FlowEngine {
2545
3746
  let pool = spec.content.skuMix;
2546
3747
  for (let i = 0; i < n && pool.length > 0; i++) {
2547
3748
  const gtin = this.pickGtin(pool);
3749
+ if (!gtin) break;
2548
3750
  lines.push({ gtin, qty: this.randInt(spec.content.qtyPerLine.min, spec.content.qtyPerLine.max) });
2549
3751
  pool = pool.filter((m) => m.gtin !== gtin);
2550
3752
  }
2551
- this.createSalesOrder(lines);
3753
+ if (!lines.length) return;
3754
+ this.createSalesOrder(lines, spec);
2552
3755
  }
2553
3756
  /** Command 채널 — 오더 즉시 투입(트랜잭션 프론트엔드/prescriptive). */
2554
3757
  handleCommand(cmd) {
@@ -2561,7 +3764,12 @@ var WmsKernel = class extends FlowEngine {
2561
3764
  }
2562
3765
  return super.handleCommand(cmd);
2563
3766
  }
2564
- createSalesOrder(lines) {
3767
+ /**
3768
+ * 오더 생성 — 약속(`promised`)이 있으면 **납기와 우선순위를 함께 싣는다**(표준 `EndTime`·`Priority`).
3769
+ * 없으면 싣지 않는다: 없는 약속을 만들면 그 뒤 모든 지연 판정이 거짓 위에 선다.
3770
+ */
3771
+ /** 오더 생성 — 약속(납기·우선순위)은 base `promiseOf` 가 계산한다(도메인마다 다르게 재지 않는다). */
3772
+ createSalesOrder(lines, spec) {
2565
3773
  const id = `order-${++this.orderSeq}`;
2566
3774
  const so = gdtiUri(COMPANY_PREFIX, "402", ++this.soSeq);
2567
3775
  const requested = lines.reduce((s, l) => s + l.qty, 0);
@@ -2575,7 +3783,8 @@ var WmsKernel = class extends FlowEngine {
2575
3783
  allocated: [],
2576
3784
  picked: [],
2577
3785
  shipmentEpc: null,
2578
- lines: lines.map((l) => ({ gtin: l.gtin, requested: l.qty }))
3786
+ lines: lines.map((l) => ({ gtin: l.gtin, requested: l.qty })),
3787
+ ...spec ? this.promiseOf(spec) : {}
2579
3788
  };
2580
3789
  this.orders.set(id, order);
2581
3790
  this.emitOrder(order);
@@ -2586,14 +3795,14 @@ var WmsKernel = class extends FlowEngine {
2586
3795
  * 멀티SKU 는 여러 라인의 팔레트를 한 오더로 모아 단일 출하(finalizeOrder 통합 화물).
2587
3796
  */
2588
3797
  allocate(o) {
2589
- const staging = this.nodeByType("staging");
3798
+ const staging = this.locationByType("staging");
2590
3799
  if (!staging || !o.lines) return;
2591
3800
  const chosenAll = [];
2592
3801
  for (const line of o.lines) {
2593
3802
  const already = o.allocated.filter((e) => this.items.get(e)?.gtin === line.gtin).length;
2594
3803
  const need = line.requested - already;
2595
3804
  if (need <= 0) continue;
2596
- const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === "storage").map((i) => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
3805
+ const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === "storage").map((i) => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
2597
3806
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
2598
3807
  for (const epc of chosen) {
2599
3808
  o.allocated.push(epc);
@@ -2615,9 +3824,10 @@ var WmsKernel = class extends FlowEngine {
2615
3824
  }
2616
3825
  /** 태스크 완료 — 이동 반영 후 putaway=storing, pick=picking(+전량 시 pack→stage→ship). */
2617
3826
  onTaskComplete(t) {
2618
- const item = this.items.get(t.itemEpc);
2619
- const from = this.nodes.get(t.fromNode);
2620
- const to = this.nodes.get(t.toNode);
3827
+ const item = this.itemByRef(t.itemEpc);
3828
+ if (!item) throw new Error(`task ${t.id}: item "${t.itemEpc}" vanished between the core check and the domain hook`);
3829
+ const from = this.locations.get(t.fromNode);
3830
+ const to = this.locations.get(t.toNode);
2621
3831
  from.occupancy--;
2622
3832
  to.occupancy++;
2623
3833
  item.location = to.id;
@@ -2635,7 +3845,7 @@ var WmsKernel = class extends FlowEngine {
2635
3845
  }
2636
3846
  /** 전량 피킹 → packing(조립)·staging·shipping 마감. 화물 사이트 이탈, 백오더 잔량 재할당. */
2637
3847
  finalizeOrder(order, staging) {
2638
- const shipDock = this.nodeByType("dock-ship") ?? staging;
3848
+ const shipDock = this.locationByType("dock-ship") ?? staging;
2639
3849
  const eventTime = this.now();
2640
3850
  const shipment = ssccUri(COMPANY_PREFIX, ++this.epcSeq);
2641
3851
  order.shipmentEpc = shipment;
@@ -2668,6 +3878,7 @@ var WmsKernel = class extends FlowEngine {
2668
3878
  var TRAVEL_MS2 = 2e4;
2669
3879
  var DWELL_MS = 3e4;
2670
3880
  var WINDOW_DELAY_MS = 4e4;
3881
+ var WINDOW_LENGTH_MS = 20 * 6e4;
2671
3882
  var CARGO_PER_TRAILER = 2;
2672
3883
  var CP = "0614141";
2673
3884
  var YmsKernel = class extends FlowEngine {
@@ -2685,16 +3896,16 @@ var YmsKernel = class extends FlowEngine {
2685
3896
  }
2686
3897
  /** 인바운드(하차) 자극 — 화물 적재된 트레일러 도착. */
2687
3898
  onArrival(_spec) {
2688
- this.spawnAppointment("appointment");
3899
+ this.spawnAppointment("appointment", _spec);
2689
3900
  }
2690
3901
  /** 아웃바운드(상차) 자극 — 빈 트레일러 도착(도크에서 staging 화물 적재 예정). */
2691
3902
  onOrder(_spec) {
2692
- this.spawnAppointment("appointment-out");
3903
+ this.spawnAppointment("appointment-out", _spec);
2693
3904
  }
2694
3905
  /** 게이트-인 — 트레일러 도착 → 어포인트먼트(도크도어 배정 + 창). drop 은 야드 주차 태스크, live 는 게이트 대기. */
2695
- spawnAppointment(kind) {
2696
- const gate = this.nodeByType("gate");
2697
- const doors = [...this.nodes.values()].filter((n) => n.type === "dock-door").sort((a, b) => a.id.localeCompare(b.id));
3906
+ spawnAppointment(kind, spec) {
3907
+ const gate = this.locationByType("gate");
3908
+ const doors = [...this.locations.values()].filter((n) => n.type === "dock-door").sort((a, b) => a.id.localeCompare(b.id));
2698
3909
  if (!gate || doors.length === 0) return;
2699
3910
  const inbound = kind === "appointment";
2700
3911
  const epc = graiUri(CP, "10", ++this.epcSeq);
@@ -2709,7 +3920,24 @@ var YmsKernel = class extends FlowEngine {
2709
3920
  const door = doors[this.doorRR++ % doors.length];
2710
3921
  const id = `order-${++this.orderSeq}`;
2711
3922
  const appt = gdtiUri(CP, "404", ++this.soSeq);
2712
- const order = { id, kind, status: "created", requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [epc], picked: [], dockDoor: door.id, windowStartMs: this.clockMs + WINDOW_DELAY_MS };
3923
+ const order = {
3924
+ id,
3925
+ kind,
3926
+ status: "created",
3927
+ requested: 1,
3928
+ fulfilled: 0,
3929
+ bizTransaction: appt,
3930
+ allocated: [epc],
3931
+ picked: [],
3932
+ dockDoor: door.id,
3933
+ /* 내부 스케줄 게이트(시뮬 ms) — 표준 `EarliestStartTime` 과 같은 개념의 우리 단위. */
3934
+ windowStartMs: this.clockMs + WINDOW_DELAY_MS,
3935
+ /* 표준 필드로도 함께 낸다 — 소비처(화면·집계·미러)는 표준 축으로 읽는다.
3936
+ `startTime` = 창이 열리는 때 · `endTime` = 창이 닫히는 때(= 납기). */
3937
+ startTime: new Date(Date.parse(this.now()) + WINDOW_DELAY_MS).toISOString(),
3938
+ endTime: new Date(Date.parse(this.now()) + WINDOW_DELAY_MS + WINDOW_LENGTH_MS).toISOString(),
3939
+ ...spec?.priority !== void 0 ? { priority: spec.priority } : {}
3940
+ };
2713
3941
  this.orders.set(id, order);
2714
3942
  this.apptMode.set(id, this.mode);
2715
3943
  this.emitOrder(order);
@@ -2731,15 +3959,15 @@ var YmsKernel = class extends FlowEngine {
2731
3959
  const trailer = this.items.get(o.allocated[0]);
2732
3960
  if (!trailer) return;
2733
3961
  if (this.clockMs < (o.windowStartMs ?? 0)) return;
2734
- const door = this.nodes.get(o.dockDoor);
3962
+ const door = this.locations.get(o.dockDoor);
2735
3963
  const inflight = [...this.tasks.values()].some((t) => t.status !== "completed" && t.toNode === door.id);
2736
3964
  if (door.occupancy > 0 || inflight) return;
2737
3965
  const mode = this.apptMode.get(o.id) ?? "drop";
2738
- if (mode === "drop" && this.nodes.get(trailer.location)?.type !== "yard-slot") return;
3966
+ if (mode === "drop" && this.locations.get(trailer.location)?.type !== "yard-slot") return;
2739
3967
  const outbound = o.kind === "appointment-out";
2740
3968
  const bizStep = outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading;
2741
3969
  if (outbound) {
2742
- const staging = this.nodeByType("staging");
3970
+ const staging = this.locationByType("staging");
2743
3971
  const reserved = new Set([...this.trailerCargo.values()].flat());
2744
3972
  const avail = [...this.items.values()].filter((i) => i.location === staging?.id && i.disposition === DISP.sellable && !reserved.has(i.epc)).sort((a, b) => a.epc.localeCompare(b.epc));
2745
3973
  if (!staging || avail.length < CARGO_PER_TRAILER) return;
@@ -2756,14 +3984,15 @@ var YmsKernel = class extends FlowEngine {
2756
3984
  }
2757
3985
  /** 태스크 완료 — 목적지 타입으로 분기: yard-slot=주차, dock-door=상/하차+depart, gate=출차. */
2758
3986
  onTaskComplete(t) {
2759
- const trailer = this.items.get(t.itemEpc);
2760
- const from = this.nodes.get(t.fromNode);
2761
- const to = this.nodes.get(t.toNode);
3987
+ const trailer = this.itemByRef(t.itemEpc);
3988
+ if (!trailer) throw new Error(`task ${t.id}: item "${t.itemEpc}" vanished between the core check and the domain hook`);
3989
+ const from = this.locations.get(t.fromNode);
3990
+ const to = this.locations.get(t.toNode);
2762
3991
  from.occupancy--;
2763
3992
  to.occupancy++;
2764
3993
  trailer.location = to.id;
2765
3994
  if (t.kind === "dwell") {
2766
- const gate = this.nodeByType("gate");
3995
+ const gate = this.locationByType("gate");
2767
3996
  const dep = { id: `task-${++this.taskSeq}`, kind: "depart", status: "created", itemEpc: trailer.epc, fromNode: t.toNode, toNode: gate.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: "depart", fromNode: t.toNode, toNode: gate.id }, TRAVEL_MS2), orderId: t.orderId };
2768
3997
  this.tasks.set(dep.id, dep);
2769
3998
  this.emitTask(dep);
@@ -2777,7 +4006,7 @@ var YmsKernel = class extends FlowEngine {
2777
4006
  if (to.type === "dock-door") {
2778
4007
  const order2 = t.orderId ? this.orders.get(t.orderId) : void 0;
2779
4008
  const outbound = order2?.kind === "appointment-out";
2780
- const staging = this.nodeByType("staging");
4009
+ const staging = this.locationByType("staging");
2781
4010
  const cargo = this.trailerCargo.get(trailer.epc) ?? [];
2782
4011
  this.observeDisposition([trailer.epc], DISP.in_progress, outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, to.id);
2783
4012
  if (outbound) {
@@ -2829,10 +4058,10 @@ var MES_PART_GTINS = { partA: PART_A.gtin, partB: PART_B.gtin };
2829
4058
  var MES_PRODUCT_GTINS = { p1: PRODUCTS[0].gtin, p2: PRODUCTS[1].gtin };
2830
4059
  var MES_PRODUCTS = PRODUCTS.map((p) => ({ gtin: p.gtin, label: p.key }));
2831
4060
  var ROUTE = [
2832
- { kind: "cut", node: "cut-station", resource: "cutter" },
2833
- { kind: "weld", node: "weld-station", resource: "welder" },
2834
- { kind: "paint", node: "paint-booth", resource: "painter" },
2835
- { kind: "assembly", node: "assembly-line", resource: "assembler" }
4061
+ { kind: "cut", locationType: "cut-station", resource: "cutter" },
4062
+ { kind: "weld", locationType: "weld-station", resource: "welder" },
4063
+ { kind: "paint", locationType: "paint-booth", resource: "painter" },
4064
+ { kind: "assembly", locationType: "assembly-line", resource: "assembler" }
2836
4065
  ];
2837
4066
  var MesKernel = class extends FlowEngine {
2838
4067
  wipSeq = 0;
@@ -2842,7 +4071,29 @@ var MesKernel = class extends FlowEngine {
2842
4071
  constructor(tenantId, policy = firstFitPolicy, mesSpec) {
2843
4072
  super(tenantId, policy);
2844
4073
  this.mesSpec = mesSpec;
2845
- if (mesSpec?.definition?.operations) this.loadOperations(mesSpec.definition.operations);
4074
+ if (mesSpec?.definition?.operations) {
4075
+ this.loadOperations(mesSpec.definition.operations);
4076
+ this.assertNoDoubleProduction(mesSpec.definition.operations);
4077
+ }
4078
+ }
4079
+ /**
4080
+ * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
4081
+ *
4082
+ * MES 는 레시피로 산출을 만든다: WIP 사슬(스텝마다 앞의 WIP 을 먹고 다음 WIP 을 낸다) · **직렬번호**
4083
+ * (회사 프리픽스 + 일련번호) · **수율**(양품/불량이 처분을 가른다) · 생산오더 연결. 코어의 일반
4084
+ * 자재 명세(`use: 'produced'`)는 **비직렬 클래스+수량**이라 그 넷을 표현하지 못한다.
4085
+ *
4086
+ * 그래서 MES 를 일반 선언으로 옮기지 않는다 — 옮기면 정체성·수율·오더 연결을 **잃는다**(§4-20).
4087
+ * 대신 둘이 겹치면 **같은 산출을 두 번 만들게** 되므로, 그 조합을 기동 시점에 거부한다.
4088
+ * 런타임에 조용히 두 배가 되는 것보다 기동이 실패하는 편이 낫다(재고가 거짓이 되면 그 위의
4089
+ * 모든 계산이 거짓이 된다).
4090
+ */
4091
+ assertNoDoubleProduction(ops) {
4092
+ const bad = ops.filter((o) => (o.materialSpecification ?? []).some((m) => m.use === "produced")).map((o) => o.key);
4093
+ if (!bad.length) return;
4094
+ throw new Error(
4095
+ `MES recipe already produces outputs \u2014 operations [${bad.join(", ")}] must not also declare materialSpecification use:'produced' (that would create the same output twice). Consumption specs are fine; declare production in the recipe.`
4096
+ );
2846
4097
  }
2847
4098
  productOf(gtin) {
2848
4099
  return PRODUCTS.find((p) => p.gtin === gtin);
@@ -2857,12 +4108,12 @@ var MesKernel = class extends FlowEngine {
2857
4108
  if (cmd.type === MES_CMD.changeover) {
2858
4109
  const a = cmd.args;
2859
4110
  if (!a?.resourceId || !a?.gtin) return { commandId: cmd.commandId, accepted: false, errorCode: "changeover-needs-args", error: "mes.changeover: resourceId and gtin required" };
2860
- const m = this.movers.get(a.resourceId);
4111
+ const m = this.equipment.get(a.resourceId);
2861
4112
  if (!m) return { commandId: cmd.commandId, accepted: false, errorCode: "resource-not-found", errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
2862
4113
  if (m.lastChangeoverKey !== a.gtin) {
2863
4114
  m.setupMs += DEFAULT_SETUP_MS;
2864
4115
  m.lastChangeoverKey = a.gtin;
2865
- this.emitMover(m);
4116
+ this.emitEquipment(m);
2866
4117
  }
2867
4118
  return { commandId: cmd.commandId, accepted: true };
2868
4119
  }
@@ -2871,7 +4122,7 @@ var MesKernel = class extends FlowEngine {
2871
4122
  /** 부품 수령(다품종) — skuMix 의 gtin 으로 부품 종류 결정. */
2872
4123
  onArrival(spec) {
2873
4124
  if (this.mesSpec) return this.onArrivalDef(spec);
2874
- const rawStore = this.nodeByType("raw-store");
4125
+ const rawStore = this.locationByType("raw-store");
2875
4126
  if (!rawStore) return;
2876
4127
  const gtin = this.pickGtin(spec.content.skuMix);
2877
4128
  const part = [PART_A, PART_B].find((p) => p.gtin === gtin);
@@ -2887,7 +4138,7 @@ var MesKernel = class extends FlowEngine {
2887
4138
  const product = PRODUCTS[this.orderSeq % PRODUCTS.length];
2888
4139
  const id = `order-${++this.orderSeq}`;
2889
4140
  const wo = gdtiUri(CP2, "403", ++this.soSeq);
2890
- const order = { id, kind: "workorder", status: "created", gtin: product.gtin, requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [] };
4141
+ const order = { id, kind: "workorder", status: "created", gtin: product.gtin, requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
2891
4142
  this.orders.set(id, order);
2892
4143
  this.emitOrder(order);
2893
4144
  }
@@ -2895,12 +4146,12 @@ var MesKernel = class extends FlowEngine {
2895
4146
  allocate(o) {
2896
4147
  if (this.mesSpec) return this.allocateDef(o);
2897
4148
  const s0 = ROUTE[0];
2898
- const first = this.nodeByType(s0.node);
4149
+ const first = this.locationByType(s0.locationType);
2899
4150
  const product = this.productOf(o.gtin);
2900
4151
  if (!first || !product) return;
2901
4152
  const picks = [];
2902
4153
  for (const line of product.bom) {
2903
- const available = [...this.items.values()].filter((i) => i.gtin === line.part.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === "raw-store").map((i) => ({ epc: i.epc, location: i.location, qty: 1 }));
4154
+ const available = [...this.items.values()].filter((i) => i.gtin === line.part.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === "raw-store").map((i) => ({ epc: i.epc, location: i.location, qty: 1 }));
2904
4155
  const chosen = this.policy.selectStock({ gtin: line.part.gtin, qty: line.qty, available });
2905
4156
  if (chosen.length < line.qty) return;
2906
4157
  picks.push(...chosen);
@@ -2914,23 +4165,36 @@ var MesKernel = class extends FlowEngine {
2914
4165
  }
2915
4166
  /** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
2916
4167
  emitStation(o, stage, itemEpc, changeoverKey) {
2917
- const node = this.nodeByType(stage.node);
2918
- const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: "created", itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: node.id, toNode: node.id, resourceKind: stage.resource }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: stage.resource, changeoverKey, setupMs: this.paramDuration(stage.kind, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: "process" };
4168
+ const loc = this.locationByType(stage.locationType);
4169
+ const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: "created", itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: loc.id, toNode: loc.id, resourceKind: stage.resource }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: stage.resource, changeoverKey, setupMs: this.paramDuration(stage.kind, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: "process" };
2919
4170
  this.tasks.set(task.id, task);
2920
4171
  this.emitTask(task);
2921
4172
  }
2922
4173
  /** op 완료 = 변환. 라우트 인덱스로 분기: 첫=BOM 소비→WIP, 중간=WIP→WIP, 마지막(조립)=WIP→완성차(수율→OEE 품질). */
4174
+ /**
4175
+ * MES 는 완료 시점에 **오더와 제품 정의**를 딛고 선다 — 둘 중 하나만 없어도 끝맺을 수 없다.
4176
+ *
4177
+ * 씨앗이 이미 이행된 오더를 심지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
4178
+ * 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서 던졌고 **그 예외
4179
+ * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
4180
+ */
4181
+ canComplete(t) {
4182
+ if (this.mesSpec) return !!(t.orderId && this.orders.get(t.orderId));
4183
+ const order = t.orderId ? this.orders.get(t.orderId) : void 0;
4184
+ return !!order && !!this.productOf(order.gtin);
4185
+ }
2923
4186
  onTaskComplete(t) {
2924
4187
  if (this.mesSpec) return this.onTaskCompleteDef(t);
2925
4188
  const order = this.orders.get(t.orderId);
2926
- const product = this.productOf(order.gtin);
4189
+ const product = order && this.productOf(order.gtin);
4190
+ if (!order || !product) throw new Error(`task ${t.id}: order/product vanished between the core check and the domain hook`);
2927
4191
  const i = ROUTE.findIndex((s) => s.kind === t.kind);
2928
- const node = this.nodes.get(t.toNode);
4192
+ const loc = this.locations.get(t.toNode);
2929
4193
  const isLast = i === ROUTE.length - 1;
2930
4194
  if (!isLast) {
2931
4195
  const inputs = order.allocated.slice();
2932
4196
  const wip2 = sgtinUri(CP2, WIP_ITEMREF, ++this.wipSeq);
2933
- this.transform(inputs, [{ epc: wip2, gtin: WIP_GTIN, qty: 1, location: node.id, disposition: DISP.in_progress }], { bizStep: MES_BIZSTEP.producing, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
4197
+ this.transform(inputs, [{ epc: wip2, gtin: WIP_GTIN, qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep: MES_BIZSTEP.producing, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
2934
4198
  order.allocated = [wip2];
2935
4199
  const next = ROUTE[i + 1];
2936
4200
  this.emitStation(order, next, wip2, product.gtin);
@@ -2938,13 +4202,13 @@ var MesKernel = class extends FlowEngine {
2938
4202
  this.emitOrder(order);
2939
4203
  return;
2940
4204
  }
2941
- const fgStore = this.nodeByType("fg-store");
4205
+ const fgStore = this.locationByType("fg-store");
2942
4206
  const wip = order.allocated[0];
2943
4207
  const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
2944
4208
  this.recordOutput(t.resource, good);
2945
4209
  const disp = good ? DISP.sellable : DISP.non_sellable;
2946
4210
  const outputEpc = sgtinUri(CP2, product.ref, ++this.prodSeq);
2947
- this.transform([wip], [{ epc: outputEpc, gtin: product.gtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep: MES_BIZSTEP.producing, disposition: disp, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
4211
+ this.transform([wip], [{ epc: outputEpc, gtin: product.gtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep: MES_BIZSTEP.producing, disposition: disp, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
2948
4212
  this.emit(objectEvent({ eventTime: this.now(), action: "OBSERVE", bizStep: MES_BIZSTEP.storing, disposition: disp, epcList: [outputEpc], quantityList: [{ epcClass: product.gtin, quantity: 1 }], readPoint: fgStore.id, bizLocation: fgStore.id }));
2949
4213
  order.allocated = [];
2950
4214
  order.fulfilled = 1;
@@ -2963,6 +4227,17 @@ var MesKernel = class extends FlowEngine {
2963
4227
  serialOf(materialKey, serial) {
2964
4228
  return sgtinUri(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey], serial);
2965
4229
  }
4230
+ /**
4231
+ * 라우트를 용량 계산에 알려 준다 — 수율을 거슬러 올릴 때 순서가 곧 계산이다.
4232
+ *
4233
+ * 순서를 모르면 선언 순서대로 세는데, 그러면 하류에서 잃는 몫이 엉뚱한 공정에 얹힌다(조용히
4234
+ * 틀린다). 생산 정의를 가진 커널만 이 답을 안다.
4235
+ */
4236
+ routeKeys() {
4237
+ if (!this.mesSpec) return void 0;
4238
+ const d = this.mesSpec.definition;
4239
+ return d.routes?.find((r) => r.key === this.recipeDef().route)?.steps;
4240
+ }
2966
4241
  /** recipe.route → 오퍼레이션 시퀀스 해소. */
2967
4242
  routeOps() {
2968
4243
  const d = this.mesSpec.definition;
@@ -2971,9 +4246,10 @@ var MesKernel = class extends FlowEngine {
2971
4246
  }
2972
4247
  /** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면 raw-store 에 생성. */
2973
4248
  onArrivalDef(spec) {
2974
- const rawStore = this.nodeByType("raw-store");
4249
+ const rawStore = this.locationByType("raw-store");
2975
4250
  if (!rawStore) return;
2976
4251
  const gtin = this.pickGtin(spec.content.skuMix);
4252
+ if (!gtin) return;
2977
4253
  const inputKey = this.recipeDef().inputs.map((i) => i.material).find((k) => this.classOf(k) === gtin);
2978
4254
  if (!inputKey) return;
2979
4255
  const epc = this.serialOf(inputKey, ++this.epcSeq);
@@ -2986,19 +4262,19 @@ var MesKernel = class extends FlowEngine {
2986
4262
  const rc = this.recipeDef();
2987
4263
  const id = `order-${++this.orderSeq}`;
2988
4264
  const wo = gdtiUri(this.mesSpec.companyPrefix, "403", ++this.soSeq);
2989
- const order = { id, kind: "workorder", status: "created", gtin: this.classOf(rc.outputs[0].material), requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [] };
4265
+ const order = { id, kind: "workorder", status: "created", gtin: this.classOf(rc.outputs[0].material), requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
2990
4266
  this.orders.set(id, order);
2991
4267
  this.emitOrder(order);
2992
4268
  }
2993
4269
  /** 정의 모드 할당 — 레시피 입력 BOM 전량 확보 후 첫 라우트 스텝 태스크. */
2994
4270
  allocateDef(o) {
2995
4271
  const ops = this.routeOps();
2996
- if (!ops.length || !ops[0].nodeType || !this.nodeByType(ops[0].nodeType)) return;
4272
+ if (!ops.length || !ops[0].locationType || !this.locationByType(ops[0].locationType)) return;
2997
4273
  const rc = this.recipeDef();
2998
4274
  const picks = [];
2999
4275
  for (const line of rc.inputs) {
3000
4276
  const g = this.classOf(line.material);
3001
- const available = [...this.items.values()].filter((i) => i.gtin === g && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === "raw-store").map((i) => ({ epc: i.epc, location: i.location, qty: 1 }));
4277
+ const available = [...this.items.values()].filter((i) => i.gtin === g && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === "raw-store").map((i) => ({ epc: i.epc, location: i.location, qty: 1 }));
3002
4278
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
3003
4279
  if (chosen.length < line.qty) return;
3004
4280
  picks.push(...chosen);
@@ -3011,25 +4287,26 @@ var MesKernel = class extends FlowEngine {
3011
4287
  this.emitOrder(o);
3012
4288
  }
3013
4289
  emitStationDef(o, op, itemEpc) {
3014
- const node = this.nodeByType(op.nodeType);
3015
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: node.id, toNode: node.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
4290
+ const loc = this.locationByType(op.locationType);
4291
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
3016
4292
  this.tasks.set(task.id, task);
3017
4293
  this.emitTask(task);
3018
4294
  }
3019
4295
  /** 정의 모드 완료 — 라우트 인덱스: 중간=WIP 변환+다음 스텝, 마지막=완제품(수율). */
3020
4296
  onTaskCompleteDef(t) {
3021
4297
  const order = this.orders.get(t.orderId);
4298
+ if (!order) throw new Error(`task ${t.id}: order vanished between the core check and the domain hook`);
3022
4299
  const rc = this.recipeDef();
3023
4300
  const ops = this.routeOps();
3024
4301
  const i = ops.findIndex((s) => s.key === t.kind);
3025
- const node = this.nodes.get(t.toNode);
4302
+ const loc = this.locations.get(t.toNode);
3026
4303
  const bizStep = ops[i]?.bizStep ?? MES_BIZSTEP.producing;
3027
4304
  const isLast = i === ops.length - 1;
3028
4305
  if (!isLast) {
3029
4306
  const inputs = order.allocated.slice();
3030
4307
  const wip2 = sgtinUri(this.mesSpec.companyPrefix, "WIP", ++this.wipSeq);
3031
4308
  const wipGtin = sgtinClass(this.mesSpec.companyPrefix, "WIP");
3032
- this.transform(inputs, [{ epc: wip2, gtin: wipGtin, qty: 1, location: node.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
4309
+ this.transform(inputs, [{ epc: wip2, gtin: wipGtin, qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
3033
4310
  order.allocated = [wip2];
3034
4311
  const next = ops[i + 1];
3035
4312
  this.emitStationDef(order, next, wip2);
@@ -3037,14 +4314,14 @@ var MesKernel = class extends FlowEngine {
3037
4314
  this.emitOrder(order);
3038
4315
  return;
3039
4316
  }
3040
- const fgStore = this.nodeByType("fg-store");
4317
+ const fgStore = this.locationByType("fg-store");
3041
4318
  const wip = order.allocated[0];
3042
4319
  const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
3043
4320
  this.recordOutput(t.resource, good);
3044
4321
  const disp = good ? DISP.sellable : DISP.non_sellable;
3045
4322
  const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
3046
4323
  const outGtin = this.classOf(rc.outputs[0].material);
3047
- this.transform([wip], [{ epc: outEpc, gtin: outGtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep, disposition: disp, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
4324
+ this.transform([wip], [{ epc: outEpc, gtin: outGtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep, disposition: disp, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
3048
4325
  this.emit(objectEvent({ eventTime: this.now(), action: "OBSERVE", bizStep: MES_BIZSTEP.storing, disposition: disp, epcList: [outEpc], quantityList: [{ epcClass: outGtin, quantity: 1 }], readPoint: fgStore.id, bizLocation: fgStore.id }));
3049
4326
  order.allocated = [];
3050
4327
  order.fulfilled = 1;
@@ -3052,6 +4329,43 @@ var MesKernel = class extends FlowEngine {
3052
4329
  this.emitOrder(order);
3053
4330
  }
3054
4331
  };
4332
+
4333
+ // src/vocabulary.ts
4334
+ var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
4335
+ var VOCABULARY_EXCEPTIONS = [
4336
+ /* ── 저널 와이어 필드 — append-only 역사이므로 이름을 바꾸지 않는다 ───────
4337
+ * 같은 사실이 시점에 따라 다른 키로 들어가면 낡은 이름보다 나쁘다. 개명은 이벤트 스키마
4338
+ * 버저닝과 함께 다룬다. (개명 시점 실측: moverId 8,965행 · fromNode/toNode 9,679행) */
4339
+ { token: "moverId", why: "journal wire field \u2014 renaming would mix two keys for one fact across history" },
4340
+ { token: "fromNode", why: "journal wire field (task.status payload)" },
4341
+ { token: "toNode", why: "journal wire field (task.status payload)" },
4342
+ /* ── 씬 컴포넌트 타입 — 보드에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
4343
+ * 보드 7개가 이 타입으로 컴포넌트를 담고 있어 개명하면 그 컴포넌트가 조용히 안 그려진다.
4344
+ * 그리고 씬에서 이 이름은 자원이 아니라 **움직임 표현**을 가리킨다(표준과 충돌 아님). */
4345
+ { token: "twin-mover", why: "scene component type persisted in boards; names a motion representation, not a resource" },
4346
+ { token: "TwinMover", why: "class backing the persisted scene component type above" },
4347
+ /* ── 플랫폼·언어 어휘 — 우리 도메인 낱말이 아니다 ─────────────────────── */
4348
+ { token: "node_modules", why: "npm path" },
4349
+ { token: "node:", why: "Node.js builtin module specifier" },
4350
+ { token: "nodemon", why: "tool name" },
4351
+ { token: "Node.js", why: "platform name" },
4352
+ { token: "NodeJS", why: "TypeScript global namespace" },
4353
+ { token: "nodeName", why: "DOM API" },
4354
+ { token: "childNodes", why: "DOM API" },
4355
+ { token: "appendChild", why: "DOM API (no match, kept for clarity)" }
4356
+ ];
4357
+ var GUARD_PRAGMA = "vocabulary-guard: allow";
4358
+ function retiredVocabularyIn(line) {
4359
+ if (line.includes(GUARD_PRAGMA)) return [];
4360
+ let rest = line;
4361
+ for (const { token } of VOCABULARY_EXCEPTIONS) rest = rest.split(token).join(" ");
4362
+ const hits = [];
4363
+ for (const word of RETIRED_VOCABULARY) {
4364
+ const re = new RegExp(`[A-Za-z_]*${word}[A-Za-z_]*`, "g");
4365
+ for (const m of rest.matchAll(re)) hits.push(m[0]);
4366
+ }
4367
+ return hits;
4368
+ }
3055
4369
  // Annotate the CommonJS export names for ESM import in node:
3056
4370
  0 && (module.exports = {
3057
4371
  BIZSTEP,
@@ -3060,65 +4374,99 @@ var MesKernel = class extends FlowEngine {
3060
4374
  BTT_PRODORDER,
3061
4375
  CAPABILITIES,
3062
4376
  CAPABILITY_KEYS,
4377
+ CBV_BIZSTEP,
3063
4378
  CMD,
3064
4379
  DISP,
3065
4380
  DOMAIN_CATALOG,
3066
4381
  DOMAIN_SYSTEMS,
3067
4382
  EPCIS_CONTEXT,
4383
+ EQUIPMENT_LEVEL,
3068
4384
  EventJournal,
3069
4385
  FlowEngine,
4386
+ GUARD_PRAGMA,
3070
4387
  ILMD_ATTR,
4388
+ LOCATION_SATURATION_NEAR,
4389
+ MATERIAL_PROPERTY,
3071
4390
  MES_BIZSTEP,
3072
- MES_NODE_TYPES,
4391
+ MES_LOCATION_TYPES,
3073
4392
  MES_PART_GTINS,
3074
4393
  MES_PRODUCTS,
3075
4394
  MES_PRODUCT_GTINS,
3076
4395
  MES_TYPES,
3077
4396
  MesKernel,
3078
- NODE_SATURATION_NEAR,
3079
4397
  OP_EVENT,
3080
4398
  OP_PARAM,
3081
4399
  ObservedReducer,
4400
+ PRIORITY_UNSET,
4401
+ RETIRED_VOCABULARY,
3082
4402
  StateProjector,
3083
4403
  TwinHistory,
3084
4404
  TwinObserver,
3085
4405
  TwinRuntime,
3086
4406
  UTC_OFFSET,
3087
- WMS_NODE_TYPES,
4407
+ VOCABULARY_EXCEPTIONS,
4408
+ WMS_LOCATION_TYPES,
3088
4409
  WMS_TYPES,
3089
4410
  WmsKernel,
3090
4411
  YARD_BIZSTEP,
3091
- YMS_NODE_TYPES,
4412
+ YMS_LOCATION_TYPES,
3092
4413
  YMS_TYPES,
3093
4414
  YmsKernel,
4415
+ activeShiftAt,
4416
+ activeShiftOf,
3094
4417
  aggregationEvent,
4418
+ analyzeCapacity,
3095
4419
  capabilitiesForType,
4420
+ classClosure,
3096
4421
  compareStates,
3097
4422
  computeOee,
3098
4423
  constantDuration,
4424
+ conversionFactorOf,
3099
4425
  counterfactualAt,
3100
4426
  deriveAttentions,
4427
+ dueStatusOf,
4428
+ effectivityAt,
3101
4429
  fefoPolicy,
3102
4430
  firstFitPolicy,
4431
+ foldJobResponses,
3103
4432
  foldTaskRecords,
3104
4433
  gdtiUri,
3105
4434
  graiUri,
4435
+ hierarchyOf,
4436
+ inWorkCalendar,
4437
+ inWorkCalendarAt,
3106
4438
  ingest,
4439
+ isEquipmentLevel,
4440
+ isoDurationHours,
4441
+ itemKeyOf,
3107
4442
  lgtinClass,
4443
+ locationStatusOf,
3108
4444
  mapRecord,
4445
+ minuteOfDayAt,
3109
4446
  monteCarloForecast,
3110
- nodeStatusOf,
3111
4447
  objectEvent,
4448
+ offCalendarAt,
4449
+ offCalendarReasonAt,
3112
4450
  parseEpc,
3113
4451
  parseIsoDuration,
3114
4452
  partialFitPolicy,
4453
+ priorityRank,
4454
+ quantityIn,
4455
+ readBoardAssets,
4456
+ readBoardEquipment,
4457
+ readBoardLocations,
3115
4458
  replay,
4459
+ replaySegments,
4460
+ retiredVocabularyIn,
3116
4461
  sgtinClass,
3117
4462
  sgtinUri,
3118
4463
  ssccUri,
3119
4464
  stateFieldsOf,
4465
+ subLotIdOf,
3120
4466
  transactionEvent,
3121
4467
  transformationEvent,
3122
4468
  validateDomainDefinition,
3123
- validateEpcisEvent
4469
+ validateEpcisEvent,
4470
+ weekdayAt,
4471
+ workingTimeOfWeek
3124
4472
  });