@operato/twin-kernel 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.d.ts +237 -1
- package/dist/contract.js +21 -0
- package/dist/counterfactual.d.ts +2 -0
- package/dist/counterfactual.js +7 -3
- package/dist/domain-definition.d.ts +84 -1
- package/dist/domain-definition.js +11 -0
- package/dist/duration-estimator.d.ts +26 -2
- package/dist/epcis.d.ts +185 -6
- package/dist/epcis.js +175 -12
- package/dist/flow-engine.d.ts +265 -3
- package/dist/flow-engine.js +731 -28
- package/dist/forecast.d.ts +8 -0
- package/dist/forecast.js +9 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/iso-duration.d.ts +5 -0
- package/dist/iso-duration.js +43 -0
- package/dist/kernel.js +15 -4
- package/dist/mes-kernel.d.ts +2 -2
- package/dist/mes-kernel.js +22 -15
- package/dist/observed-reducer.d.ts +103 -0
- package/dist/observed-reducer.js +374 -0
- package/dist/state-projector.d.ts +2 -37
- package/dist/state-projector.js +5 -121
- package/dist/twin-observer.d.ts +2 -0
- package/dist/twin-observer.js +9 -3
- package/dist/yms-kernel.js +4 -2
- package/dist-cjs/index.cjs +1182 -178
- package/package.json +1 -1
package/dist-cjs/index.cjs
CHANGED
|
@@ -32,6 +32,7 @@ __export(index_exports, {
|
|
|
32
32
|
EPCIS_CONTEXT: () => EPCIS_CONTEXT,
|
|
33
33
|
EventJournal: () => EventJournal,
|
|
34
34
|
FlowEngine: () => FlowEngine,
|
|
35
|
+
ILMD_ATTR: () => ILMD_ATTR,
|
|
35
36
|
MES_BIZSTEP: () => MES_BIZSTEP,
|
|
36
37
|
MES_NODE_TYPES: () => MES_NODE_TYPES,
|
|
37
38
|
MES_PART_GTINS: () => MES_PART_GTINS,
|
|
@@ -39,8 +40,11 @@ __export(index_exports, {
|
|
|
39
40
|
MES_PRODUCT_GTINS: () => MES_PRODUCT_GTINS,
|
|
40
41
|
MES_TYPES: () => MES_TYPES,
|
|
41
42
|
MesKernel: () => MesKernel,
|
|
43
|
+
NODE_SATURATION_NEAR: () => NODE_SATURATION_NEAR,
|
|
42
44
|
OP_EVENT: () => OP_EVENT,
|
|
43
|
-
|
|
45
|
+
OP_PARAM: () => OP_PARAM,
|
|
46
|
+
ObservedReducer: () => ObservedReducer,
|
|
47
|
+
StateProjector: () => ObservedReducer,
|
|
44
48
|
TwinHistory: () => TwinHistory,
|
|
45
49
|
TwinObserver: () => TwinObserver,
|
|
46
50
|
TwinRuntime: () => TwinRuntime,
|
|
@@ -65,9 +69,13 @@ __export(index_exports, {
|
|
|
65
69
|
gdtiUri: () => gdtiUri,
|
|
66
70
|
graiUri: () => graiUri,
|
|
67
71
|
ingest: () => ingest,
|
|
72
|
+
lgtinClass: () => lgtinClass,
|
|
68
73
|
mapRecord: () => mapRecord,
|
|
69
74
|
monteCarloForecast: () => monteCarloForecast,
|
|
75
|
+
nodeStatusOf: () => nodeStatusOf,
|
|
70
76
|
objectEvent: () => objectEvent,
|
|
77
|
+
parseEpc: () => parseEpc,
|
|
78
|
+
parseIsoDuration: () => parseIsoDuration,
|
|
71
79
|
partialFitPolicy: () => partialFitPolicy,
|
|
72
80
|
replay: () => replay,
|
|
73
81
|
sgtinClass: () => sgtinClass,
|
|
@@ -82,9 +90,20 @@ __export(index_exports, {
|
|
|
82
90
|
module.exports = __toCommonJS(index_exports);
|
|
83
91
|
|
|
84
92
|
// src/contract.ts
|
|
93
|
+
var NODE_SATURATION_NEAR = 0.9;
|
|
94
|
+
function nodeStatusOf(n) {
|
|
95
|
+
const cap = n.capacity;
|
|
96
|
+
if (!(typeof cap === "number" && cap > 0)) return void 0;
|
|
97
|
+
const r = (n.occupancy ?? 0) / cap;
|
|
98
|
+
return r >= 1 ? "full" : r >= NODE_SATURATION_NEAR ? "near-full" : "available";
|
|
99
|
+
}
|
|
85
100
|
var OP_EVENT = {
|
|
86
101
|
task: "task.status",
|
|
87
102
|
equipment: "equipment.status",
|
|
103
|
+
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
104
|
+
person: "person.status",
|
|
105
|
+
/** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
|
|
106
|
+
asset: "asset.status",
|
|
88
107
|
order: "order.status",
|
|
89
108
|
quality: "quality.output"
|
|
90
109
|
// 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
|
|
@@ -111,6 +130,13 @@ var CMD = {
|
|
|
111
130
|
};
|
|
112
131
|
|
|
113
132
|
// src/domain-definition.ts
|
|
133
|
+
var OP_PARAM = {
|
|
134
|
+
/** 양품률(0..1, 무차원). 없으면 커널 기본값 — 기본값을 쓴 사실은 `specCoverage()` 가 밝힌다. */
|
|
135
|
+
yield: "yield",
|
|
136
|
+
/** 셋업·체인지오버 소요(ISO 8601 기간 문자열). ISA-95 는 셋업을 별도 세그먼트로도 표현하지만,
|
|
137
|
+
* 현재 커널은 작업에 붙는 셋업으로 다루므로 모수로 받는다. */
|
|
138
|
+
setupDuration: "setupDuration"
|
|
139
|
+
};
|
|
114
140
|
var INTENTS = ["transport", "process", "dwell"];
|
|
115
141
|
function dupes(keys) {
|
|
116
142
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -179,6 +205,9 @@ function compareStates(predicted, actual) {
|
|
|
179
205
|
}
|
|
180
206
|
|
|
181
207
|
// src/counterfactual.ts
|
|
208
|
+
function clockOf(twin) {
|
|
209
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
210
|
+
}
|
|
182
211
|
var TwinHistory = class {
|
|
183
212
|
live;
|
|
184
213
|
tickMs;
|
|
@@ -189,7 +218,7 @@ var TwinHistory = class {
|
|
|
189
218
|
}
|
|
190
219
|
/** 현재를 체크포인트로 저장(호스트가 주기적으로 호출). */
|
|
191
220
|
checkpoint() {
|
|
192
|
-
this.checkpoints.push({ simMs: this.live
|
|
221
|
+
this.checkpoints.push({ simMs: clockOf(this.live), twin: this.live.fork() });
|
|
193
222
|
}
|
|
194
223
|
get count() {
|
|
195
224
|
return this.checkpoints.length;
|
|
@@ -201,7 +230,7 @@ var TwinHistory = class {
|
|
|
201
230
|
if (!best) return void 0;
|
|
202
231
|
const t = best.twin.fork();
|
|
203
232
|
let guard = 0;
|
|
204
|
-
while (t
|
|
233
|
+
while (clockOf(t) < simMs && guard++ < 1e6) t.tick(this.tickMs);
|
|
205
234
|
return t;
|
|
206
235
|
}
|
|
207
236
|
};
|
|
@@ -215,7 +244,7 @@ function counterfactualAt(history, atSimMs, opts) {
|
|
|
215
244
|
const baseline = base.fork();
|
|
216
245
|
const run = (t) => {
|
|
217
246
|
let g = 0;
|
|
218
|
-
while (t
|
|
247
|
+
while (clockOf(t) < target && g++ < 1e6) t.tick(step);
|
|
219
248
|
};
|
|
220
249
|
run(withAlt);
|
|
221
250
|
run(baseline);
|
|
@@ -223,8 +252,11 @@ function counterfactualAt(history, atSimMs, opts) {
|
|
|
223
252
|
}
|
|
224
253
|
|
|
225
254
|
// src/forecast.ts
|
|
255
|
+
function clockOf2(twin) {
|
|
256
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
257
|
+
}
|
|
226
258
|
function monteCarloForecast(twin, opts) {
|
|
227
|
-
const now = twin
|
|
259
|
+
const now = clockOf2(twin);
|
|
228
260
|
const step = opts.tickMs ?? 1e3;
|
|
229
261
|
const baseSeed = opts.scenario.seed ?? 1;
|
|
230
262
|
const samples = [];
|
|
@@ -234,7 +266,7 @@ function monteCarloForecast(twin, opts) {
|
|
|
234
266
|
fc.scenario.start();
|
|
235
267
|
const target = now + opts.horizonMs;
|
|
236
268
|
let guard = 0;
|
|
237
|
-
while (fc
|
|
269
|
+
while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
|
|
238
270
|
samples.push(opts.metric(fc.getSnapshot()));
|
|
239
271
|
}
|
|
240
272
|
return summarize(opts.runs, samples);
|
|
@@ -247,6 +279,9 @@ function summarize(runs, samples) {
|
|
|
247
279
|
}
|
|
248
280
|
|
|
249
281
|
// src/twin-observer.ts
|
|
282
|
+
function clockOf3(twin) {
|
|
283
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
284
|
+
}
|
|
250
285
|
var TwinObserver = class {
|
|
251
286
|
live;
|
|
252
287
|
opts;
|
|
@@ -257,7 +292,7 @@ var TwinObserver = class {
|
|
|
257
292
|
}
|
|
258
293
|
/** 관측 1회 — 만기 예측을 실제와 대조(발산 알림) + 새 예측 생성. 호스트가 주기적으로 호출. */
|
|
259
294
|
observe() {
|
|
260
|
-
const now = this.live
|
|
295
|
+
const now = clockOf3(this.live);
|
|
261
296
|
const due = this.pending.filter((p) => p.horizonSimMs <= now);
|
|
262
297
|
this.pending = this.pending.filter((p) => p.horizonSimMs > now);
|
|
263
298
|
for (const p of due) {
|
|
@@ -268,8 +303,9 @@ var TwinObserver = class {
|
|
|
268
303
|
const step = this.opts.tickMs ?? 1e3;
|
|
269
304
|
const target = now + this.opts.horizonMs;
|
|
270
305
|
let guard = 0;
|
|
271
|
-
while (fc
|
|
272
|
-
|
|
306
|
+
while (clockOf3(fc) < target && guard++ < 1e6) fc.tick(step);
|
|
307
|
+
const predicted = fc.getSnapshot();
|
|
308
|
+
this.pending.push({ madeAtSimMs: now, horizonSimMs: predicted.simClockMs, predicted });
|
|
273
309
|
}
|
|
274
310
|
/** 대기 중(아직 만기 안 된) 예측 수 — 진단용. */
|
|
275
311
|
get pendingCount() {
|
|
@@ -277,19 +313,243 @@ var TwinObserver = class {
|
|
|
277
313
|
}
|
|
278
314
|
};
|
|
279
315
|
|
|
280
|
-
// src/
|
|
281
|
-
var
|
|
316
|
+
// src/epcis.ts
|
|
317
|
+
var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
|
|
318
|
+
var UTC_OFFSET = "+00:00";
|
|
319
|
+
var DISP = {
|
|
320
|
+
in_progress: "urn:epcglobal:cbv:disp:in_progress",
|
|
321
|
+
sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
|
|
322
|
+
reserved: "urn:epcglobal:cbv:disp:reserved",
|
|
323
|
+
in_transit: "urn:epcglobal:cbv:disp:in_transit",
|
|
324
|
+
non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
|
|
325
|
+
// 불량/scrap
|
|
326
|
+
};
|
|
327
|
+
function ssccUri(companyPrefix, serial) {
|
|
328
|
+
return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
|
|
329
|
+
}
|
|
330
|
+
function sgtinClass(companyPrefix, itemRef) {
|
|
331
|
+
return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
|
|
332
|
+
}
|
|
333
|
+
var ILMD_ATTR = {
|
|
334
|
+
/** 유통기한·만료(로트 단위). */
|
|
335
|
+
expiry: "cbvmda:itemExpirationDate",
|
|
336
|
+
/** 로트·배치 번호(직렬 개체에 로트를 붙일 때). */
|
|
337
|
+
lot: "cbvmda:lotNumber"
|
|
338
|
+
};
|
|
339
|
+
function lgtinClass(companyPrefix, itemRefAndIndicator, lot) {
|
|
340
|
+
return `urn:epc:class:lgtin:${companyPrefix}.${itemRefAndIndicator}.${encodeURIComponent(lot)}`;
|
|
341
|
+
}
|
|
342
|
+
function parseEpc(uri) {
|
|
343
|
+
const raw = String(uri ?? "");
|
|
344
|
+
const cls = raw.match(/^urn:epc:class:lgtin:(.+)$/);
|
|
345
|
+
if (cls) {
|
|
346
|
+
const seg = cls[1].split(".");
|
|
347
|
+
const lot = seg.slice(2).join(".");
|
|
348
|
+
return {
|
|
349
|
+
scheme: "lgtin",
|
|
350
|
+
instance: false,
|
|
351
|
+
gtinKey: seg.slice(0, 2).join("."),
|
|
352
|
+
lot: lot ? decodeURIComponent(lot) : void 0,
|
|
353
|
+
uri: raw
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const pat = raw.match(/^urn:epc:idpat:sgtin:(.+)$/);
|
|
357
|
+
if (pat) {
|
|
358
|
+
const seg = pat[1].split(".");
|
|
359
|
+
return { scheme: "idpat", instance: false, gtinKey: seg.slice(0, 2).join("."), uri: raw };
|
|
360
|
+
}
|
|
361
|
+
const id = raw.match(/^urn:epc:id:([a-z]+):(.+)$/);
|
|
362
|
+
if (id) {
|
|
363
|
+
const scheme = id[1];
|
|
364
|
+
const seg = id[2].split(".");
|
|
365
|
+
const known = ["sgtin", "sscc", "gdti", "grai", "giai", "sgln"].includes(scheme);
|
|
366
|
+
return {
|
|
367
|
+
scheme: known ? scheme : "unknown",
|
|
368
|
+
instance: true,
|
|
369
|
+
...scheme === "sgtin" ? { gtinKey: seg.slice(0, 2).join("."), serial: seg[2] } : { serial: seg.slice(1).join(".") },
|
|
370
|
+
uri: raw
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return { scheme: "unknown", instance: false, uri: raw };
|
|
374
|
+
}
|
|
375
|
+
function gdtiUri(companyPrefix, docType, serial) {
|
|
376
|
+
return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
|
|
377
|
+
}
|
|
378
|
+
function header(type, eventTime, bizStep, opts) {
|
|
379
|
+
const h = {
|
|
380
|
+
"@context": EPCIS_CONTEXT,
|
|
381
|
+
type,
|
|
382
|
+
eventTime,
|
|
383
|
+
eventTimeZoneOffset: UTC_OFFSET,
|
|
384
|
+
bizStep
|
|
385
|
+
};
|
|
386
|
+
if (opts?.eventID) h.eventID = opts.eventID;
|
|
387
|
+
if (opts?.recordTime) h.recordTime = opts.recordTime;
|
|
388
|
+
if (opts?.errorDeclaration) h.errorDeclaration = opts.errorDeclaration;
|
|
389
|
+
if (opts?.ilmd) h.ilmd = opts.ilmd;
|
|
390
|
+
if (opts?.sourceList) h.sourceList = opts.sourceList;
|
|
391
|
+
if (opts?.destinationList) h.destinationList = opts.destinationList;
|
|
392
|
+
if (opts?.persistentDisposition) h.persistentDisposition = opts.persistentDisposition;
|
|
393
|
+
if (opts?.sensorElementList) h.sensorElementList = opts.sensorElementList;
|
|
394
|
+
if (opts?.certificationInfo) h.certificationInfo = opts.certificationInfo;
|
|
395
|
+
return h;
|
|
396
|
+
}
|
|
397
|
+
function common(type, eventTime, action, bizStep, opts) {
|
|
398
|
+
return { ...header(type, eventTime, bizStep, opts), action };
|
|
399
|
+
}
|
|
400
|
+
function objectEvent(p) {
|
|
401
|
+
const e = { ...common("ObjectEvent", p.eventTime, p.action, p.bizStep, p), epcList: p.epcList };
|
|
402
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
403
|
+
if (p.quantityList) e.quantityList = p.quantityList;
|
|
404
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
405
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
406
|
+
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
407
|
+
return e;
|
|
408
|
+
}
|
|
409
|
+
function aggregationEvent(p) {
|
|
410
|
+
const e = { ...common("AggregationEvent", p.eventTime, p.action, p.bizStep, p), parentID: p.parentID };
|
|
411
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
412
|
+
if (p.childEPCs) e.childEPCs = p.childEPCs;
|
|
413
|
+
if (p.childQuantityList) e.childQuantityList = p.childQuantityList;
|
|
414
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
415
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
416
|
+
return e;
|
|
417
|
+
}
|
|
418
|
+
function transactionEvent(p) {
|
|
419
|
+
const e = {
|
|
420
|
+
...common("TransactionEvent", p.eventTime, p.action, p.bizStep, p),
|
|
421
|
+
bizTransactionList: p.bizTransactionList
|
|
422
|
+
};
|
|
423
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
424
|
+
if (p.parentID) e.parentID = p.parentID;
|
|
425
|
+
if (p.epcList) e.epcList = p.epcList;
|
|
426
|
+
if (p.quantityList) e.quantityList = p.quantityList;
|
|
427
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
428
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
429
|
+
return e;
|
|
430
|
+
}
|
|
431
|
+
function transformationEvent(p) {
|
|
432
|
+
const e = header("TransformationEvent", p.eventTime, p.bizStep, p);
|
|
433
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
434
|
+
if (p.inputEPCList) e.inputEPCList = p.inputEPCList;
|
|
435
|
+
if (p.inputQuantityList) e.inputQuantityList = p.inputQuantityList;
|
|
436
|
+
if (p.outputEPCList) e.outputEPCList = p.outputEPCList;
|
|
437
|
+
if (p.outputQuantityList) e.outputQuantityList = p.outputQuantityList;
|
|
438
|
+
if (p.transformationID) e.transformationID = p.transformationID;
|
|
439
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
440
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
441
|
+
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
442
|
+
return e;
|
|
443
|
+
}
|
|
444
|
+
var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
445
|
+
var TZ_RE = /^[+-]\d{2}:\d{2}$/;
|
|
446
|
+
var ACTIONS = ["ADD", "OBSERVE", "DELETE"];
|
|
447
|
+
function validateEpcisEvent(e) {
|
|
448
|
+
const v = [];
|
|
449
|
+
if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
|
|
450
|
+
if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
|
|
451
|
+
if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
|
|
452
|
+
if (typeof e.eventTimeZoneOffset !== "string" || !TZ_RE.test(e.eventTimeZoneOffset)) v.push("eventTimeZoneOffset \uD615\uC2DD \uC624\uB958");
|
|
453
|
+
if (typeof e.bizStep !== "string" || !e.bizStep) v.push("bizStep \uB204\uB77D");
|
|
454
|
+
if (e.eventID !== void 0 && (typeof e.eventID !== "string" || !e.eventID)) v.push("eventID \uAC00 \uBE48 \uBB38\uC790\uC5F4");
|
|
455
|
+
if (e.recordTime !== void 0 && (typeof e.recordTime !== "string" || !ISO_RE.test(e.recordTime))) {
|
|
456
|
+
v.push("recordTime ISO8601 \uC544\uB2D8");
|
|
457
|
+
}
|
|
458
|
+
if (e.ilmd !== void 0) {
|
|
459
|
+
const allowed = e.type === "ObjectEvent" && e.action === "ADD" || e.type === "TransformationEvent";
|
|
460
|
+
if (!allowed) v.push("ilmd \uB294 ObjectEvent(action=ADD) \uB610\uB294 TransformationEvent \uC5D0\uB9CC \uC2E4\uC744 \uC218 \uC788\uB2E4");
|
|
461
|
+
}
|
|
462
|
+
for (const sd of e.sourceList ?? []) {
|
|
463
|
+
if (!sd?.type || !sd?.source) v.push("sourceList \uD56D\uBAA9\uC5D0 type \uB610\uB294 source \uB204\uB77D");
|
|
464
|
+
}
|
|
465
|
+
for (const sd of e.destinationList ?? []) {
|
|
466
|
+
if (!sd?.type || !sd?.destination) v.push("destinationList \uD56D\uBAA9\uC5D0 type \uB610\uB294 destination \uB204\uB77D");
|
|
467
|
+
}
|
|
468
|
+
if (e.persistentDisposition !== void 0) {
|
|
469
|
+
const set = e.persistentDisposition.set ?? [];
|
|
470
|
+
const unset = e.persistentDisposition.unset ?? [];
|
|
471
|
+
if (!set.length && !unset.length) v.push("persistentDisposition \uC774 set\xB7unset \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
472
|
+
const both = set.filter((x) => unset.includes(x));
|
|
473
|
+
if (both.length) v.push(`persistentDisposition \uC774 \uAC19\uC740 \uAC12\uC744 set\xB7unset \uB3D9\uC2DC \uC9C0\uC815: ${both.join(", ")}`);
|
|
474
|
+
}
|
|
475
|
+
for (const se of e.sensorElementList ?? []) {
|
|
476
|
+
if (!Array.isArray(se?.sensorReport) || se.sensorReport.length === 0) {
|
|
477
|
+
v.push("sensorElement \uC5D0 sensorReport \uAC00 \uD558\uB098\uB3C4 \uC5C6\uC74C");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (e.errorDeclaration !== void 0) {
|
|
481
|
+
const d = e.errorDeclaration;
|
|
482
|
+
if (typeof d?.declarationTime !== "string" || !ISO_RE.test(d.declarationTime)) {
|
|
483
|
+
v.push("errorDeclaration.declarationTime ISO8601 \uC544\uB2D8/\uB204\uB77D");
|
|
484
|
+
}
|
|
485
|
+
if (d?.correctiveEventIDs !== void 0) {
|
|
486
|
+
if (!Array.isArray(d.correctiveEventIDs)) v.push("errorDeclaration.correctiveEventIDs \uBC30\uC5F4 \uC544\uB2D8");
|
|
487
|
+
else if (d.correctiveEventIDs.some((x) => typeof x !== "string" || !x)) {
|
|
488
|
+
v.push("errorDeclaration.correctiveEventIDs \uC5D0 \uBE48 \uAC12");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (e.type === "ObjectEvent") {
|
|
493
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
494
|
+
if (!Array.isArray(e.epcList)) v.push("ObjectEvent.epcList \uB204\uB77D");
|
|
495
|
+
else if (e.epcList.length === 0 && !e.quantityList?.length) v.push("ObjectEvent: epcList/quantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
496
|
+
} else if (e.type === "AggregationEvent") {
|
|
497
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
498
|
+
if (!e.parentID) v.push("AggregationEvent.parentID \uB204\uB77D");
|
|
499
|
+
if (!e.childEPCs?.length && !e.childQuantityList?.length) v.push("AggregationEvent: childEPCs/childQuantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
500
|
+
} else if (e.type === "TransactionEvent") {
|
|
501
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
502
|
+
if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0) v.push("TransactionEvent.bizTransactionList \uB204\uB77D");
|
|
503
|
+
} else if (e.type === "TransformationEvent") {
|
|
504
|
+
if (!e.inputEPCList?.length && !e.inputQuantityList?.length) v.push("TransformationEvent: input \uBE44\uC5B4\uC788\uC74C");
|
|
505
|
+
if (!e.outputEPCList?.length && !e.outputQuantityList?.length) v.push("TransformationEvent: output \uBE44\uC5B4\uC788\uC74C");
|
|
506
|
+
}
|
|
507
|
+
const qtyLists = [
|
|
508
|
+
"quantityList" in e ? e.quantityList : void 0,
|
|
509
|
+
"childQuantityList" in e ? e.childQuantityList : void 0,
|
|
510
|
+
"inputQuantityList" in e ? e.inputQuantityList : void 0,
|
|
511
|
+
"outputQuantityList" in e ? e.outputQuantityList : void 0
|
|
512
|
+
];
|
|
513
|
+
for (const list of qtyLists) for (const q of list ?? []) {
|
|
514
|
+
if (!q.epcClass?.startsWith("urn:epc:idpat:") && !q.epcClass?.startsWith("urn:epc:class:")) v.push(`quantity epcClass \uBD80\uC815: ${q.epcClass}`);
|
|
515
|
+
const hasQty = q.quantity !== void 0 && q.quantity !== null;
|
|
516
|
+
if (!hasQty) {
|
|
517
|
+
if (q.uom !== void 0) v.push("quantity \uC5C6\uC73C\uBA74 uom \uB3C4 \uC5C6\uC5B4\uC57C \uD55C\uB2E4(\uC218\uB7C9 \uBBF8\uC9C0\uC815)");
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (typeof q.quantity !== "number" || !Number.isFinite(q.quantity) || q.quantity <= 0) {
|
|
521
|
+
v.push("quantity \uB294 \uC591\uC218\uC5EC\uC57C \uD55C\uB2E4(\uBAA8\uB974\uBA74 \uC0DD\uB7B5)");
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (q.uom === void 0 && !Number.isInteger(q.quantity)) v.push("uom \uC5C6\uB294 quantity \uB294 \uC815\uC218(\uAC1C\uC218)\uC5EC\uC57C \uD55C\uB2E4");
|
|
525
|
+
if (q.uom !== void 0 && !/^[A-Z0-9]{2,3}$/.test(q.uom)) v.push(`uom \uD615\uC2DD \uBD80\uC815(UN/CEFACT \uAD8C\uACE0 20 \uC758 2~3\uC790 \uCF54\uB4DC): ${q.uom}`);
|
|
526
|
+
}
|
|
527
|
+
return v;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/observed-reducer.ts
|
|
531
|
+
var UNKNOWN_TYPE = "unknown";
|
|
532
|
+
var ObservedReducer = class {
|
|
533
|
+
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
282
534
|
master = /* @__PURE__ */ new Map();
|
|
283
535
|
items = /* @__PURE__ */ new Map();
|
|
284
536
|
aggregation = /* @__PURE__ */ new Map();
|
|
285
|
-
|
|
537
|
+
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
538
|
+
pendingParent = /* @__PURE__ */ new Map();
|
|
539
|
+
// 자식 EPC → 부모(물류단위)
|
|
286
540
|
tasks = /* @__PURE__ */ new Map();
|
|
287
541
|
movers = /* @__PURE__ */ new Map();
|
|
542
|
+
persons = /* @__PURE__ */ new Map();
|
|
543
|
+
assets = /* @__PURE__ */ new Map();
|
|
288
544
|
orders = /* @__PURE__ */ new Map();
|
|
289
545
|
revision = 0;
|
|
546
|
+
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
547
|
+
corrections = [];
|
|
290
548
|
constructor(board) {
|
|
291
|
-
for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parentId: n.parentId });
|
|
292
|
-
for (const m of board.movers) this.movers.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeNode });
|
|
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" });
|
|
293
553
|
}
|
|
294
554
|
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
295
555
|
applyMaster(u) {
|
|
@@ -298,48 +558,173 @@ var StateProjector = class {
|
|
|
298
558
|
return;
|
|
299
559
|
}
|
|
300
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;
|
|
301
563
|
this.master.set(u.node.id, {
|
|
302
564
|
id: u.node.id,
|
|
303
|
-
type: u.node.type ?? cur?.type ??
|
|
304
|
-
capacity:
|
|
565
|
+
type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
|
|
566
|
+
...capacity === void 0 ? {} : { capacity },
|
|
567
|
+
...parallelism === void 0 ? {} : { parallelism },
|
|
568
|
+
...u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {},
|
|
569
|
+
/* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
|
|
570
|
+
origin: "master"
|
|
305
571
|
});
|
|
306
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
|
|
575
|
+
*
|
|
576
|
+
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
|
|
577
|
+
* 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
|
|
578
|
+
* 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
|
|
579
|
+
* 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
|
|
580
|
+
*
|
|
581
|
+
* 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
|
|
582
|
+
* (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
|
|
583
|
+
*/
|
|
584
|
+
touchLocation(id) {
|
|
585
|
+
if (!id || this.master.has(id)) return;
|
|
586
|
+
this.master.set(id, { id, type: UNKNOWN_TYPE, origin: "observed" });
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
|
|
590
|
+
*
|
|
591
|
+
* 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
|
|
592
|
+
* 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
|
|
593
|
+
* 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
|
|
594
|
+
*
|
|
595
|
+
* 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
|
|
596
|
+
* 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
|
|
597
|
+
*/
|
|
598
|
+
stale(key, e) {
|
|
599
|
+
const at = Date.parse(String(e.eventTime ?? ""));
|
|
600
|
+
if (!Number.isFinite(at)) return false;
|
|
601
|
+
const recorded = Date.parse(String(e.data?.recordTime ?? ""));
|
|
602
|
+
const seen = this.lastAt.get(key);
|
|
603
|
+
if (seen === void 0) {
|
|
604
|
+
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : void 0 });
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
if (at < seen.at) return true;
|
|
608
|
+
if (at === seen.at && Number.isFinite(recorded) && seen.recorded !== void 0 && recorded < seen.recorded) return true;
|
|
609
|
+
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : seen.recorded });
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
613
|
+
lastAt = /* @__PURE__ */ new Map();
|
|
307
614
|
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
308
615
|
apply(e) {
|
|
309
616
|
this.revision++;
|
|
310
617
|
if (e.eventType.startsWith("epcis.")) {
|
|
311
|
-
this.applyEpcis(e.data);
|
|
618
|
+
this.applyEpcis(e.data, e);
|
|
312
619
|
return;
|
|
313
620
|
}
|
|
314
621
|
switch (e.eventType) {
|
|
315
622
|
case OP_EVENT.task: {
|
|
316
623
|
const d = e.data;
|
|
317
|
-
this.
|
|
624
|
+
if (this.stale(`task:${d.taskId}`, e)) return;
|
|
625
|
+
this.touchLocation(d.fromNode);
|
|
626
|
+
this.touchLocation(d.toNode);
|
|
627
|
+
this.tasks.set(d.taskId, {
|
|
628
|
+
id: d.taskId,
|
|
629
|
+
kind: d.kind,
|
|
630
|
+
status: d.status,
|
|
631
|
+
fromNode: d.fromNode,
|
|
632
|
+
toNode: d.toNode,
|
|
633
|
+
itemRefs: d.itemRefs,
|
|
634
|
+
resourceRef: d.resourceRef,
|
|
635
|
+
orderId: d.orderId,
|
|
636
|
+
intent: d.intent,
|
|
637
|
+
progress: d.progress,
|
|
638
|
+
remainingMs: d.remainingMs,
|
|
639
|
+
durationMs: d.durationMs,
|
|
640
|
+
startedAtSimMs: d.startedAtSimMs,
|
|
641
|
+
...d.personnel?.length ? { personnel: d.personnel.slice() } : {},
|
|
642
|
+
...d.assets?.length ? { assets: d.assets.slice() } : {}
|
|
643
|
+
});
|
|
318
644
|
break;
|
|
319
645
|
}
|
|
320
646
|
case OP_EVENT.equipment: {
|
|
321
647
|
const d = e.data;
|
|
322
|
-
this.
|
|
648
|
+
if (this.stale(`mover:${d.moverId}`, e)) return;
|
|
649
|
+
this.touchLocation(d.location);
|
|
650
|
+
const known = this.movers.get(d.moverId);
|
|
651
|
+
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" });
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
case OP_EVENT.person: {
|
|
655
|
+
const d = e.data;
|
|
656
|
+
if (this.stale(`person:${d.personId}`, e)) return;
|
|
657
|
+
this.persons.set(d.personId, {
|
|
658
|
+
id: d.personId,
|
|
659
|
+
personnelClass: d.personnelClass ?? this.persons.get(d.personId)?.personnelClass,
|
|
660
|
+
status: d.status,
|
|
661
|
+
taskId: d.taskId,
|
|
662
|
+
...d.offShift ? { offShift: true } : {}
|
|
663
|
+
});
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
case OP_EVENT.asset: {
|
|
667
|
+
const d = e.data;
|
|
668
|
+
if (this.stale(`asset:${d.assetId}`, e)) return;
|
|
669
|
+
const cur = this.assets.get(d.assetId);
|
|
670
|
+
this.assets.set(d.assetId, {
|
|
671
|
+
id: d.assetId,
|
|
672
|
+
assetClass: d.assetClass ?? cur?.assetClass,
|
|
673
|
+
location: d.location ?? cur?.location,
|
|
674
|
+
status: d.status,
|
|
675
|
+
taskId: d.taskId,
|
|
676
|
+
...d.carrying ? { carrying: d.carrying } : {}
|
|
677
|
+
});
|
|
678
|
+
this.touchLocation(d.location);
|
|
323
679
|
break;
|
|
324
680
|
}
|
|
325
681
|
case OP_EVENT.order: {
|
|
326
682
|
const d = e.data;
|
|
683
|
+
if (this.stale(`order:${d.orderId}`, e)) return;
|
|
327
684
|
this.orders.set(d.orderId, { id: d.orderId, kind: d.kind, status: d.status, progress: d.requested ? d.fulfilled / d.requested : 0, held: d.held });
|
|
328
685
|
break;
|
|
329
686
|
}
|
|
330
687
|
}
|
|
331
688
|
}
|
|
332
|
-
applyEpcis(ev) {
|
|
689
|
+
applyEpcis(ev, envelope) {
|
|
690
|
+
if (ev.errorDeclaration) {
|
|
691
|
+
this.corrections.push({
|
|
692
|
+
declaredAt: String(ev.errorDeclaration.declarationTime ?? ""),
|
|
693
|
+
reason: ev.errorDeclaration.reason,
|
|
694
|
+
correctiveEventIDs: ev.errorDeclaration.correctiveEventIDs ?? [],
|
|
695
|
+
eventID: ev.eventID
|
|
696
|
+
});
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
333
699
|
if (ev.type === "AggregationEvent") {
|
|
334
|
-
if (ev.action === "ADD" && ev.childEPCs?.length)
|
|
335
|
-
|
|
700
|
+
if (ev.action === "ADD" && ev.childEPCs?.length) {
|
|
701
|
+
this.aggregation.set(ev.parentID, [...ev.childEPCs]);
|
|
702
|
+
for (const child of ev.childEPCs) {
|
|
703
|
+
const cur = this.items.get(child);
|
|
704
|
+
if (cur) {
|
|
705
|
+
this.items.set(child, { ...cur, parent: ev.parentID });
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
this.pendingParent.set(child, ev.parentID);
|
|
709
|
+
}
|
|
710
|
+
} else if (ev.action === "DELETE") {
|
|
711
|
+
for (const child of this.aggregation.get(ev.parentID) ?? []) {
|
|
712
|
+
const cur = this.items.get(child);
|
|
713
|
+
if (cur) this.items.set(child, { ...cur, parent: void 0 });
|
|
714
|
+
this.pendingParent.delete(child);
|
|
715
|
+
}
|
|
716
|
+
this.aggregation.delete(ev.parentID);
|
|
717
|
+
}
|
|
336
718
|
return;
|
|
337
719
|
}
|
|
338
720
|
if (ev.type === "TransactionEvent") return;
|
|
339
721
|
if (ev.type === "TransformationEvent") {
|
|
340
722
|
for (const epc of ev.inputEPCList ?? []) this.remove(epc);
|
|
341
723
|
const loc2 = ev.readPoint?.id ?? "";
|
|
342
|
-
|
|
724
|
+
this.touchLocation(loc2);
|
|
725
|
+
for (const epc of ev.outputEPCList ?? []) {
|
|
726
|
+
this.items.set(epc, this.mergeItem(epc, { location: loc2, disposition: ev.disposition, ilmd: ev.ilmd }));
|
|
727
|
+
}
|
|
343
728
|
return;
|
|
344
729
|
}
|
|
345
730
|
if (ev.action === "DELETE") {
|
|
@@ -347,11 +732,57 @@ var StateProjector = class {
|
|
|
347
732
|
return;
|
|
348
733
|
}
|
|
349
734
|
const loc = ev.readPoint?.id;
|
|
350
|
-
const
|
|
735
|
+
const q = ev.quantityList?.[0];
|
|
736
|
+
this.touchLocation(loc);
|
|
351
737
|
for (const epc of ev.epcList) {
|
|
352
|
-
|
|
353
|
-
this.items.set(epc,
|
|
738
|
+
if (envelope && this.stale(`item:${epc}`, envelope)) continue;
|
|
739
|
+
this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q));
|
|
740
|
+
}
|
|
741
|
+
if (!ev.epcList?.length) {
|
|
742
|
+
for (const qe of ev.quantityList ?? []) {
|
|
743
|
+
if (qe?.epcClass) this.items.set(qe.epcClass, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
|
|
749
|
+
* 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
|
|
750
|
+
*/
|
|
751
|
+
/**
|
|
752
|
+
* 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
|
|
753
|
+
*
|
|
754
|
+
* 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
|
|
755
|
+
* `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
|
|
756
|
+
*/
|
|
757
|
+
expiryOf(ilmd) {
|
|
758
|
+
const raw = ilmd?.[ILMD_ATTR.expiry];
|
|
759
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
760
|
+
if (typeof raw === "string") {
|
|
761
|
+
const t = Date.parse(raw);
|
|
762
|
+
if (Number.isFinite(t)) return t;
|
|
354
763
|
}
|
|
764
|
+
return void 0;
|
|
765
|
+
}
|
|
766
|
+
mergeItem(epc, patch, q) {
|
|
767
|
+
const cur = this.items.get(epc);
|
|
768
|
+
const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : void 0;
|
|
769
|
+
const parsedSelf = parseEpc(epc);
|
|
770
|
+
const classUri = q?.epcClass ?? (parsedSelf.instance ? void 0 : epc);
|
|
771
|
+
return {
|
|
772
|
+
epc,
|
|
773
|
+
gtin: classUri ?? cur?.gtin,
|
|
774
|
+
gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
|
|
775
|
+
location: patch.location ?? cur?.location ?? "",
|
|
776
|
+
disposition: patch.disposition ?? cur?.disposition,
|
|
777
|
+
parent: cur?.parent ?? this.pendingParent.get(epc),
|
|
778
|
+
qty: q?.quantity ?? cur?.qty,
|
|
779
|
+
uom: q?.uom ?? cur?.uom,
|
|
780
|
+
/* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
|
|
781
|
+
ilmd: patch.ilmd ?? cur?.ilmd,
|
|
782
|
+
expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
|
|
783
|
+
/* 로트는 LGTIN(식별자)에서 오지만, 직렬 개체는 마스터데이터에 실려 온다. */
|
|
784
|
+
lot: parsedClass?.lot ?? parsedSelf.lot ?? (typeof patch.ilmd?.[ILMD_ATTR.lot] === "string" ? patch.ilmd[ILMD_ATTR.lot] : void 0) ?? cur?.lot
|
|
785
|
+
};
|
|
355
786
|
}
|
|
356
787
|
/** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
|
|
357
788
|
remove(epc) {
|
|
@@ -368,8 +799,26 @@ var StateProjector = class {
|
|
|
368
799
|
for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
|
|
369
800
|
return {
|
|
370
801
|
revision: this.revision,
|
|
371
|
-
|
|
372
|
-
|
|
802
|
+
...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
|
|
803
|
+
nodes: [...this.master.values()].map((n) => {
|
|
804
|
+
const occupancy = occ.get(n.id) ?? 0;
|
|
805
|
+
const status = nodeStatusOf({ occupancy, capacity: n.capacity });
|
|
806
|
+
return {
|
|
807
|
+
id: n.id,
|
|
808
|
+
type: n.type,
|
|
809
|
+
occupancy,
|
|
810
|
+
...status ? { status } : {},
|
|
811
|
+
/* 용량 미상은 **키를 만들지 않는다** — 0 으로 실으면 "자리 없음" 이라는 없는 사실이 생긴다. */
|
|
812
|
+
...n.capacity === void 0 ? {} : { capacity: n.capacity },
|
|
813
|
+
...n.parallelism === void 0 ? {} : { parallelism: n.parallelism },
|
|
814
|
+
...n.parentId ? { parentId: n.parentId } : {},
|
|
815
|
+
origin: n.origin
|
|
816
|
+
};
|
|
817
|
+
}),
|
|
818
|
+
/* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
|
|
819
|
+
items: [...this.items.values()].map((i) => ({ ...i })),
|
|
820
|
+
persons: [...this.persons.values()].map((p) => ({ ...p })),
|
|
821
|
+
assets: [...this.assets.values()].map((a) => ({ ...a })),
|
|
373
822
|
tasks: [...this.tasks.values()].map((t) => ({ ...t })),
|
|
374
823
|
movers: [...this.movers.values()].map((m) => ({ ...m })),
|
|
375
824
|
orders: [...this.orders.values()].map((o) => ({ ...o }))
|
|
@@ -404,119 +853,11 @@ var EventJournal = class {
|
|
|
404
853
|
}
|
|
405
854
|
};
|
|
406
855
|
function replay(board, events) {
|
|
407
|
-
const proj = new
|
|
856
|
+
const proj = new ObservedReducer(board);
|
|
408
857
|
for (const e of events) proj.apply(e);
|
|
409
858
|
return proj.snapshot();
|
|
410
859
|
}
|
|
411
860
|
|
|
412
|
-
// src/epcis.ts
|
|
413
|
-
var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
|
|
414
|
-
var UTC_OFFSET = "+00:00";
|
|
415
|
-
var DISP = {
|
|
416
|
-
in_progress: "urn:epcglobal:cbv:disp:in_progress",
|
|
417
|
-
sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
|
|
418
|
-
reserved: "urn:epcglobal:cbv:disp:reserved",
|
|
419
|
-
in_transit: "urn:epcglobal:cbv:disp:in_transit",
|
|
420
|
-
non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
|
|
421
|
-
// 불량/scrap
|
|
422
|
-
};
|
|
423
|
-
function ssccUri(companyPrefix, serial) {
|
|
424
|
-
return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
|
|
425
|
-
}
|
|
426
|
-
function sgtinClass(companyPrefix, itemRef) {
|
|
427
|
-
return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
|
|
428
|
-
}
|
|
429
|
-
function gdtiUri(companyPrefix, docType, serial) {
|
|
430
|
-
return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
|
|
431
|
-
}
|
|
432
|
-
function header(type, eventTime, bizStep) {
|
|
433
|
-
return { "@context": EPCIS_CONTEXT, type, eventTime, eventTimeZoneOffset: UTC_OFFSET, bizStep };
|
|
434
|
-
}
|
|
435
|
-
function common(type, eventTime, action, bizStep) {
|
|
436
|
-
return { ...header(type, eventTime, bizStep), action };
|
|
437
|
-
}
|
|
438
|
-
function objectEvent(p) {
|
|
439
|
-
const e = { ...common("ObjectEvent", p.eventTime, p.action, p.bizStep), epcList: p.epcList };
|
|
440
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
441
|
-
if (p.quantityList) e.quantityList = p.quantityList;
|
|
442
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
443
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
444
|
-
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
445
|
-
return e;
|
|
446
|
-
}
|
|
447
|
-
function aggregationEvent(p) {
|
|
448
|
-
const e = { ...common("AggregationEvent", p.eventTime, p.action, p.bizStep), parentID: p.parentID };
|
|
449
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
450
|
-
if (p.childEPCs) e.childEPCs = p.childEPCs;
|
|
451
|
-
if (p.childQuantityList) e.childQuantityList = p.childQuantityList;
|
|
452
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
453
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
454
|
-
return e;
|
|
455
|
-
}
|
|
456
|
-
function transactionEvent(p) {
|
|
457
|
-
const e = {
|
|
458
|
-
...common("TransactionEvent", p.eventTime, p.action, p.bizStep),
|
|
459
|
-
bizTransactionList: p.bizTransactionList
|
|
460
|
-
};
|
|
461
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
462
|
-
if (p.parentID) e.parentID = p.parentID;
|
|
463
|
-
if (p.epcList) e.epcList = p.epcList;
|
|
464
|
-
if (p.quantityList) e.quantityList = p.quantityList;
|
|
465
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
466
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
467
|
-
return e;
|
|
468
|
-
}
|
|
469
|
-
function transformationEvent(p) {
|
|
470
|
-
const e = header("TransformationEvent", p.eventTime, p.bizStep);
|
|
471
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
472
|
-
if (p.inputEPCList) e.inputEPCList = p.inputEPCList;
|
|
473
|
-
if (p.inputQuantityList) e.inputQuantityList = p.inputQuantityList;
|
|
474
|
-
if (p.outputEPCList) e.outputEPCList = p.outputEPCList;
|
|
475
|
-
if (p.outputQuantityList) e.outputQuantityList = p.outputQuantityList;
|
|
476
|
-
if (p.transformationID) e.transformationID = p.transformationID;
|
|
477
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
478
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
479
|
-
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
480
|
-
return e;
|
|
481
|
-
}
|
|
482
|
-
var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
483
|
-
var TZ_RE = /^[+-]\d{2}:\d{2}$/;
|
|
484
|
-
var ACTIONS = ["ADD", "OBSERVE", "DELETE"];
|
|
485
|
-
function validateEpcisEvent(e) {
|
|
486
|
-
const v = [];
|
|
487
|
-
if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
|
|
488
|
-
if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
|
|
489
|
-
if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
|
|
490
|
-
if (typeof e.eventTimeZoneOffset !== "string" || !TZ_RE.test(e.eventTimeZoneOffset)) v.push("eventTimeZoneOffset \uD615\uC2DD \uC624\uB958");
|
|
491
|
-
if (typeof e.bizStep !== "string" || !e.bizStep) v.push("bizStep \uB204\uB77D");
|
|
492
|
-
if (e.type === "ObjectEvent") {
|
|
493
|
-
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
494
|
-
if (!Array.isArray(e.epcList)) v.push("ObjectEvent.epcList \uB204\uB77D");
|
|
495
|
-
else if (e.epcList.length === 0 && !e.quantityList?.length) v.push("ObjectEvent: epcList/quantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
496
|
-
} else if (e.type === "AggregationEvent") {
|
|
497
|
-
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
498
|
-
if (!e.parentID) v.push("AggregationEvent.parentID \uB204\uB77D");
|
|
499
|
-
if (!e.childEPCs?.length && !e.childQuantityList?.length) v.push("AggregationEvent: childEPCs/childQuantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
500
|
-
} else if (e.type === "TransactionEvent") {
|
|
501
|
-
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
502
|
-
if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0) v.push("TransactionEvent.bizTransactionList \uB204\uB77D");
|
|
503
|
-
} else if (e.type === "TransformationEvent") {
|
|
504
|
-
if (!e.inputEPCList?.length && !e.inputQuantityList?.length) v.push("TransformationEvent: input \uBE44\uC5B4\uC788\uC74C");
|
|
505
|
-
if (!e.outputEPCList?.length && !e.outputQuantityList?.length) v.push("TransformationEvent: output \uBE44\uC5B4\uC788\uC74C");
|
|
506
|
-
}
|
|
507
|
-
const qtyLists = [
|
|
508
|
-
"quantityList" in e ? e.quantityList : void 0,
|
|
509
|
-
"childQuantityList" in e ? e.childQuantityList : void 0,
|
|
510
|
-
"inputQuantityList" in e ? e.inputQuantityList : void 0,
|
|
511
|
-
"outputQuantityList" in e ? e.outputQuantityList : void 0
|
|
512
|
-
];
|
|
513
|
-
for (const list of qtyLists) for (const q of list ?? []) {
|
|
514
|
-
if (!q.epcClass?.startsWith("urn:epc:idpat:") && !q.epcClass?.startsWith("urn:epc:class:")) v.push(`quantity epcClass \uBD80\uC815: ${q.epcClass}`);
|
|
515
|
-
if (typeof q.quantity !== "number" || q.quantity < 0) v.push("quantity \uBD80\uC815");
|
|
516
|
-
}
|
|
517
|
-
return v;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
861
|
// src/wms-profile.ts
|
|
521
862
|
var BIZSTEP = {
|
|
522
863
|
receiving: "urn:epcglobal:cbv:bizstep:receiving",
|
|
@@ -682,6 +1023,25 @@ var fefoPolicy = {
|
|
|
682
1023
|
// src/duration-estimator.ts
|
|
683
1024
|
var constantDuration = (ms2) => ({ estimate: () => ms2 });
|
|
684
1025
|
|
|
1026
|
+
// src/iso-duration.ts
|
|
1027
|
+
var RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
1028
|
+
function parseIsoDuration(text) {
|
|
1029
|
+
if (typeof text !== "string") return void 0;
|
|
1030
|
+
const s = text.trim();
|
|
1031
|
+
if (!s || s === "P" || s === "PT") return void 0;
|
|
1032
|
+
if (/\d+Y/.test(s)) return void 0;
|
|
1033
|
+
const tIdx = s.indexOf("T");
|
|
1034
|
+
const datePart = tIdx === -1 ? s : s.slice(0, tIdx);
|
|
1035
|
+
if (/\d+M/.test(datePart)) return void 0;
|
|
1036
|
+
const m = RE.exec(s);
|
|
1037
|
+
if (!m) return void 0;
|
|
1038
|
+
const [, sign, w, d, h, min, sec] = m;
|
|
1039
|
+
if (!w && !d && !h && !min && !sec) return void 0;
|
|
1040
|
+
const ms2 = (Number(w ?? 0) * 7 + Number(d ?? 0)) * 864e5 + Number(h ?? 0) * 36e5 + Number(min ?? 0) * 6e4 + Number(sec ?? 0) * 1e3;
|
|
1041
|
+
if (!Number.isFinite(ms2)) return void 0;
|
|
1042
|
+
return sign ? -ms2 : ms2;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
685
1045
|
// src/task-fold.ts
|
|
686
1046
|
function ms(value) {
|
|
687
1047
|
if (!value) return null;
|
|
@@ -931,19 +1291,41 @@ var FlowEngine = class {
|
|
|
931
1291
|
nodes = /* @__PURE__ */ new Map();
|
|
932
1292
|
items = /* @__PURE__ */ new Map();
|
|
933
1293
|
movers = /* @__PURE__ */ new Map();
|
|
1294
|
+
/** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
|
|
1295
|
+
persons = /* @__PURE__ */ new Map();
|
|
1296
|
+
/** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
|
|
1297
|
+
assets = /* @__PURE__ */ new Map();
|
|
934
1298
|
tasks = /* @__PURE__ */ new Map();
|
|
935
1299
|
orders = /* @__PURE__ */ new Map();
|
|
936
1300
|
revision = 0;
|
|
937
1301
|
clockMs = 0;
|
|
938
1302
|
rng = mulberry32(1);
|
|
939
1303
|
policy;
|
|
940
|
-
/** duration 시임(선택) — 미주입 시 도메인 상수.
|
|
1304
|
+
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
941
1305
|
durationEstimator;
|
|
1306
|
+
/**
|
|
1307
|
+
* 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
|
|
1308
|
+
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
1309
|
+
*/
|
|
1310
|
+
operationSpecs = /* @__PURE__ */ new Map();
|
|
1311
|
+
/** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
|
|
1312
|
+
observer;
|
|
1313
|
+
/** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
|
|
1314
|
+
observedDirty = false;
|
|
1315
|
+
observeMode = false;
|
|
1316
|
+
/** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
|
|
1317
|
+
boardDef;
|
|
1318
|
+
/** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
|
|
1319
|
+
specUse = /* @__PURE__ */ new Map();
|
|
942
1320
|
epcSeq = 0;
|
|
943
1321
|
taskSeq = 0;
|
|
944
1322
|
orderSeq = 0;
|
|
945
1323
|
soSeq = 0;
|
|
946
1324
|
handlers = [];
|
|
1325
|
+
/** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
|
|
1326
|
+
handlersRef() {
|
|
1327
|
+
return this.handlers;
|
|
1328
|
+
}
|
|
947
1329
|
gens = [];
|
|
948
1330
|
generating = false;
|
|
949
1331
|
speed = 1;
|
|
@@ -954,9 +1336,12 @@ var FlowEngine = class {
|
|
|
954
1336
|
}
|
|
955
1337
|
// ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
|
|
956
1338
|
loadBoard(def) {
|
|
957
|
-
|
|
1339
|
+
this.boardDef = def;
|
|
1340
|
+
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 });
|
|
1341
|
+
for (const p of def.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null, window: p.window });
|
|
1342
|
+
for (const a of def.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle", taskId: null });
|
|
958
1343
|
for (const m of def.movers) {
|
|
959
|
-
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 };
|
|
1344
|
+
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 };
|
|
960
1345
|
if (m.mtbfMs !== void 0) {
|
|
961
1346
|
mover.mtbfMs = m.mtbfMs;
|
|
962
1347
|
mover.mttrMs = m.mttrMs;
|
|
@@ -987,10 +1372,82 @@ var FlowEngine = class {
|
|
|
987
1372
|
* 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
|
|
988
1373
|
*/
|
|
989
1374
|
hydrateObserved(snap, orders = []) {
|
|
990
|
-
for (const n of snap.nodes)
|
|
1375
|
+
for (const n of snap.nodes) {
|
|
1376
|
+
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 });
|
|
1377
|
+
}
|
|
991
1378
|
this.items.clear();
|
|
992
|
-
for (const it of snap.items)
|
|
993
|
-
|
|
1379
|
+
for (const it of snap.items) {
|
|
1380
|
+
this.items.set(it.epc, {
|
|
1381
|
+
/* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
|
|
1382
|
+
epc: it.epc,
|
|
1383
|
+
location: it.location,
|
|
1384
|
+
disposition: it.disposition ?? DISP.sellable,
|
|
1385
|
+
gtin: it.gtin,
|
|
1386
|
+
qty: it.qty ?? 1,
|
|
1387
|
+
uom: it.uom,
|
|
1388
|
+
parent: it.parent,
|
|
1389
|
+
carriedBy: it.carriedBy,
|
|
1390
|
+
expiry: it.expiry,
|
|
1391
|
+
ilmd: it.ilmd
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
for (const m of snap.movers) {
|
|
1395
|
+
const oee = m.oee;
|
|
1396
|
+
this.movers.set(m.id, {
|
|
1397
|
+
id: m.id,
|
|
1398
|
+
kind: m.kind,
|
|
1399
|
+
location: m.location ?? "",
|
|
1400
|
+
status: m.status ?? "idle",
|
|
1401
|
+
taskId: null,
|
|
1402
|
+
runMs: oee?.runMs ?? 0,
|
|
1403
|
+
setupMs: oee?.setupMs ?? 0,
|
|
1404
|
+
downMs: oee?.downMs ?? 0,
|
|
1405
|
+
goodCount: oee?.goodCount ?? 0,
|
|
1406
|
+
scrapCount: oee?.scrapCount ?? 0
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
for (const p of snap.persons ?? []) {
|
|
1410
|
+
this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null });
|
|
1411
|
+
}
|
|
1412
|
+
for (const a of snap.assets ?? []) {
|
|
1413
|
+
this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.location, status: "idle", taskId: null, carrying: a.carrying });
|
|
1414
|
+
}
|
|
1415
|
+
for (const t of snap.tasks ?? []) {
|
|
1416
|
+
if (t.status === "completed") continue;
|
|
1417
|
+
const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
|
|
1418
|
+
this.tasks.set(t.id, {
|
|
1419
|
+
id: t.id,
|
|
1420
|
+
kind: t.kind,
|
|
1421
|
+
status: known && t.status === "in-progress" ? "in-progress" : "created",
|
|
1422
|
+
itemEpc: t.itemRefs?.[0] ?? "",
|
|
1423
|
+
fromNode: t.fromNode ?? "",
|
|
1424
|
+
toNode: t.toNode ?? "",
|
|
1425
|
+
resource: known && t.status === "in-progress" ? t.resourceRef ?? null : null,
|
|
1426
|
+
remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
|
|
1427
|
+
startedAtSimMs: t.startedAtSimMs,
|
|
1428
|
+
durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
|
|
1429
|
+
orderId: t.orderId,
|
|
1430
|
+
intent: t.intent
|
|
1431
|
+
});
|
|
1432
|
+
if (known && t.status === "in-progress" && t.resourceRef) {
|
|
1433
|
+
const mv = this.movers.get(t.resourceRef);
|
|
1434
|
+
if (mv) {
|
|
1435
|
+
mv.status = "busy";
|
|
1436
|
+
mv.taskId = t.id;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (known && t.status === "in-progress") {
|
|
1440
|
+
const restored = this.tasks.get(t.id);
|
|
1441
|
+
if (restored) restored.personnel = t.personnel ? [...t.personnel] : void 0;
|
|
1442
|
+
for (const id of t.personnel ?? []) {
|
|
1443
|
+
const pp = this.persons.get(id);
|
|
1444
|
+
if (pp) {
|
|
1445
|
+
pp.status = "busy";
|
|
1446
|
+
pp.taskId = t.id;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
994
1451
|
for (const o of orders) {
|
|
995
1452
|
const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
|
|
996
1453
|
const remaining = lines.reduce((s, l) => s + l.requested, 0);
|
|
@@ -1121,7 +1578,7 @@ var FlowEngine = class {
|
|
|
1121
1578
|
start: () => {
|
|
1122
1579
|
if (this.generating) return;
|
|
1123
1580
|
this.generating = true;
|
|
1124
|
-
for (const g of this.gens) g.nextMs = this.
|
|
1581
|
+
for (const g of this.gens) g.nextMs = this.nextFireMs(g.spec, this.clockMs);
|
|
1125
1582
|
},
|
|
1126
1583
|
pause: () => {
|
|
1127
1584
|
this.generating = false;
|
|
@@ -1143,18 +1600,52 @@ var FlowEngine = class {
|
|
|
1143
1600
|
this.processTasks(dt);
|
|
1144
1601
|
}
|
|
1145
1602
|
getSnapshot() {
|
|
1603
|
+
this.settleObserved();
|
|
1146
1604
|
return {
|
|
1147
1605
|
revision: this.revision,
|
|
1148
1606
|
simClockMs: this.clockMs,
|
|
1149
|
-
|
|
1150
|
-
|
|
1607
|
+
/* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
|
|
1608
|
+
* 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
|
|
1609
|
+
nodes: [...this.nodes.values()].map((n) => {
|
|
1610
|
+
const { status, ...rest } = n;
|
|
1611
|
+
const derived = nodeStatusOf(n);
|
|
1612
|
+
return { ...rest, ...derived ? { status: derived } : {}, origin: "master" };
|
|
1613
|
+
}),
|
|
1614
|
+
items: [...this.items.values()].map((i) => this.itemState(i)),
|
|
1151
1615
|
movers: [...this.movers.values()].map((m) => {
|
|
1152
|
-
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 };
|
|
1616
|
+
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 } : {} };
|
|
1153
1617
|
const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
|
|
1154
1618
|
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 };
|
|
1155
1619
|
return s;
|
|
1156
1620
|
}),
|
|
1157
|
-
|
|
1621
|
+
assets: [...this.assets.values()].map((a) => {
|
|
1622
|
+
const st = { id: a.id, assetClass: a.assetClass, location: a.location, status: a.status, taskId: a.taskId ?? void 0 };
|
|
1623
|
+
if (a.carrying) st.carrying = a.carrying;
|
|
1624
|
+
return st;
|
|
1625
|
+
}),
|
|
1626
|
+
persons: [...this.persons.values()].map((p) => {
|
|
1627
|
+
const st = { id: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0 };
|
|
1628
|
+
if (this.personOffShift(p)) st.offShift = true;
|
|
1629
|
+
return st;
|
|
1630
|
+
}),
|
|
1631
|
+
/* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
|
|
1632
|
+
* 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
|
|
1633
|
+
* 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
|
|
1634
|
+
tasks: [...this.tasks.values()].map((t) => ({
|
|
1635
|
+
id: t.id,
|
|
1636
|
+
kind: t.kind,
|
|
1637
|
+
status: t.status,
|
|
1638
|
+
itemRefs: [t.itemEpc],
|
|
1639
|
+
fromNode: t.fromNode,
|
|
1640
|
+
toNode: t.toNode,
|
|
1641
|
+
resourceRef: t.resource ?? void 0,
|
|
1642
|
+
orderId: t.orderId,
|
|
1643
|
+
...t.intent ? { intent: t.intent } : {},
|
|
1644
|
+
...t.durationMs ? { durationMs: t.durationMs } : {},
|
|
1645
|
+
...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
|
|
1646
|
+
...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
|
|
1647
|
+
...t.assets?.length ? { assets: t.assets.slice() } : {}
|
|
1648
|
+
})),
|
|
1158
1649
|
orders: [...this.orders.values()].map((o) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.requested ? o.fulfilled / o.requested : 0, held: o.held })),
|
|
1159
1650
|
attentions: this.computeAttentions()
|
|
1160
1651
|
};
|
|
@@ -1180,6 +1671,7 @@ var FlowEngine = class {
|
|
|
1180
1671
|
* rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
|
|
1181
1672
|
*/
|
|
1182
1673
|
fork(tenantId = this.tenantId) {
|
|
1674
|
+
this.settleObserved();
|
|
1183
1675
|
const Ctor = this.constructor;
|
|
1184
1676
|
const clone = new Ctor(tenantId, this.policy);
|
|
1185
1677
|
const skip = /* @__PURE__ */ new Set(["policy", "scenario", "tenantId", "handlers", "durationEstimator"]);
|
|
@@ -1191,6 +1683,8 @@ var FlowEngine = class {
|
|
|
1191
1683
|
}
|
|
1192
1684
|
clone.rng.state = this.rng.state;
|
|
1193
1685
|
clone.durationEstimator = this.durationEstimator;
|
|
1686
|
+
clone.operationSpecs = this.operationSpecs;
|
|
1687
|
+
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
|
|
1194
1688
|
return clone;
|
|
1195
1689
|
}
|
|
1196
1690
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
@@ -1200,9 +1694,161 @@ var FlowEngine = class {
|
|
|
1200
1694
|
randInt(min, max) {
|
|
1201
1695
|
return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
|
|
1202
1696
|
}
|
|
1203
|
-
/**
|
|
1697
|
+
/**
|
|
1698
|
+
* 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
|
|
1699
|
+
*
|
|
1700
|
+
* 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
|
|
1701
|
+
* 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
|
|
1702
|
+
* 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
|
|
1703
|
+
*
|
|
1704
|
+
* 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
|
|
1705
|
+
* (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
|
|
1706
|
+
* 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
|
|
1707
|
+
*
|
|
1708
|
+
* **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
|
|
1709
|
+
* reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
|
|
1710
|
+
*
|
|
1711
|
+
* `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
|
|
1712
|
+
*/
|
|
1713
|
+
apply(envelope) {
|
|
1714
|
+
if (!this.observer) {
|
|
1715
|
+
this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
|
|
1716
|
+
this.observeMode = true;
|
|
1717
|
+
}
|
|
1718
|
+
this.observer.apply(envelope);
|
|
1719
|
+
for (const h of this.observedHandlers()) h(envelope);
|
|
1720
|
+
this.observedDirty = true;
|
|
1721
|
+
this.revision++;
|
|
1722
|
+
}
|
|
1723
|
+
/** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
|
|
1724
|
+
settleObserved() {
|
|
1725
|
+
if (!this.observedDirty || !this.observer) return;
|
|
1726
|
+
this.observedDirty = false;
|
|
1727
|
+
this.hydrateObserved(this.observer.snapshot());
|
|
1728
|
+
}
|
|
1729
|
+
/** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
|
|
1730
|
+
observedHandlers() {
|
|
1731
|
+
return this.handlersRef();
|
|
1732
|
+
}
|
|
1733
|
+
/** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
|
|
1734
|
+
get observing() {
|
|
1735
|
+
return this.observeMode;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
|
|
1739
|
+
* 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
|
|
1740
|
+
*/
|
|
1741
|
+
loadOperations(ops = []) {
|
|
1742
|
+
for (const o of ops) if (o?.key) this.operationSpecs.set(o.key, o);
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
|
|
1746
|
+
*
|
|
1747
|
+
* 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 박아 둔 상수를 이긴다.
|
|
1748
|
+
* 셋 중 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
|
|
1749
|
+
* "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
|
|
1750
|
+
*/
|
|
1204
1751
|
durationOf(ctx, fallbackMs) {
|
|
1205
|
-
|
|
1752
|
+
const estimated = this.durationEstimator?.estimate(ctx);
|
|
1753
|
+
if (typeof estimated === "number") {
|
|
1754
|
+
this.noteSpecUse(ctx.kind, "measured");
|
|
1755
|
+
return estimated;
|
|
1756
|
+
}
|
|
1757
|
+
if (estimated) {
|
|
1758
|
+
this.noteSpecUse(ctx.kind, "measured", estimated.spread?.distribution);
|
|
1759
|
+
return this.sampleSpread(estimated.meanMs, estimated.spread);
|
|
1760
|
+
}
|
|
1761
|
+
const spec = this.operationSpecs.get(ctx.kind);
|
|
1762
|
+
const declared = parseIsoDuration(spec?.duration);
|
|
1763
|
+
if (declared === void 0) {
|
|
1764
|
+
this.noteSpecUse(ctx.kind, "default");
|
|
1765
|
+
return fallbackMs;
|
|
1766
|
+
}
|
|
1767
|
+
this.noteSpecUse(ctx.kind, "declared");
|
|
1768
|
+
return this.applyVariability(declared, spec?.variability);
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* 소요시간 변동 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 표준 밖 확장이므로 미지정이면 상수.
|
|
1772
|
+
* 모수가 모자라면(uniform 에 min/max 없음 등) 변동을 발명하지 않고 평균을 그대로 쓴다.
|
|
1773
|
+
*/
|
|
1774
|
+
applyVariability(meanMs, v) {
|
|
1775
|
+
if (!v || v.distribution === "constant") return meanMs;
|
|
1776
|
+
if (v.distribution === "exponential") return -Math.log(1 - this.rng()) * meanMs;
|
|
1777
|
+
const minMs = parseIsoDuration(v.min);
|
|
1778
|
+
const maxMs = parseIsoDuration(v.max);
|
|
1779
|
+
if (minMs === void 0 || maxMs === void 0) return meanMs;
|
|
1780
|
+
return this.sampleSpread(meanMs, { distribution: v.distribution, minMs, maxMs, modeMs: parseIsoDuration(v.mode) });
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* 퍼짐 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 선언 명세(ISO 표기)와 실측 분포(ms)가
|
|
1784
|
+
* 같은 수식을 쓴다: 한쪽만 고치면 두 경로가 다른 답을 낸다.
|
|
1785
|
+
* 모수가 모자라거나 뒤집혀 있으면 **퍼짐을 발명하지 않고** 평균을 그대로 쓴다.
|
|
1786
|
+
*/
|
|
1787
|
+
sampleSpread(meanMs, spread) {
|
|
1788
|
+
if (!spread) return meanMs;
|
|
1789
|
+
const { minMs, maxMs } = spread;
|
|
1790
|
+
if (!Number.isFinite(minMs) || !Number.isFinite(maxMs) || maxMs < minMs) return meanMs;
|
|
1791
|
+
if (spread.distribution === "uniform") return minMs + this.rng() * (maxMs - minMs);
|
|
1792
|
+
const mode = Math.min(maxMs, Math.max(minMs, spread.modeMs ?? meanMs));
|
|
1793
|
+
const u = this.rng();
|
|
1794
|
+
const span = maxMs - minMs;
|
|
1795
|
+
if (span <= 0) return minMs;
|
|
1796
|
+
const c = (mode - minMs) / span;
|
|
1797
|
+
return u < c ? minMs + Math.sqrt(u * span * (mode - minMs)) : maxMs - Math.sqrt((1 - u) * span * (maxMs - mode));
|
|
1798
|
+
}
|
|
1799
|
+
/** 명세 모수(숫자) — 선언 없으면 undefined(0 으로 꾸미지 않는다). 소비처가 기본값을 정한다. */
|
|
1800
|
+
paramNumber(opKey, id) {
|
|
1801
|
+
const p = this.operationSpecs.get(opKey)?.parameters?.find((x) => x.id === id);
|
|
1802
|
+
if (!p) return void 0;
|
|
1803
|
+
const n = Number(p.value);
|
|
1804
|
+
if (!Number.isFinite(n)) return void 0;
|
|
1805
|
+
this.noteParamUse(opKey, id);
|
|
1806
|
+
return n;
|
|
1807
|
+
}
|
|
1808
|
+
/** 명세 모수(기간) — ISO 8601 문자열을 밀리초로. 선언 없으면 undefined. */
|
|
1809
|
+
paramDuration(opKey, id) {
|
|
1810
|
+
const p = this.operationSpecs.get(opKey)?.parameters?.find((x) => x.id === id);
|
|
1811
|
+
const ms2 = parseIsoDuration(p?.value);
|
|
1812
|
+
if (ms2 !== void 0) this.noteParamUse(opKey, id);
|
|
1813
|
+
return ms2;
|
|
1814
|
+
}
|
|
1815
|
+
noteSpecUse(kind, duration, variability) {
|
|
1816
|
+
const cur = this.specUse.get(kind);
|
|
1817
|
+
if (cur) {
|
|
1818
|
+
cur.duration = duration;
|
|
1819
|
+
if (variability) cur.variability = variability;
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
this.specUse.set(kind, { duration, ...variability ? { variability } : {}, params: /* @__PURE__ */ new Set() });
|
|
1823
|
+
}
|
|
1824
|
+
noteParamUse(kind, id) {
|
|
1825
|
+
const cur = this.specUse.get(kind);
|
|
1826
|
+
if (cur) cur.params.add(id);
|
|
1827
|
+
else this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]) });
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 박아 둔 상수인가.**
|
|
1831
|
+
*
|
|
1832
|
+
* 시뮬레이션 결과를 받는 쪽이 이걸 봐야 한다: 소요시간이 전부 기본값이면 그 예측으로 말할 수 있는 것은
|
|
1833
|
+
* "같은 조건에서의 상대 비교" 뿐이고 "몇 시에 끝난다" 는 근거가 없다. 그 구분을 숫자로 드러낸다.
|
|
1834
|
+
*/
|
|
1835
|
+
specCoverage() {
|
|
1836
|
+
const operations = [...this.specUse.entries()].map(([kind, u]) => {
|
|
1837
|
+
const spec = this.operationSpecs.get(kind);
|
|
1838
|
+
const variability = u.variability ?? spec?.variability?.distribution;
|
|
1839
|
+
return {
|
|
1840
|
+
kind,
|
|
1841
|
+
duration: u.duration,
|
|
1842
|
+
...variability ? { variability } : {},
|
|
1843
|
+
parameters: [...u.params].sort()
|
|
1844
|
+
};
|
|
1845
|
+
});
|
|
1846
|
+
return {
|
|
1847
|
+
operations,
|
|
1848
|
+
measuredDurations: operations.filter((o) => o.duration === "measured").length,
|
|
1849
|
+
declaredDurations: operations.filter((o) => o.duration === "declared").length,
|
|
1850
|
+
defaultDurations: operations.filter((o) => o.duration === "default").length
|
|
1851
|
+
};
|
|
1206
1852
|
}
|
|
1207
1853
|
/** skuMix 에서 weight 로 gtin 선택(rng) — 도착/오더 자극의 품목 결정. */
|
|
1208
1854
|
pickGtin(mix) {
|
|
@@ -1250,6 +1896,53 @@ var FlowEngine = class {
|
|
|
1250
1896
|
bizTransactionList: opts.bizTransactionList
|
|
1251
1897
|
}));
|
|
1252
1898
|
}
|
|
1899
|
+
/**
|
|
1900
|
+
* 할당(예약) — **처분 변화를 이벤트로 낸다.**
|
|
1901
|
+
*
|
|
1902
|
+
* 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
|
|
1903
|
+
* 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
|
|
1904
|
+
* 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
|
|
1905
|
+
*
|
|
1906
|
+
* 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
|
|
1907
|
+
* 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
1908
|
+
*
|
|
1909
|
+
* `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
|
|
1910
|
+
* 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
|
|
1911
|
+
*/
|
|
1912
|
+
reserve(epcs, bizStep) {
|
|
1913
|
+
this.observeDisposition(epcs, DISP.reserved, bizStep);
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
|
|
1917
|
+
*
|
|
1918
|
+
* 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
|
|
1919
|
+
* 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
|
|
1920
|
+
*
|
|
1921
|
+
* 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
1922
|
+
* 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
|
|
1923
|
+
*/
|
|
1924
|
+
observeDisposition(epcs, disposition, bizStep, at) {
|
|
1925
|
+
const byLocation = /* @__PURE__ */ new Map();
|
|
1926
|
+
for (const epc of epcs) {
|
|
1927
|
+
const it = this.items.get(epc);
|
|
1928
|
+
if (!it || it.disposition === disposition) continue;
|
|
1929
|
+
it.disposition = disposition;
|
|
1930
|
+
const where = at ?? it.location ?? "";
|
|
1931
|
+
const bin = byLocation.get(where);
|
|
1932
|
+
if (bin) bin.push(epc);
|
|
1933
|
+
else byLocation.set(where, [epc]);
|
|
1934
|
+
}
|
|
1935
|
+
for (const [where, list] of byLocation) {
|
|
1936
|
+
this.emit(objectEvent({
|
|
1937
|
+
eventTime: this.now(),
|
|
1938
|
+
action: "OBSERVE",
|
|
1939
|
+
bizStep,
|
|
1940
|
+
disposition,
|
|
1941
|
+
epcList: list,
|
|
1942
|
+
...where ? { readPoint: where, bizLocation: where } : {}
|
|
1943
|
+
}));
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1253
1946
|
/**
|
|
1254
1947
|
* containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
|
|
1255
1948
|
* consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
|
|
@@ -1267,6 +1960,11 @@ var FlowEngine = class {
|
|
|
1267
1960
|
this.items.delete(c);
|
|
1268
1961
|
}
|
|
1269
1962
|
}
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
for (const c of children) {
|
|
1966
|
+
const it = this.items.get(c);
|
|
1967
|
+
if (it) it.parent = parent;
|
|
1270
1968
|
}
|
|
1271
1969
|
}
|
|
1272
1970
|
/**
|
|
@@ -1276,6 +1974,10 @@ var FlowEngine = class {
|
|
|
1276
1974
|
*/
|
|
1277
1975
|
disaggregate(parent, children, opts) {
|
|
1278
1976
|
this.emit(aggregationEvent({ eventTime: this.now(), action: "DELETE", bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
|
|
1977
|
+
for (const c of children) {
|
|
1978
|
+
const it = this.items.get(c);
|
|
1979
|
+
if (it) it.parent = void 0;
|
|
1980
|
+
}
|
|
1279
1981
|
if (opts.materialize) {
|
|
1280
1982
|
const m = opts.materialize;
|
|
1281
1983
|
for (const c of children) {
|
|
@@ -1316,19 +2018,239 @@ var FlowEngine = class {
|
|
|
1316
2018
|
const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
|
|
1317
2019
|
for (const h of this.handlers) h(e);
|
|
1318
2020
|
}
|
|
2021
|
+
/**
|
|
2022
|
+
* 작업 전이 방출 — **커널이 아는 것을 미러도 알게** 한다.
|
|
2023
|
+
*
|
|
2024
|
+
* 예전에는 진척·남은 시간·의도를 싣지 않아, 미러 상태를 씨앗으로 한 예측이 "진행 중인 일이 없는
|
|
2025
|
+
* 현장" 에서 출발했고, 소비처는 무자원 체류를 기록 누락으로 오해할 수밖에 없었다.
|
|
2026
|
+
* 진척은 진행 중일 때만 뜻이 있으므로 그때만 싣는다(생성·완료 시점의 0/1 은 노이즈).
|
|
2027
|
+
*/
|
|
1319
2028
|
emitTask(t) {
|
|
1320
|
-
|
|
2029
|
+
const inProgress = t.status === "in-progress";
|
|
2030
|
+
const done = Math.max(0, (t.durationMs ?? 0) - (t.remainingMs ?? 0));
|
|
2031
|
+
this.emitOp(OP_EVENT.task, {
|
|
2032
|
+
taskId: t.id,
|
|
2033
|
+
orderId: t.orderId,
|
|
2034
|
+
kind: t.kind,
|
|
2035
|
+
status: t.status,
|
|
2036
|
+
fromNode: t.fromNode,
|
|
2037
|
+
toNode: t.toNode,
|
|
2038
|
+
itemRefs: [t.itemEpc],
|
|
2039
|
+
resourceRef: t.resource ?? void 0,
|
|
2040
|
+
intent: t.intent,
|
|
2041
|
+
...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
|
|
2042
|
+
...t.assets?.length ? { assets: t.assets.slice() } : {},
|
|
2043
|
+
...t.durationMs ? { durationMs: t.durationMs } : {},
|
|
2044
|
+
...inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
2048
|
+
emitAsset(a) {
|
|
2049
|
+
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 });
|
|
1321
2050
|
}
|
|
2051
|
+
emitPerson(p) {
|
|
2052
|
+
this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0, ...this.personOffShift(p) ? { offShift: true } : {} });
|
|
2053
|
+
}
|
|
2054
|
+
/** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
|
|
2055
|
+
* 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
|
|
1322
2056
|
emitMover(m, motion) {
|
|
1323
|
-
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion });
|
|
2057
|
+
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? void 0, motion });
|
|
1324
2058
|
}
|
|
1325
2059
|
emitOrder(o) {
|
|
1326
2060
|
this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held });
|
|
1327
2061
|
}
|
|
1328
2062
|
// ── 내부 mechanics ─────────────────────────────────────────────────────────
|
|
2063
|
+
/**
|
|
2064
|
+
* 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
|
|
2065
|
+
*
|
|
2066
|
+
* 예전에는 `poisson` 만 구현하고 나머지는 전부 상수로 떨어졌다. 계약이 `uniform`·`profile` 을
|
|
2067
|
+
* 선언하고 있었으므로, 그것을 지정한 사람은 자기가 요청한 분포로 도는 줄 알았다 — **조용한 거짓**이다.
|
|
2068
|
+
*
|
|
2069
|
+
* constant 간격이 일정(평균 그대로)
|
|
2070
|
+
* poisson 무기억 도착(지수 간격) — 평균 유지
|
|
2071
|
+
* uniform 0..2×평균 균등 — 평균을 유지하면서 흔들린다(교과서적 U(0,2μ))
|
|
2072
|
+
* profile 시간대별 배율(`profile[시]`)로 도착률을 조절 — 하루 안의 수요 곡선
|
|
2073
|
+
*/
|
|
1329
2074
|
intervalMs(spec) {
|
|
1330
|
-
const
|
|
1331
|
-
|
|
2075
|
+
const perHour = spec.rate.meanPerHour * this.profileFactor(spec.rate);
|
|
2076
|
+
if (!(perHour > 0)) return Number.POSITIVE_INFINITY;
|
|
2077
|
+
const base = 36e5 / perHour;
|
|
2078
|
+
switch (spec.rate.distribution) {
|
|
2079
|
+
case "poisson":
|
|
2080
|
+
return -Math.log(1 - this.rng()) * base;
|
|
2081
|
+
case "uniform":
|
|
2082
|
+
return this.rng() * 2 * base;
|
|
2083
|
+
case "profile":
|
|
2084
|
+
case "constant":
|
|
2085
|
+
default:
|
|
2086
|
+
return base;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* 시간대 배율 — `profile[시]`. `profile` 분포일 때만 적용하며, 배열이 짧으면 **순환**한다
|
|
2091
|
+
* (24개면 하루, 8개면 8시간 주기). 미지정·다른 분포면 1(무영향).
|
|
2092
|
+
* 시(hour)는 **시뮬 시각 자신의 프레임**(BASE_EPOCH 기준 UTC)이다 — 계약에 표준시가 없으므로
|
|
2093
|
+
* 현지 시간대 해석은 아직 하지 않는다(꾸미지 않는다).
|
|
2094
|
+
*/
|
|
2095
|
+
/**
|
|
2096
|
+
* 다음 발화 시각 — **발화가 없는 시간대를 영원한 침묵으로 만들지 않는다.**
|
|
2097
|
+
*
|
|
2098
|
+
* 배율 0(그 시간대 도착 없음)이면 간격이 무한이 된다. 그것을 그대로 예약하면 이후 어떤 시간대가
|
|
2099
|
+
* 와도 깨어나지 않는다 — 그래서 **다음 정시로 미뤄 다시 판정**한다(시간대가 바뀌면 배율도 바뀐다).
|
|
2100
|
+
* 시나리오 시작과 구동 루프가 같은 규칙을 쓰도록 한 곳에 둔다(예전에는 시작 경로만 따로였다).
|
|
2101
|
+
*/
|
|
2102
|
+
nextFireMs(spec, from) {
|
|
2103
|
+
const interval = this.intervalMs(spec);
|
|
2104
|
+
if (Number.isFinite(interval)) return from + interval;
|
|
2105
|
+
const hourMs = 36e5;
|
|
2106
|
+
return Math.floor(from / hourMs) * hourMs + hourMs;
|
|
2107
|
+
}
|
|
2108
|
+
profileFactor(rate) {
|
|
2109
|
+
if (rate.distribution !== "profile") return 1;
|
|
2110
|
+
const p = rate.profile;
|
|
2111
|
+
if (!p?.length) return 1;
|
|
2112
|
+
const f = p[this.hourOfDay() % p.length];
|
|
2113
|
+
return Number.isFinite(f) && f >= 0 ? f : 1;
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* 필요 물리 자산을 확보한다 — 인원과 **같은 규칙**(등급으로 요구, 부분 투입 없음, 확정은 나중).
|
|
2117
|
+
* 자산은 사람과 달리 교대가 없고 **자리**가 있다(빈 팔레트가 어디 있는지가 다음 문제이지만,
|
|
2118
|
+
* 지금은 자리를 따지지 않는다 — 따지려면 자산 이송 작업이 먼저 있어야 한다).
|
|
2119
|
+
*/
|
|
2120
|
+
claimAssets(t) {
|
|
2121
|
+
const need = this.operationSpecs.get(t.kind)?.physicalAssetSpecification;
|
|
2122
|
+
if (!need?.length) return [];
|
|
2123
|
+
const picked = [];
|
|
2124
|
+
for (const req of need) {
|
|
2125
|
+
const want = Math.max(0, Math.floor(req.quantity ?? 0));
|
|
2126
|
+
if (!want) continue;
|
|
2127
|
+
const avail = [...this.assets.values()].filter(
|
|
2128
|
+
(a) => a.status === "idle" && !picked.includes(a.id) && (req.assetClass === void 0 || a.assetClass === req.assetClass)
|
|
2129
|
+
);
|
|
2130
|
+
if (avail.length < want) return null;
|
|
2131
|
+
for (let i = 0; i < want; i++) picked.push(avail[i].id);
|
|
2132
|
+
}
|
|
2133
|
+
return picked;
|
|
2134
|
+
}
|
|
2135
|
+
/** 확보한 자산을 작업에 묶는다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
|
|
2136
|
+
assignAssets(t, gear) {
|
|
2137
|
+
if (!gear.length) return;
|
|
2138
|
+
t.assets = gear;
|
|
2139
|
+
for (const id of gear) {
|
|
2140
|
+
const a = this.assets.get(id);
|
|
2141
|
+
if (!a) continue;
|
|
2142
|
+
a.status = "in-use";
|
|
2143
|
+
a.taskId = t.id;
|
|
2144
|
+
if (t.itemEpc) {
|
|
2145
|
+
a.carrying = t.itemEpc;
|
|
2146
|
+
const it = this.items.get(t.itemEpc);
|
|
2147
|
+
if (it) it.carriedBy = a.id;
|
|
2148
|
+
}
|
|
2149
|
+
this.emitAsset(a);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
|
|
2154
|
+
* 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
|
|
2155
|
+
*/
|
|
2156
|
+
releaseAssets(t) {
|
|
2157
|
+
for (const id of t.assets ?? []) {
|
|
2158
|
+
const a = this.assets.get(id);
|
|
2159
|
+
if (!a) continue;
|
|
2160
|
+
a.status = "idle";
|
|
2161
|
+
a.taskId = null;
|
|
2162
|
+
a.location = t.toNode || a.location;
|
|
2163
|
+
if (a.carrying) {
|
|
2164
|
+
const it = this.items.get(a.carrying);
|
|
2165
|
+
if (it) it.carriedBy = void 0;
|
|
2166
|
+
}
|
|
2167
|
+
a.carrying = void 0;
|
|
2168
|
+
this.emitAsset(a);
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* 필요 인원을 확보한다 — **등급으로 요구하고 등급으로 고른다**(특정인 지목이 아니다).
|
|
2173
|
+
* 요구가 없으면 빈 배열, 모자라면 `null`(작업은 기다린다 — **부분 투입으로 시작하지 않는다**).
|
|
2174
|
+
* 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
|
|
2175
|
+
* (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
|
|
2176
|
+
*/
|
|
2177
|
+
claimPersonnel(t) {
|
|
2178
|
+
const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
|
|
2179
|
+
if (!need?.length) return [];
|
|
2180
|
+
const picked = [];
|
|
2181
|
+
for (const req of need) {
|
|
2182
|
+
const want = Math.max(0, Math.floor(req.quantity ?? 0));
|
|
2183
|
+
if (!want) continue;
|
|
2184
|
+
const avail = [...this.persons.values()].filter(
|
|
2185
|
+
(p) => p.status === "idle" && !picked.includes(p.id) && !this.personOffShift(p) && (req.personnelClass === void 0 || p.personnelClass === req.personnelClass)
|
|
2186
|
+
);
|
|
2187
|
+
if (avail.length < want) return null;
|
|
2188
|
+
for (let i = 0; i < want; i++) picked.push(avail[i].id);
|
|
2189
|
+
}
|
|
2190
|
+
return picked;
|
|
2191
|
+
}
|
|
2192
|
+
/** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
|
|
2193
|
+
assignCrew(t, crew) {
|
|
2194
|
+
if (!crew.length) return;
|
|
2195
|
+
t.personnel = crew;
|
|
2196
|
+
for (const id of crew) {
|
|
2197
|
+
const p = this.persons.get(id);
|
|
2198
|
+
if (!p) continue;
|
|
2199
|
+
p.status = "busy";
|
|
2200
|
+
p.taskId = t.id;
|
|
2201
|
+
this.emitPerson(p);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
/** 작업이 끝나면 사람을 놓아 준다 — 설비 해제와 별개 경로. */
|
|
2205
|
+
releaseCrew(t) {
|
|
2206
|
+
for (const id of t.personnel ?? []) {
|
|
2207
|
+
const p = this.persons.get(id);
|
|
2208
|
+
if (!p) continue;
|
|
2209
|
+
p.status = "idle";
|
|
2210
|
+
p.taskId = null;
|
|
2211
|
+
this.emitPerson(p);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
/** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
|
|
2215
|
+
personOffShift(p) {
|
|
2216
|
+
const w = p.window;
|
|
2217
|
+
if (!w) return false;
|
|
2218
|
+
const h = this.hourOfDay();
|
|
2219
|
+
return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
|
|
2223
|
+
* 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
|
|
2224
|
+
*/
|
|
2225
|
+
stationFull(nodeId) {
|
|
2226
|
+
if (!nodeId) return false;
|
|
2227
|
+
const limit = this.nodes.get(nodeId)?.parallelism;
|
|
2228
|
+
if (!(typeof limit === "number" && limit > 0)) return false;
|
|
2229
|
+
let running = 0;
|
|
2230
|
+
for (const t of this.tasks.values()) if (t.status === "in-progress" && t.toNode === nodeId) running++;
|
|
2231
|
+
return running >= limit;
|
|
2232
|
+
}
|
|
2233
|
+
/** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
|
|
2234
|
+
offShift(m) {
|
|
2235
|
+
const w = m.window;
|
|
2236
|
+
if (!w) return false;
|
|
2237
|
+
const h = this.hourOfDay();
|
|
2238
|
+
return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
|
|
2239
|
+
}
|
|
2240
|
+
/** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
|
|
2241
|
+
hourOfDay() {
|
|
2242
|
+
return new Date(BASE_EPOCH + this.clockMs).getUTCHours();
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
|
|
2246
|
+
* `startHour <= endHour` 면 같은 날 구간, 넘어가면 자정을 가로지르는 구간(야간 교대: 22→6).
|
|
2247
|
+
* 미지정이면 언제나 참(24시간 가동).
|
|
2248
|
+
*/
|
|
2249
|
+
inWindow(spec) {
|
|
2250
|
+
const w = spec.window;
|
|
2251
|
+
if (!w) return true;
|
|
2252
|
+
const h = this.hourOfDay();
|
|
2253
|
+
return w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour;
|
|
1332
2254
|
}
|
|
1333
2255
|
/** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
|
|
1334
2256
|
sampleExp(meanMs) {
|
|
@@ -1363,32 +2285,89 @@ var FlowEngine = class {
|
|
|
1363
2285
|
}
|
|
1364
2286
|
}
|
|
1365
2287
|
}
|
|
2288
|
+
/**
|
|
2289
|
+
* 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
|
|
2290
|
+
*
|
|
2291
|
+
* 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
|
|
2292
|
+
* 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
|
|
2293
|
+
* 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
|
|
2294
|
+
*/
|
|
2295
|
+
itemState(i) {
|
|
2296
|
+
const parsedClass = i.gtin ? parseEpc(i.gtin) : void 0;
|
|
2297
|
+
const parsedSelf = parseEpc(i.epc);
|
|
2298
|
+
const lot = parsedClass?.lot ?? parsedSelf.lot ?? (typeof i.ilmd?.[ILMD_ATTR.lot] === "string" ? i.ilmd[ILMD_ATTR.lot] : void 0);
|
|
2299
|
+
const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
|
|
2300
|
+
return {
|
|
2301
|
+
epc: i.epc,
|
|
2302
|
+
...i.gtin ? { gtin: i.gtin } : {},
|
|
2303
|
+
...gtinKey ? { gtinKey } : {},
|
|
2304
|
+
...lot ? { lot } : {},
|
|
2305
|
+
location: i.location,
|
|
2306
|
+
...i.disposition ? { disposition: i.disposition } : {},
|
|
2307
|
+
...i.parent ? { parent: i.parent } : {},
|
|
2308
|
+
...i.carriedBy ? { carriedBy: i.carriedBy } : {},
|
|
2309
|
+
...i.qty !== void 0 ? { qty: i.qty } : {},
|
|
2310
|
+
...i.uom ? { uom: i.uom } : {},
|
|
2311
|
+
...i.expiry !== void 0 ? { expiry: i.expiry } : {},
|
|
2312
|
+
...i.ilmd ? { ilmd: i.ilmd } : {}
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
1366
2315
|
progressOf(t) {
|
|
1367
2316
|
return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
|
|
1368
2317
|
}
|
|
1369
2318
|
generate() {
|
|
1370
2319
|
for (const g of this.gens) {
|
|
1371
2320
|
while (this.clockMs >= g.nextMs) {
|
|
2321
|
+
const next = this.nextFireMs(g.spec, g.nextMs);
|
|
2322
|
+
if (this.intervalMs(g.spec) === Number.POSITIVE_INFINITY) {
|
|
2323
|
+
g.nextMs = next;
|
|
2324
|
+
continue;
|
|
2325
|
+
}
|
|
2326
|
+
if (!this.inWindow(g.spec)) {
|
|
2327
|
+
g.nextMs = next;
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
1372
2330
|
const stimulus = g.spec.stimulus ?? (g.spec.kind === "outbound-order" ? "order" : "arrival");
|
|
1373
2331
|
if (stimulus === "order") this.onOrder(g.spec);
|
|
1374
2332
|
else this.onArrival(g.spec);
|
|
1375
|
-
g.nextMs
|
|
2333
|
+
g.nextMs = next;
|
|
1376
2334
|
}
|
|
1377
2335
|
}
|
|
1378
2336
|
}
|
|
1379
2337
|
processOrders() {
|
|
1380
2338
|
for (const o of this.orders.values()) if (o.status === "created" && !o.held) this.allocate(o);
|
|
1381
2339
|
}
|
|
2340
|
+
/**
|
|
2341
|
+
* 작업 진행 — **진행을 먼저, 배정을 나중에.**
|
|
2342
|
+
*
|
|
2343
|
+
* 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
|
|
2344
|
+
* 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
|
|
2345
|
+
* tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
|
|
2346
|
+
*/
|
|
1382
2347
|
processTasks(dt) {
|
|
2348
|
+
this.advanceTasks(dt);
|
|
2349
|
+
this.assignTasks();
|
|
2350
|
+
}
|
|
2351
|
+
assignTasks() {
|
|
1383
2352
|
for (const t of this.tasks.values()) {
|
|
1384
2353
|
if (t.status !== "created") continue;
|
|
2354
|
+
const crew = this.claimPersonnel(t);
|
|
2355
|
+
if (crew === null) continue;
|
|
2356
|
+
const gear = this.claimAssets(t);
|
|
2357
|
+
if (gear === null) continue;
|
|
1385
2358
|
if (t.intent === "dwell") {
|
|
1386
2359
|
t.status = "in-progress";
|
|
1387
2360
|
t.remainingMs = t.durationMs;
|
|
2361
|
+
t.startedAtSimMs = this.clockMs;
|
|
2362
|
+
this.assignCrew(t, crew);
|
|
2363
|
+
this.assignAssets(t, gear);
|
|
1388
2364
|
this.emitTask(t);
|
|
1389
2365
|
continue;
|
|
1390
2366
|
}
|
|
1391
|
-
|
|
2367
|
+
if (this.stationFull(t.toNode)) continue;
|
|
2368
|
+
const mover = [...this.movers.values()].find(
|
|
2369
|
+
(m) => m.status === "idle" && !m.held && !this.offShift(m) && (t.resourceType === void 0 || m.kind === t.resourceType)
|
|
2370
|
+
);
|
|
1392
2371
|
if (!mover) continue;
|
|
1393
2372
|
if (t.setupMs && t.changeoverKey !== void 0 && mover.lastChangeoverKey !== void 0 && mover.lastChangeoverKey !== t.changeoverKey) {
|
|
1394
2373
|
t.appliedSetupMs = t.setupMs;
|
|
@@ -1400,10 +2379,16 @@ var FlowEngine = class {
|
|
|
1400
2379
|
t.status = "in-progress";
|
|
1401
2380
|
t.resource = mover.id;
|
|
1402
2381
|
t.remainingMs = t.durationMs;
|
|
2382
|
+
t.startedAtSimMs = this.clockMs;
|
|
2383
|
+
this.assignCrew(t, crew);
|
|
2384
|
+
this.assignAssets(t, gear);
|
|
1403
2385
|
this.emitTask(t);
|
|
1404
2386
|
if (t.intent === "process") this.emitMover(mover);
|
|
1405
2387
|
else this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
|
|
1406
2388
|
}
|
|
2389
|
+
}
|
|
2390
|
+
/** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
|
|
2391
|
+
advanceTasks(dt) {
|
|
1407
2392
|
for (const t of this.tasks.values()) {
|
|
1408
2393
|
if (t.status !== "in-progress") continue;
|
|
1409
2394
|
if (t.resource && this.movers.get(t.resource)?.status === "down") continue;
|
|
@@ -1411,6 +2396,8 @@ var FlowEngine = class {
|
|
|
1411
2396
|
if (t.remainingMs > 0) continue;
|
|
1412
2397
|
this.onTaskComplete(t);
|
|
1413
2398
|
t.status = "completed";
|
|
2399
|
+
this.releaseCrew(t);
|
|
2400
|
+
this.releaseAssets(t);
|
|
1414
2401
|
if (!t.resource) {
|
|
1415
2402
|
this.emitTask(t);
|
|
1416
2403
|
continue;
|
|
@@ -1450,11 +2437,23 @@ var WmsKernel = class extends FlowEngine {
|
|
|
1450
2437
|
const qtyList = [{ epcClass: gtin, quantity: qty }];
|
|
1451
2438
|
const poTxn = [{ type: BTT.po, bizTransaction: po }];
|
|
1452
2439
|
const expiry = this.clockMs + SHELF_MS - this.epcSeq % 5 * SHELF_JITTER_MS;
|
|
1453
|
-
|
|
2440
|
+
const ilmd = { [ILMD_ATTR.expiry]: expiry };
|
|
2441
|
+
this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
|
|
1454
2442
|
dock.occupancy++;
|
|
1455
2443
|
this.emit(transactionEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
|
|
1456
2444
|
this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
|
|
1457
|
-
this.emit(objectEvent({
|
|
2445
|
+
this.emit(objectEvent({
|
|
2446
|
+
eventTime,
|
|
2447
|
+
action: "ADD",
|
|
2448
|
+
bizStep: BIZSTEP.receiving,
|
|
2449
|
+
disposition: DISP.in_progress,
|
|
2450
|
+
epcList: [epc],
|
|
2451
|
+
quantityList: qtyList,
|
|
2452
|
+
readPoint: dock.id,
|
|
2453
|
+
bizLocation: dock.id,
|
|
2454
|
+
bizTransactionList: poTxn,
|
|
2455
|
+
ilmd
|
|
2456
|
+
}));
|
|
1458
2457
|
const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews("storage") });
|
|
1459
2458
|
if (!binId) return;
|
|
1460
2459
|
const id = `task-${++this.taskSeq}`;
|
|
@@ -1528,12 +2527,12 @@ var WmsKernel = class extends FlowEngine {
|
|
|
1528
2527
|
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 }));
|
|
1529
2528
|
const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
|
|
1530
2529
|
for (const epc of chosen) {
|
|
1531
|
-
this.items.get(epc).disposition = DISP.reserved;
|
|
1532
2530
|
o.allocated.push(epc);
|
|
1533
2531
|
chosenAll.push(epc);
|
|
1534
2532
|
}
|
|
1535
2533
|
}
|
|
1536
2534
|
if (chosenAll.length === 0) return;
|
|
2535
|
+
this.reserve(chosenAll, BIZSTEP.storing);
|
|
1537
2536
|
this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
|
|
1538
2537
|
for (const epc of chosenAll) {
|
|
1539
2538
|
const it = this.items.get(epc);
|
|
@@ -1677,7 +2676,7 @@ var YmsKernel = class extends FlowEngine {
|
|
|
1677
2676
|
if (!staging || avail.length < CARGO_PER_TRAILER) return;
|
|
1678
2677
|
this.trailerCargo.set(trailer.epc, avail.slice(0, CARGO_PER_TRAILER).map((i) => i.epc));
|
|
1679
2678
|
}
|
|
1680
|
-
trailer.
|
|
2679
|
+
this.reserve([trailer.epc], bizStep);
|
|
1681
2680
|
this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep, bizTransactionList: [{ type: BTT_DELIVERY, bizTransaction: o.bizTransaction }], epcList: [trailer.epc], readPoint: door.id }));
|
|
1682
2681
|
const dockKind = mode === "drop" ? "pull" : "spot-live";
|
|
1683
2682
|
const task = { id: `task-${++this.taskSeq}`, kind: dockKind, status: "created", itemEpc: trailer.epc, fromNode: trailer.location, toNode: door.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: dockKind, fromNode: trailer.location, toNode: door.id }, TRAVEL_MS2), orderId: o.id };
|
|
@@ -1711,7 +2710,7 @@ var YmsKernel = class extends FlowEngine {
|
|
|
1711
2710
|
const outbound = order2?.kind === "appointment-out";
|
|
1712
2711
|
const staging = this.nodeByType("staging");
|
|
1713
2712
|
const cargo = this.trailerCargo.get(trailer.epc) ?? [];
|
|
1714
|
-
this.
|
|
2713
|
+
this.observeDisposition([trailer.epc], DISP.in_progress, outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, to.id);
|
|
1715
2714
|
if (outbound) {
|
|
1716
2715
|
if (cargo.length && staging) {
|
|
1717
2716
|
this.aggregate(trailer.epc, cargo, { bizStep: YARD_BIZSTEP.loading, readPoint: to.id, consume: { readPoint: staging.id, disposition: DISP.in_transit } });
|
|
@@ -1744,13 +2743,13 @@ var YmsKernel = class extends FlowEngine {
|
|
|
1744
2743
|
};
|
|
1745
2744
|
|
|
1746
2745
|
// src/mes-kernel.ts
|
|
1747
|
-
var
|
|
1748
|
-
var
|
|
2746
|
+
var DEFAULT_CYCLE_MS = 4e4;
|
|
2747
|
+
var DEFAULT_SETUP_MS = 15e3;
|
|
1749
2748
|
var MES_CMD = { changeover: "mes.changeover" };
|
|
1750
2749
|
var CP2 = "0614141";
|
|
1751
2750
|
var WIP_ITEMREF = "066666";
|
|
1752
2751
|
var WIP_GTIN = sgtinClass(CP2, WIP_ITEMREF);
|
|
1753
|
-
var
|
|
2752
|
+
var DEFAULT_YIELD = 0.8;
|
|
1754
2753
|
var PART_A = { itemRef: "055551", gtin: sgtinClass(CP2, "055551") };
|
|
1755
2754
|
var PART_B = { itemRef: "055552", gtin: sgtinClass(CP2, "055552") };
|
|
1756
2755
|
var PRODUCTS = [
|
|
@@ -1774,6 +2773,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1774
2773
|
constructor(tenantId, policy = firstFitPolicy, mesSpec) {
|
|
1775
2774
|
super(tenantId, policy);
|
|
1776
2775
|
this.mesSpec = mesSpec;
|
|
2776
|
+
if (mesSpec?.definition?.operations) this.loadOperations(mesSpec.definition.operations);
|
|
1777
2777
|
}
|
|
1778
2778
|
productOf(gtin) {
|
|
1779
2779
|
return PRODUCTS.find((p) => p.gtin === gtin);
|
|
@@ -1781,7 +2781,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1781
2781
|
/**
|
|
1782
2782
|
* MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
|
|
1783
2783
|
* 자동 체인지오버(task.changeoverKey 상이 시 셋업)의 수동 버전 — 운영자가 사전 전환(툴링 교체) 지시.
|
|
1784
|
-
* 이미 그 제품이면 no-op, 아니면 셋업(
|
|
2784
|
+
* 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
|
|
1785
2785
|
* (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
|
|
1786
2786
|
*/
|
|
1787
2787
|
handleCommand(cmd) {
|
|
@@ -1791,7 +2791,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1791
2791
|
const m = this.movers.get(a.resourceId);
|
|
1792
2792
|
if (!m) return { commandId: cmd.commandId, accepted: false, errorCode: "resource-not-found", errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
|
|
1793
2793
|
if (m.lastChangeoverKey !== a.gtin) {
|
|
1794
|
-
m.setupMs +=
|
|
2794
|
+
m.setupMs += DEFAULT_SETUP_MS;
|
|
1795
2795
|
m.lastChangeoverKey = a.gtin;
|
|
1796
2796
|
this.emitMover(m);
|
|
1797
2797
|
}
|
|
@@ -1836,10 +2836,8 @@ var MesKernel = class extends FlowEngine {
|
|
|
1836
2836
|
if (chosen.length < line.qty) return;
|
|
1837
2837
|
picks.push(...chosen);
|
|
1838
2838
|
}
|
|
1839
|
-
for (const epc of picks)
|
|
1840
|
-
|
|
1841
|
-
o.allocated.push(epc);
|
|
1842
|
-
}
|
|
2839
|
+
for (const epc of picks) o.allocated.push(epc);
|
|
2840
|
+
this.reserve(picks, MES_BIZSTEP.producing);
|
|
1843
2841
|
this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
|
|
1844
2842
|
this.emitStation(o, s0, o.allocated[0], product.gtin);
|
|
1845
2843
|
o.status = "op-" + s0.kind;
|
|
@@ -1848,7 +2846,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1848
2846
|
/** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
|
|
1849
2847
|
emitStation(o, stage, itemEpc, changeoverKey) {
|
|
1850
2848
|
const node = this.nodeByType(stage.node);
|
|
1851
|
-
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 },
|
|
2849
|
+
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" };
|
|
1852
2850
|
this.tasks.set(task.id, task);
|
|
1853
2851
|
this.emitTask(task);
|
|
1854
2852
|
}
|
|
@@ -1873,7 +2871,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1873
2871
|
}
|
|
1874
2872
|
const fgStore = this.nodeByType("fg-store");
|
|
1875
2873
|
const wip = order.allocated[0];
|
|
1876
|
-
const good = this.rng() <
|
|
2874
|
+
const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
|
|
1877
2875
|
this.recordOutput(t.resource, good);
|
|
1878
2876
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
1879
2877
|
const outputEpc = sgtinUri(CP2, product.ref, ++this.prodSeq);
|
|
@@ -1936,10 +2934,8 @@ var MesKernel = class extends FlowEngine {
|
|
|
1936
2934
|
if (chosen.length < line.qty) return;
|
|
1937
2935
|
picks.push(...chosen);
|
|
1938
2936
|
}
|
|
1939
|
-
for (const epc of picks)
|
|
1940
|
-
|
|
1941
|
-
o.allocated.push(epc);
|
|
1942
|
-
}
|
|
2937
|
+
for (const epc of picks) o.allocated.push(epc);
|
|
2938
|
+
this.reserve(picks, MES_BIZSTEP.producing);
|
|
1943
2939
|
this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
|
|
1944
2940
|
this.emitStationDef(o, ops[0], o.allocated[0]);
|
|
1945
2941
|
o.status = "op-" + ops[0].key;
|
|
@@ -1947,7 +2943,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1947
2943
|
}
|
|
1948
2944
|
emitStationDef(o, op, itemEpc) {
|
|
1949
2945
|
const node = this.nodeByType(op.nodeType);
|
|
1950
|
-
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 },
|
|
2946
|
+
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 };
|
|
1951
2947
|
this.tasks.set(task.id, task);
|
|
1952
2948
|
this.emitTask(task);
|
|
1953
2949
|
}
|
|
@@ -1974,7 +2970,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1974
2970
|
}
|
|
1975
2971
|
const fgStore = this.nodeByType("fg-store");
|
|
1976
2972
|
const wip = order.allocated[0];
|
|
1977
|
-
const good = this.rng() <
|
|
2973
|
+
const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
|
|
1978
2974
|
this.recordOutput(t.resource, good);
|
|
1979
2975
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
1980
2976
|
const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
|
|
@@ -2002,6 +2998,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
2002
2998
|
EPCIS_CONTEXT,
|
|
2003
2999
|
EventJournal,
|
|
2004
3000
|
FlowEngine,
|
|
3001
|
+
ILMD_ATTR,
|
|
2005
3002
|
MES_BIZSTEP,
|
|
2006
3003
|
MES_NODE_TYPES,
|
|
2007
3004
|
MES_PART_GTINS,
|
|
@@ -2009,7 +3006,10 @@ var MesKernel = class extends FlowEngine {
|
|
|
2009
3006
|
MES_PRODUCT_GTINS,
|
|
2010
3007
|
MES_TYPES,
|
|
2011
3008
|
MesKernel,
|
|
3009
|
+
NODE_SATURATION_NEAR,
|
|
2012
3010
|
OP_EVENT,
|
|
3011
|
+
OP_PARAM,
|
|
3012
|
+
ObservedReducer,
|
|
2013
3013
|
StateProjector,
|
|
2014
3014
|
TwinHistory,
|
|
2015
3015
|
TwinObserver,
|
|
@@ -2035,9 +3035,13 @@ var MesKernel = class extends FlowEngine {
|
|
|
2035
3035
|
gdtiUri,
|
|
2036
3036
|
graiUri,
|
|
2037
3037
|
ingest,
|
|
3038
|
+
lgtinClass,
|
|
2038
3039
|
mapRecord,
|
|
2039
3040
|
monteCarloForecast,
|
|
3041
|
+
nodeStatusOf,
|
|
2040
3042
|
objectEvent,
|
|
3043
|
+
parseEpc,
|
|
3044
|
+
parseIsoDuration,
|
|
2041
3045
|
partialFitPolicy,
|
|
2042
3046
|
replay,
|
|
2043
3047
|
sgtinClass,
|