@operato/twin-kernel 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.d.ts +209 -1
- package/dist/contract.js +4 -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 +171 -3
- package/dist/flow-engine.js +522 -21
- package/dist/forecast.d.ts +8 -0
- package/dist/forecast.js +9 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/iso-duration.d.ts +5 -0
- package/dist/iso-duration.js +43 -0
- package/dist/kernel.js +8 -2
- package/dist/mes-kernel.d.ts +2 -2
- package/dist/mes-kernel.js +18 -9
- package/dist/state-projector.d.ts +65 -2
- package/dist/state-projector.js +241 -17
- package/dist/twin-observer.d.ts +2 -0
- package/dist/twin-observer.js +9 -3
- package/dist-cjs/index.cjs +950 -158
- 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,
|
|
@@ -40,6 +41,7 @@ __export(index_exports, {
|
|
|
40
41
|
MES_TYPES: () => MES_TYPES,
|
|
41
42
|
MesKernel: () => MesKernel,
|
|
42
43
|
OP_EVENT: () => OP_EVENT,
|
|
44
|
+
OP_PARAM: () => OP_PARAM,
|
|
43
45
|
StateProjector: () => StateProjector,
|
|
44
46
|
TwinHistory: () => TwinHistory,
|
|
45
47
|
TwinObserver: () => TwinObserver,
|
|
@@ -65,9 +67,12 @@ __export(index_exports, {
|
|
|
65
67
|
gdtiUri: () => gdtiUri,
|
|
66
68
|
graiUri: () => graiUri,
|
|
67
69
|
ingest: () => ingest,
|
|
70
|
+
lgtinClass: () => lgtinClass,
|
|
68
71
|
mapRecord: () => mapRecord,
|
|
69
72
|
monteCarloForecast: () => monteCarloForecast,
|
|
70
73
|
objectEvent: () => objectEvent,
|
|
74
|
+
parseEpc: () => parseEpc,
|
|
75
|
+
parseIsoDuration: () => parseIsoDuration,
|
|
71
76
|
partialFitPolicy: () => partialFitPolicy,
|
|
72
77
|
replay: () => replay,
|
|
73
78
|
sgtinClass: () => sgtinClass,
|
|
@@ -85,6 +90,10 @@ module.exports = __toCommonJS(index_exports);
|
|
|
85
90
|
var OP_EVENT = {
|
|
86
91
|
task: "task.status",
|
|
87
92
|
equipment: "equipment.status",
|
|
93
|
+
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
94
|
+
person: "person.status",
|
|
95
|
+
/** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
|
|
96
|
+
asset: "asset.status",
|
|
88
97
|
order: "order.status",
|
|
89
98
|
quality: "quality.output"
|
|
90
99
|
// 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
|
|
@@ -111,6 +120,13 @@ var CMD = {
|
|
|
111
120
|
};
|
|
112
121
|
|
|
113
122
|
// src/domain-definition.ts
|
|
123
|
+
var OP_PARAM = {
|
|
124
|
+
/** 양품률(0..1, 무차원). 없으면 커널 기본값 — 기본값을 쓴 사실은 `specCoverage()` 가 밝힌다. */
|
|
125
|
+
yield: "yield",
|
|
126
|
+
/** 셋업·체인지오버 소요(ISO 8601 기간 문자열). ISA-95 는 셋업을 별도 세그먼트로도 표현하지만,
|
|
127
|
+
* 현재 커널은 작업에 붙는 셋업으로 다루므로 모수로 받는다. */
|
|
128
|
+
setupDuration: "setupDuration"
|
|
129
|
+
};
|
|
114
130
|
var INTENTS = ["transport", "process", "dwell"];
|
|
115
131
|
function dupes(keys) {
|
|
116
132
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -179,6 +195,9 @@ function compareStates(predicted, actual) {
|
|
|
179
195
|
}
|
|
180
196
|
|
|
181
197
|
// src/counterfactual.ts
|
|
198
|
+
function clockOf(twin) {
|
|
199
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
200
|
+
}
|
|
182
201
|
var TwinHistory = class {
|
|
183
202
|
live;
|
|
184
203
|
tickMs;
|
|
@@ -189,7 +208,7 @@ var TwinHistory = class {
|
|
|
189
208
|
}
|
|
190
209
|
/** 현재를 체크포인트로 저장(호스트가 주기적으로 호출). */
|
|
191
210
|
checkpoint() {
|
|
192
|
-
this.checkpoints.push({ simMs: this.live
|
|
211
|
+
this.checkpoints.push({ simMs: clockOf(this.live), twin: this.live.fork() });
|
|
193
212
|
}
|
|
194
213
|
get count() {
|
|
195
214
|
return this.checkpoints.length;
|
|
@@ -201,7 +220,7 @@ var TwinHistory = class {
|
|
|
201
220
|
if (!best) return void 0;
|
|
202
221
|
const t = best.twin.fork();
|
|
203
222
|
let guard = 0;
|
|
204
|
-
while (t
|
|
223
|
+
while (clockOf(t) < simMs && guard++ < 1e6) t.tick(this.tickMs);
|
|
205
224
|
return t;
|
|
206
225
|
}
|
|
207
226
|
};
|
|
@@ -215,7 +234,7 @@ function counterfactualAt(history, atSimMs, opts) {
|
|
|
215
234
|
const baseline = base.fork();
|
|
216
235
|
const run = (t) => {
|
|
217
236
|
let g = 0;
|
|
218
|
-
while (t
|
|
237
|
+
while (clockOf(t) < target && g++ < 1e6) t.tick(step);
|
|
219
238
|
};
|
|
220
239
|
run(withAlt);
|
|
221
240
|
run(baseline);
|
|
@@ -223,8 +242,11 @@ function counterfactualAt(history, atSimMs, opts) {
|
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
// src/forecast.ts
|
|
245
|
+
function clockOf2(twin) {
|
|
246
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
247
|
+
}
|
|
226
248
|
function monteCarloForecast(twin, opts) {
|
|
227
|
-
const now = twin
|
|
249
|
+
const now = clockOf2(twin);
|
|
228
250
|
const step = opts.tickMs ?? 1e3;
|
|
229
251
|
const baseSeed = opts.scenario.seed ?? 1;
|
|
230
252
|
const samples = [];
|
|
@@ -234,7 +256,7 @@ function monteCarloForecast(twin, opts) {
|
|
|
234
256
|
fc.scenario.start();
|
|
235
257
|
const target = now + opts.horizonMs;
|
|
236
258
|
let guard = 0;
|
|
237
|
-
while (fc
|
|
259
|
+
while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
|
|
238
260
|
samples.push(opts.metric(fc.getSnapshot()));
|
|
239
261
|
}
|
|
240
262
|
return summarize(opts.runs, samples);
|
|
@@ -247,6 +269,9 @@ function summarize(runs, samples) {
|
|
|
247
269
|
}
|
|
248
270
|
|
|
249
271
|
// src/twin-observer.ts
|
|
272
|
+
function clockOf3(twin) {
|
|
273
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
274
|
+
}
|
|
250
275
|
var TwinObserver = class {
|
|
251
276
|
live;
|
|
252
277
|
opts;
|
|
@@ -257,7 +282,7 @@ var TwinObserver = class {
|
|
|
257
282
|
}
|
|
258
283
|
/** 관측 1회 — 만기 예측을 실제와 대조(발산 알림) + 새 예측 생성. 호스트가 주기적으로 호출. */
|
|
259
284
|
observe() {
|
|
260
|
-
const now = this.live
|
|
285
|
+
const now = clockOf3(this.live);
|
|
261
286
|
const due = this.pending.filter((p) => p.horizonSimMs <= now);
|
|
262
287
|
this.pending = this.pending.filter((p) => p.horizonSimMs > now);
|
|
263
288
|
for (const p of due) {
|
|
@@ -268,8 +293,9 @@ var TwinObserver = class {
|
|
|
268
293
|
const step = this.opts.tickMs ?? 1e3;
|
|
269
294
|
const target = now + this.opts.horizonMs;
|
|
270
295
|
let guard = 0;
|
|
271
|
-
while (fc
|
|
272
|
-
|
|
296
|
+
while (clockOf3(fc) < target && guard++ < 1e6) fc.tick(step);
|
|
297
|
+
const predicted = fc.getSnapshot();
|
|
298
|
+
this.pending.push({ madeAtSimMs: now, horizonSimMs: predicted.simClockMs, predicted });
|
|
273
299
|
}
|
|
274
300
|
/** 대기 중(아직 만기 안 된) 예측 수 — 진단용. */
|
|
275
301
|
get pendingCount() {
|
|
@@ -277,19 +303,241 @@ var TwinObserver = class {
|
|
|
277
303
|
}
|
|
278
304
|
};
|
|
279
305
|
|
|
306
|
+
// src/epcis.ts
|
|
307
|
+
var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
|
|
308
|
+
var UTC_OFFSET = "+00:00";
|
|
309
|
+
var DISP = {
|
|
310
|
+
in_progress: "urn:epcglobal:cbv:disp:in_progress",
|
|
311
|
+
sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
|
|
312
|
+
reserved: "urn:epcglobal:cbv:disp:reserved",
|
|
313
|
+
in_transit: "urn:epcglobal:cbv:disp:in_transit",
|
|
314
|
+
non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
|
|
315
|
+
// 불량/scrap
|
|
316
|
+
};
|
|
317
|
+
function ssccUri(companyPrefix, serial) {
|
|
318
|
+
return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
|
|
319
|
+
}
|
|
320
|
+
function sgtinClass(companyPrefix, itemRef) {
|
|
321
|
+
return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
|
|
322
|
+
}
|
|
323
|
+
var ILMD_ATTR = {
|
|
324
|
+
/** 유통기한·만료(로트 단위). */
|
|
325
|
+
expiry: "cbvmda:itemExpirationDate",
|
|
326
|
+
/** 로트·배치 번호(직렬 개체에 로트를 붙일 때). */
|
|
327
|
+
lot: "cbvmda:lotNumber"
|
|
328
|
+
};
|
|
329
|
+
function lgtinClass(companyPrefix, itemRefAndIndicator, lot) {
|
|
330
|
+
return `urn:epc:class:lgtin:${companyPrefix}.${itemRefAndIndicator}.${encodeURIComponent(lot)}`;
|
|
331
|
+
}
|
|
332
|
+
function parseEpc(uri) {
|
|
333
|
+
const raw = String(uri ?? "");
|
|
334
|
+
const cls = raw.match(/^urn:epc:class:lgtin:(.+)$/);
|
|
335
|
+
if (cls) {
|
|
336
|
+
const seg = cls[1].split(".");
|
|
337
|
+
const lot = seg.slice(2).join(".");
|
|
338
|
+
return {
|
|
339
|
+
scheme: "lgtin",
|
|
340
|
+
instance: false,
|
|
341
|
+
gtinKey: seg.slice(0, 2).join("."),
|
|
342
|
+
lot: lot ? decodeURIComponent(lot) : void 0,
|
|
343
|
+
uri: raw
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const pat = raw.match(/^urn:epc:idpat:sgtin:(.+)$/);
|
|
347
|
+
if (pat) {
|
|
348
|
+
const seg = pat[1].split(".");
|
|
349
|
+
return { scheme: "idpat", instance: false, gtinKey: seg.slice(0, 2).join("."), uri: raw };
|
|
350
|
+
}
|
|
351
|
+
const id = raw.match(/^urn:epc:id:([a-z]+):(.+)$/);
|
|
352
|
+
if (id) {
|
|
353
|
+
const scheme = id[1];
|
|
354
|
+
const seg = id[2].split(".");
|
|
355
|
+
const known = ["sgtin", "sscc", "gdti", "grai", "giai", "sgln"].includes(scheme);
|
|
356
|
+
return {
|
|
357
|
+
scheme: known ? scheme : "unknown",
|
|
358
|
+
instance: true,
|
|
359
|
+
...scheme === "sgtin" ? { gtinKey: seg.slice(0, 2).join("."), serial: seg[2] } : { serial: seg.slice(1).join(".") },
|
|
360
|
+
uri: raw
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return { scheme: "unknown", instance: false, uri: raw };
|
|
364
|
+
}
|
|
365
|
+
function gdtiUri(companyPrefix, docType, serial) {
|
|
366
|
+
return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
|
|
367
|
+
}
|
|
368
|
+
function header(type, eventTime, bizStep, opts) {
|
|
369
|
+
const h = {
|
|
370
|
+
"@context": EPCIS_CONTEXT,
|
|
371
|
+
type,
|
|
372
|
+
eventTime,
|
|
373
|
+
eventTimeZoneOffset: UTC_OFFSET,
|
|
374
|
+
bizStep
|
|
375
|
+
};
|
|
376
|
+
if (opts?.eventID) h.eventID = opts.eventID;
|
|
377
|
+
if (opts?.recordTime) h.recordTime = opts.recordTime;
|
|
378
|
+
if (opts?.errorDeclaration) h.errorDeclaration = opts.errorDeclaration;
|
|
379
|
+
if (opts?.ilmd) h.ilmd = opts.ilmd;
|
|
380
|
+
if (opts?.sourceList) h.sourceList = opts.sourceList;
|
|
381
|
+
if (opts?.destinationList) h.destinationList = opts.destinationList;
|
|
382
|
+
if (opts?.persistentDisposition) h.persistentDisposition = opts.persistentDisposition;
|
|
383
|
+
if (opts?.sensorElementList) h.sensorElementList = opts.sensorElementList;
|
|
384
|
+
if (opts?.certificationInfo) h.certificationInfo = opts.certificationInfo;
|
|
385
|
+
return h;
|
|
386
|
+
}
|
|
387
|
+
function common(type, eventTime, action, bizStep, opts) {
|
|
388
|
+
return { ...header(type, eventTime, bizStep, opts), action };
|
|
389
|
+
}
|
|
390
|
+
function objectEvent(p) {
|
|
391
|
+
const e = { ...common("ObjectEvent", p.eventTime, p.action, p.bizStep, p), epcList: p.epcList };
|
|
392
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
393
|
+
if (p.quantityList) e.quantityList = p.quantityList;
|
|
394
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
395
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
396
|
+
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
397
|
+
return e;
|
|
398
|
+
}
|
|
399
|
+
function aggregationEvent(p) {
|
|
400
|
+
const e = { ...common("AggregationEvent", p.eventTime, p.action, p.bizStep, p), parentID: p.parentID };
|
|
401
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
402
|
+
if (p.childEPCs) e.childEPCs = p.childEPCs;
|
|
403
|
+
if (p.childQuantityList) e.childQuantityList = p.childQuantityList;
|
|
404
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
405
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
406
|
+
return e;
|
|
407
|
+
}
|
|
408
|
+
function transactionEvent(p) {
|
|
409
|
+
const e = {
|
|
410
|
+
...common("TransactionEvent", p.eventTime, p.action, p.bizStep, p),
|
|
411
|
+
bizTransactionList: p.bizTransactionList
|
|
412
|
+
};
|
|
413
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
414
|
+
if (p.parentID) e.parentID = p.parentID;
|
|
415
|
+
if (p.epcList) e.epcList = p.epcList;
|
|
416
|
+
if (p.quantityList) e.quantityList = p.quantityList;
|
|
417
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
418
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
419
|
+
return e;
|
|
420
|
+
}
|
|
421
|
+
function transformationEvent(p) {
|
|
422
|
+
const e = header("TransformationEvent", p.eventTime, p.bizStep, p);
|
|
423
|
+
if (p.disposition) e.disposition = p.disposition;
|
|
424
|
+
if (p.inputEPCList) e.inputEPCList = p.inputEPCList;
|
|
425
|
+
if (p.inputQuantityList) e.inputQuantityList = p.inputQuantityList;
|
|
426
|
+
if (p.outputEPCList) e.outputEPCList = p.outputEPCList;
|
|
427
|
+
if (p.outputQuantityList) e.outputQuantityList = p.outputQuantityList;
|
|
428
|
+
if (p.transformationID) e.transformationID = p.transformationID;
|
|
429
|
+
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
430
|
+
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
431
|
+
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
432
|
+
return e;
|
|
433
|
+
}
|
|
434
|
+
var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
435
|
+
var TZ_RE = /^[+-]\d{2}:\d{2}$/;
|
|
436
|
+
var ACTIONS = ["ADD", "OBSERVE", "DELETE"];
|
|
437
|
+
function validateEpcisEvent(e) {
|
|
438
|
+
const v = [];
|
|
439
|
+
if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
|
|
440
|
+
if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
|
|
441
|
+
if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
|
|
442
|
+
if (typeof e.eventTimeZoneOffset !== "string" || !TZ_RE.test(e.eventTimeZoneOffset)) v.push("eventTimeZoneOffset \uD615\uC2DD \uC624\uB958");
|
|
443
|
+
if (typeof e.bizStep !== "string" || !e.bizStep) v.push("bizStep \uB204\uB77D");
|
|
444
|
+
if (e.eventID !== void 0 && (typeof e.eventID !== "string" || !e.eventID)) v.push("eventID \uAC00 \uBE48 \uBB38\uC790\uC5F4");
|
|
445
|
+
if (e.recordTime !== void 0 && (typeof e.recordTime !== "string" || !ISO_RE.test(e.recordTime))) {
|
|
446
|
+
v.push("recordTime ISO8601 \uC544\uB2D8");
|
|
447
|
+
}
|
|
448
|
+
if (e.ilmd !== void 0) {
|
|
449
|
+
const allowed = e.type === "ObjectEvent" && e.action === "ADD" || e.type === "TransformationEvent";
|
|
450
|
+
if (!allowed) v.push("ilmd \uB294 ObjectEvent(action=ADD) \uB610\uB294 TransformationEvent \uC5D0\uB9CC \uC2E4\uC744 \uC218 \uC788\uB2E4");
|
|
451
|
+
}
|
|
452
|
+
for (const sd of e.sourceList ?? []) {
|
|
453
|
+
if (!sd?.type || !sd?.source) v.push("sourceList \uD56D\uBAA9\uC5D0 type \uB610\uB294 source \uB204\uB77D");
|
|
454
|
+
}
|
|
455
|
+
for (const sd of e.destinationList ?? []) {
|
|
456
|
+
if (!sd?.type || !sd?.destination) v.push("destinationList \uD56D\uBAA9\uC5D0 type \uB610\uB294 destination \uB204\uB77D");
|
|
457
|
+
}
|
|
458
|
+
if (e.persistentDisposition !== void 0) {
|
|
459
|
+
const set = e.persistentDisposition.set ?? [];
|
|
460
|
+
const unset = e.persistentDisposition.unset ?? [];
|
|
461
|
+
if (!set.length && !unset.length) v.push("persistentDisposition \uC774 set\xB7unset \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
462
|
+
const both = set.filter((x) => unset.includes(x));
|
|
463
|
+
if (both.length) v.push(`persistentDisposition \uC774 \uAC19\uC740 \uAC12\uC744 set\xB7unset \uB3D9\uC2DC \uC9C0\uC815: ${both.join(", ")}`);
|
|
464
|
+
}
|
|
465
|
+
for (const se of e.sensorElementList ?? []) {
|
|
466
|
+
if (!Array.isArray(se?.sensorReport) || se.sensorReport.length === 0) {
|
|
467
|
+
v.push("sensorElement \uC5D0 sensorReport \uAC00 \uD558\uB098\uB3C4 \uC5C6\uC74C");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (e.errorDeclaration !== void 0) {
|
|
471
|
+
const d = e.errorDeclaration;
|
|
472
|
+
if (typeof d?.declarationTime !== "string" || !ISO_RE.test(d.declarationTime)) {
|
|
473
|
+
v.push("errorDeclaration.declarationTime ISO8601 \uC544\uB2D8/\uB204\uB77D");
|
|
474
|
+
}
|
|
475
|
+
if (d?.correctiveEventIDs !== void 0) {
|
|
476
|
+
if (!Array.isArray(d.correctiveEventIDs)) v.push("errorDeclaration.correctiveEventIDs \uBC30\uC5F4 \uC544\uB2D8");
|
|
477
|
+
else if (d.correctiveEventIDs.some((x) => typeof x !== "string" || !x)) {
|
|
478
|
+
v.push("errorDeclaration.correctiveEventIDs \uC5D0 \uBE48 \uAC12");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (e.type === "ObjectEvent") {
|
|
483
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
484
|
+
if (!Array.isArray(e.epcList)) v.push("ObjectEvent.epcList \uB204\uB77D");
|
|
485
|
+
else if (e.epcList.length === 0 && !e.quantityList?.length) v.push("ObjectEvent: epcList/quantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
486
|
+
} else if (e.type === "AggregationEvent") {
|
|
487
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
488
|
+
if (!e.parentID) v.push("AggregationEvent.parentID \uB204\uB77D");
|
|
489
|
+
if (!e.childEPCs?.length && !e.childQuantityList?.length) v.push("AggregationEvent: childEPCs/childQuantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
490
|
+
} else if (e.type === "TransactionEvent") {
|
|
491
|
+
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
492
|
+
if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0) v.push("TransactionEvent.bizTransactionList \uB204\uB77D");
|
|
493
|
+
} else if (e.type === "TransformationEvent") {
|
|
494
|
+
if (!e.inputEPCList?.length && !e.inputQuantityList?.length) v.push("TransformationEvent: input \uBE44\uC5B4\uC788\uC74C");
|
|
495
|
+
if (!e.outputEPCList?.length && !e.outputQuantityList?.length) v.push("TransformationEvent: output \uBE44\uC5B4\uC788\uC74C");
|
|
496
|
+
}
|
|
497
|
+
const qtyLists = [
|
|
498
|
+
"quantityList" in e ? e.quantityList : void 0,
|
|
499
|
+
"childQuantityList" in e ? e.childQuantityList : void 0,
|
|
500
|
+
"inputQuantityList" in e ? e.inputQuantityList : void 0,
|
|
501
|
+
"outputQuantityList" in e ? e.outputQuantityList : void 0
|
|
502
|
+
];
|
|
503
|
+
for (const list of qtyLists) for (const q of list ?? []) {
|
|
504
|
+
if (!q.epcClass?.startsWith("urn:epc:idpat:") && !q.epcClass?.startsWith("urn:epc:class:")) v.push(`quantity epcClass \uBD80\uC815: ${q.epcClass}`);
|
|
505
|
+
const hasQty = q.quantity !== void 0 && q.quantity !== null;
|
|
506
|
+
if (!hasQty) {
|
|
507
|
+
if (q.uom !== void 0) v.push("quantity \uC5C6\uC73C\uBA74 uom \uB3C4 \uC5C6\uC5B4\uC57C \uD55C\uB2E4(\uC218\uB7C9 \uBBF8\uC9C0\uC815)");
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (typeof q.quantity !== "number" || !Number.isFinite(q.quantity) || q.quantity <= 0) {
|
|
511
|
+
v.push("quantity \uB294 \uC591\uC218\uC5EC\uC57C \uD55C\uB2E4(\uBAA8\uB974\uBA74 \uC0DD\uB7B5)");
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (q.uom === void 0 && !Number.isInteger(q.quantity)) v.push("uom \uC5C6\uB294 quantity \uB294 \uC815\uC218(\uAC1C\uC218)\uC5EC\uC57C \uD55C\uB2E4");
|
|
515
|
+
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}`);
|
|
516
|
+
}
|
|
517
|
+
return v;
|
|
518
|
+
}
|
|
519
|
+
|
|
280
520
|
// src/state-projector.ts
|
|
521
|
+
var UNKNOWN_TYPE = "unknown";
|
|
281
522
|
var StateProjector = class {
|
|
523
|
+
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
282
524
|
master = /* @__PURE__ */ new Map();
|
|
283
525
|
items = /* @__PURE__ */ new Map();
|
|
284
526
|
aggregation = /* @__PURE__ */ new Map();
|
|
285
527
|
// parent SSCC → child EPCs
|
|
286
528
|
tasks = /* @__PURE__ */ new Map();
|
|
287
529
|
movers = /* @__PURE__ */ new Map();
|
|
530
|
+
persons = /* @__PURE__ */ new Map();
|
|
531
|
+
assets = /* @__PURE__ */ new Map();
|
|
288
532
|
orders = /* @__PURE__ */ new Map();
|
|
289
533
|
revision = 0;
|
|
534
|
+
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
535
|
+
corrections = [];
|
|
290
536
|
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 });
|
|
537
|
+
for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parentId: n.parentId, origin: "master" });
|
|
538
|
+
for (const m of board.movers) this.movers.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeNode, origin: "master" });
|
|
539
|
+
for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle" });
|
|
540
|
+
for (const a of board.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle" });
|
|
293
541
|
}
|
|
294
542
|
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
295
543
|
applyMaster(u) {
|
|
@@ -298,48 +546,166 @@ var StateProjector = class {
|
|
|
298
546
|
return;
|
|
299
547
|
}
|
|
300
548
|
const cur = this.master.get(u.node.id);
|
|
549
|
+
const capacity = u.node.capacity ?? cur?.capacity;
|
|
301
550
|
this.master.set(u.node.id, {
|
|
302
551
|
id: u.node.id,
|
|
303
|
-
type: u.node.type ?? cur?.type ??
|
|
304
|
-
capacity:
|
|
552
|
+
type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
|
|
553
|
+
...capacity === void 0 ? {} : { capacity },
|
|
554
|
+
...u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {},
|
|
555
|
+
/* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
|
|
556
|
+
origin: "master"
|
|
305
557
|
});
|
|
306
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
|
|
561
|
+
*
|
|
562
|
+
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
|
|
563
|
+
* 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
|
|
564
|
+
* 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
|
|
565
|
+
* 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
|
|
566
|
+
*
|
|
567
|
+
* 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
|
|
568
|
+
* (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
|
|
569
|
+
*/
|
|
570
|
+
touchLocation(id) {
|
|
571
|
+
if (!id || this.master.has(id)) return;
|
|
572
|
+
this.master.set(id, { id, type: UNKNOWN_TYPE, origin: "observed" });
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
|
|
576
|
+
*
|
|
577
|
+
* 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
|
|
578
|
+
* 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
|
|
579
|
+
* 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
|
|
580
|
+
*
|
|
581
|
+
* 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
|
|
582
|
+
* 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
|
|
583
|
+
*/
|
|
584
|
+
stale(key, e) {
|
|
585
|
+
const at = Date.parse(String(e.eventTime ?? ""));
|
|
586
|
+
if (!Number.isFinite(at)) return false;
|
|
587
|
+
const recorded = Date.parse(String(e.data?.recordTime ?? ""));
|
|
588
|
+
const seen = this.lastAt.get(key);
|
|
589
|
+
if (seen === void 0) {
|
|
590
|
+
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : void 0 });
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
if (at < seen.at) return true;
|
|
594
|
+
if (at === seen.at && Number.isFinite(recorded) && seen.recorded !== void 0 && recorded < seen.recorded) return true;
|
|
595
|
+
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : seen.recorded });
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
599
|
+
lastAt = /* @__PURE__ */ new Map();
|
|
307
600
|
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
308
601
|
apply(e) {
|
|
309
602
|
this.revision++;
|
|
310
603
|
if (e.eventType.startsWith("epcis.")) {
|
|
311
|
-
this.applyEpcis(e.data);
|
|
604
|
+
this.applyEpcis(e.data, e);
|
|
312
605
|
return;
|
|
313
606
|
}
|
|
314
607
|
switch (e.eventType) {
|
|
315
608
|
case OP_EVENT.task: {
|
|
316
609
|
const d = e.data;
|
|
317
|
-
this.
|
|
610
|
+
if (this.stale(`task:${d.taskId}`, e)) return;
|
|
611
|
+
this.touchLocation(d.fromNode);
|
|
612
|
+
this.touchLocation(d.toNode);
|
|
613
|
+
this.tasks.set(d.taskId, {
|
|
614
|
+
id: d.taskId,
|
|
615
|
+
kind: d.kind,
|
|
616
|
+
status: d.status,
|
|
617
|
+
fromNode: d.fromNode,
|
|
618
|
+
toNode: d.toNode,
|
|
619
|
+
itemRefs: d.itemRefs,
|
|
620
|
+
resourceRef: d.resourceRef,
|
|
621
|
+
orderId: d.orderId,
|
|
622
|
+
intent: d.intent,
|
|
623
|
+
progress: d.progress,
|
|
624
|
+
remainingMs: d.remainingMs,
|
|
625
|
+
durationMs: d.durationMs,
|
|
626
|
+
...d.personnel?.length ? { personnel: d.personnel.slice() } : {},
|
|
627
|
+
...d.assets?.length ? { assets: d.assets.slice() } : {}
|
|
628
|
+
});
|
|
318
629
|
break;
|
|
319
630
|
}
|
|
320
631
|
case OP_EVENT.equipment: {
|
|
321
632
|
const d = e.data;
|
|
322
|
-
this.
|
|
633
|
+
if (this.stale(`mover:${d.moverId}`, e)) return;
|
|
634
|
+
this.touchLocation(d.location);
|
|
635
|
+
const known = this.movers.get(d.moverId);
|
|
636
|
+
this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, motion: d.motion, origin: known?.origin ?? "observed" });
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
case OP_EVENT.person: {
|
|
640
|
+
const d = e.data;
|
|
641
|
+
if (this.stale(`person:${d.personId}`, e)) return;
|
|
642
|
+
this.persons.set(d.personId, {
|
|
643
|
+
id: d.personId,
|
|
644
|
+
personnelClass: d.personnelClass ?? this.persons.get(d.personId)?.personnelClass,
|
|
645
|
+
status: d.status,
|
|
646
|
+
taskId: d.taskId,
|
|
647
|
+
...d.offShift ? { offShift: true } : {}
|
|
648
|
+
});
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
case OP_EVENT.asset: {
|
|
652
|
+
const d = e.data;
|
|
653
|
+
if (this.stale(`asset:${d.assetId}`, e)) return;
|
|
654
|
+
const cur = this.assets.get(d.assetId);
|
|
655
|
+
this.assets.set(d.assetId, {
|
|
656
|
+
id: d.assetId,
|
|
657
|
+
assetClass: d.assetClass ?? cur?.assetClass,
|
|
658
|
+
location: d.location ?? cur?.location,
|
|
659
|
+
status: d.status,
|
|
660
|
+
taskId: d.taskId,
|
|
661
|
+
...d.carrying ? { carrying: d.carrying } : {}
|
|
662
|
+
});
|
|
663
|
+
this.touchLocation(d.location);
|
|
323
664
|
break;
|
|
324
665
|
}
|
|
325
666
|
case OP_EVENT.order: {
|
|
326
667
|
const d = e.data;
|
|
668
|
+
if (this.stale(`order:${d.orderId}`, e)) return;
|
|
327
669
|
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
670
|
break;
|
|
329
671
|
}
|
|
330
672
|
}
|
|
331
673
|
}
|
|
332
|
-
applyEpcis(ev) {
|
|
674
|
+
applyEpcis(ev, envelope) {
|
|
675
|
+
if (ev.errorDeclaration) {
|
|
676
|
+
this.corrections.push({
|
|
677
|
+
declaredAt: String(ev.errorDeclaration.declarationTime ?? ""),
|
|
678
|
+
reason: ev.errorDeclaration.reason,
|
|
679
|
+
correctiveEventIDs: ev.errorDeclaration.correctiveEventIDs ?? [],
|
|
680
|
+
eventID: ev.eventID
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
333
684
|
if (ev.type === "AggregationEvent") {
|
|
334
|
-
if (ev.action === "ADD" && ev.childEPCs?.length)
|
|
335
|
-
|
|
685
|
+
if (ev.action === "ADD" && ev.childEPCs?.length) {
|
|
686
|
+
this.aggregation.set(ev.parentID, [...ev.childEPCs]);
|
|
687
|
+
for (const child of ev.childEPCs) {
|
|
688
|
+
const cur = this.items.get(child);
|
|
689
|
+
if (cur) this.items.set(child, { ...cur, parent: ev.parentID });
|
|
690
|
+
else this.items.set(child, { epc: child, location: "", parent: ev.parentID });
|
|
691
|
+
}
|
|
692
|
+
} else if (ev.action === "DELETE") {
|
|
693
|
+
for (const child of this.aggregation.get(ev.parentID) ?? []) {
|
|
694
|
+
const cur = this.items.get(child);
|
|
695
|
+
if (cur) this.items.set(child, { ...cur, parent: void 0 });
|
|
696
|
+
}
|
|
697
|
+
this.aggregation.delete(ev.parentID);
|
|
698
|
+
}
|
|
336
699
|
return;
|
|
337
700
|
}
|
|
338
701
|
if (ev.type === "TransactionEvent") return;
|
|
339
702
|
if (ev.type === "TransformationEvent") {
|
|
340
703
|
for (const epc of ev.inputEPCList ?? []) this.remove(epc);
|
|
341
704
|
const loc2 = ev.readPoint?.id ?? "";
|
|
342
|
-
|
|
705
|
+
this.touchLocation(loc2);
|
|
706
|
+
for (const epc of ev.outputEPCList ?? []) {
|
|
707
|
+
this.items.set(epc, this.mergeItem(epc, { location: loc2, disposition: ev.disposition, ilmd: ev.ilmd }));
|
|
708
|
+
}
|
|
343
709
|
return;
|
|
344
710
|
}
|
|
345
711
|
if (ev.action === "DELETE") {
|
|
@@ -347,12 +713,58 @@ var StateProjector = class {
|
|
|
347
713
|
return;
|
|
348
714
|
}
|
|
349
715
|
const loc = ev.readPoint?.id;
|
|
350
|
-
const
|
|
716
|
+
const q = ev.quantityList?.[0];
|
|
717
|
+
this.touchLocation(loc);
|
|
351
718
|
for (const epc of ev.epcList) {
|
|
352
|
-
|
|
353
|
-
this.items.set(epc,
|
|
719
|
+
if (envelope && this.stale(`item:${epc}`, envelope)) continue;
|
|
720
|
+
this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q));
|
|
721
|
+
}
|
|
722
|
+
if (!ev.epcList?.length) {
|
|
723
|
+
for (const qe of ev.quantityList ?? []) {
|
|
724
|
+
if (qe?.epcClass) this.items.set(qe.epcClass, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe));
|
|
725
|
+
}
|
|
354
726
|
}
|
|
355
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
|
|
730
|
+
* 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
|
|
731
|
+
*/
|
|
732
|
+
/**
|
|
733
|
+
* 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
|
|
734
|
+
*
|
|
735
|
+
* 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
|
|
736
|
+
* `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
|
|
737
|
+
*/
|
|
738
|
+
expiryOf(ilmd) {
|
|
739
|
+
const raw = ilmd?.[ILMD_ATTR.expiry];
|
|
740
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
741
|
+
if (typeof raw === "string") {
|
|
742
|
+
const t = Date.parse(raw);
|
|
743
|
+
if (Number.isFinite(t)) return t;
|
|
744
|
+
}
|
|
745
|
+
return void 0;
|
|
746
|
+
}
|
|
747
|
+
mergeItem(epc, patch, q) {
|
|
748
|
+
const cur = this.items.get(epc);
|
|
749
|
+
const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : void 0;
|
|
750
|
+
const parsedSelf = parseEpc(epc);
|
|
751
|
+
const classUri = q?.epcClass ?? (parsedSelf.instance ? void 0 : epc);
|
|
752
|
+
return {
|
|
753
|
+
epc,
|
|
754
|
+
gtin: classUri ?? cur?.gtin,
|
|
755
|
+
gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
|
|
756
|
+
location: patch.location ?? cur?.location ?? "",
|
|
757
|
+
disposition: patch.disposition ?? cur?.disposition,
|
|
758
|
+
parent: cur?.parent,
|
|
759
|
+
qty: q?.quantity ?? cur?.qty,
|
|
760
|
+
uom: q?.uom ?? cur?.uom,
|
|
761
|
+
/* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
|
|
762
|
+
ilmd: patch.ilmd ?? cur?.ilmd,
|
|
763
|
+
expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
|
|
764
|
+
/* 로트는 LGTIN(식별자)에서 오지만, 직렬 개체는 마스터데이터에 실려 온다. */
|
|
765
|
+
lot: parsedClass?.lot ?? parsedSelf.lot ?? (typeof patch.ilmd?.[ILMD_ATTR.lot] === "string" ? patch.ilmd[ILMD_ATTR.lot] : void 0) ?? cur?.lot
|
|
766
|
+
};
|
|
767
|
+
}
|
|
356
768
|
/** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
|
|
357
769
|
remove(epc) {
|
|
358
770
|
this.items.delete(epc);
|
|
@@ -368,8 +780,20 @@ var StateProjector = class {
|
|
|
368
780
|
for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
|
|
369
781
|
return {
|
|
370
782
|
revision: this.revision,
|
|
371
|
-
|
|
372
|
-
|
|
783
|
+
...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
|
|
784
|
+
nodes: [...this.master.values()].map((n) => ({
|
|
785
|
+
id: n.id,
|
|
786
|
+
type: n.type,
|
|
787
|
+
occupancy: occ.get(n.id) ?? 0,
|
|
788
|
+
/* 용량 미상은 **키를 만들지 않는다** — 0 으로 실으면 "자리 없음" 이라는 없는 사실이 생긴다. */
|
|
789
|
+
...n.capacity === void 0 ? {} : { capacity: n.capacity },
|
|
790
|
+
...n.parentId ? { parentId: n.parentId } : {},
|
|
791
|
+
origin: n.origin
|
|
792
|
+
})),
|
|
793
|
+
/* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
|
|
794
|
+
items: [...this.items.values()].map((i) => ({ ...i })),
|
|
795
|
+
persons: [...this.persons.values()].map((p) => ({ ...p })),
|
|
796
|
+
assets: [...this.assets.values()].map((a) => ({ ...a })),
|
|
373
797
|
tasks: [...this.tasks.values()].map((t) => ({ ...t })),
|
|
374
798
|
movers: [...this.movers.values()].map((m) => ({ ...m })),
|
|
375
799
|
orders: [...this.orders.values()].map((o) => ({ ...o }))
|
|
@@ -409,114 +833,6 @@ function replay(board, events) {
|
|
|
409
833
|
return proj.snapshot();
|
|
410
834
|
}
|
|
411
835
|
|
|
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
836
|
// src/wms-profile.ts
|
|
521
837
|
var BIZSTEP = {
|
|
522
838
|
receiving: "urn:epcglobal:cbv:bizstep:receiving",
|
|
@@ -682,6 +998,25 @@ var fefoPolicy = {
|
|
|
682
998
|
// src/duration-estimator.ts
|
|
683
999
|
var constantDuration = (ms2) => ({ estimate: () => ms2 });
|
|
684
1000
|
|
|
1001
|
+
// src/iso-duration.ts
|
|
1002
|
+
var RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
1003
|
+
function parseIsoDuration(text) {
|
|
1004
|
+
if (typeof text !== "string") return void 0;
|
|
1005
|
+
const s = text.trim();
|
|
1006
|
+
if (!s || s === "P" || s === "PT") return void 0;
|
|
1007
|
+
if (/\d+Y/.test(s)) return void 0;
|
|
1008
|
+
const tIdx = s.indexOf("T");
|
|
1009
|
+
const datePart = tIdx === -1 ? s : s.slice(0, tIdx);
|
|
1010
|
+
if (/\d+M/.test(datePart)) return void 0;
|
|
1011
|
+
const m = RE.exec(s);
|
|
1012
|
+
if (!m) return void 0;
|
|
1013
|
+
const [, sign, w, d, h, min, sec] = m;
|
|
1014
|
+
if (!w && !d && !h && !min && !sec) return void 0;
|
|
1015
|
+
const ms2 = (Number(w ?? 0) * 7 + Number(d ?? 0)) * 864e5 + Number(h ?? 0) * 36e5 + Number(min ?? 0) * 6e4 + Number(sec ?? 0) * 1e3;
|
|
1016
|
+
if (!Number.isFinite(ms2)) return void 0;
|
|
1017
|
+
return sign ? -ms2 : ms2;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
685
1020
|
// src/task-fold.ts
|
|
686
1021
|
function ms(value) {
|
|
687
1022
|
if (!value) return null;
|
|
@@ -931,14 +1266,25 @@ var FlowEngine = class {
|
|
|
931
1266
|
nodes = /* @__PURE__ */ new Map();
|
|
932
1267
|
items = /* @__PURE__ */ new Map();
|
|
933
1268
|
movers = /* @__PURE__ */ new Map();
|
|
1269
|
+
/** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
|
|
1270
|
+
persons = /* @__PURE__ */ new Map();
|
|
1271
|
+
/** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
|
|
1272
|
+
assets = /* @__PURE__ */ new Map();
|
|
934
1273
|
tasks = /* @__PURE__ */ new Map();
|
|
935
1274
|
orders = /* @__PURE__ */ new Map();
|
|
936
1275
|
revision = 0;
|
|
937
1276
|
clockMs = 0;
|
|
938
1277
|
rng = mulberry32(1);
|
|
939
1278
|
policy;
|
|
940
|
-
/** duration 시임(선택) — 미주입 시 도메인 상수.
|
|
1279
|
+
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
941
1280
|
durationEstimator;
|
|
1281
|
+
/**
|
|
1282
|
+
* 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
|
|
1283
|
+
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
1284
|
+
*/
|
|
1285
|
+
operationSpecs = /* @__PURE__ */ new Map();
|
|
1286
|
+
/** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
|
|
1287
|
+
specUse = /* @__PURE__ */ new Map();
|
|
942
1288
|
epcSeq = 0;
|
|
943
1289
|
taskSeq = 0;
|
|
944
1290
|
orderSeq = 0;
|
|
@@ -954,9 +1300,11 @@ var FlowEngine = class {
|
|
|
954
1300
|
}
|
|
955
1301
|
// ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
|
|
956
1302
|
loadBoard(def) {
|
|
957
|
-
for (const n of def.nodes) this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, occupancy: 0, status: "idle", parentId: n.parentId });
|
|
1303
|
+
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 });
|
|
1304
|
+
for (const p of def.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null, window: p.window });
|
|
1305
|
+
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
1306
|
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 };
|
|
1307
|
+
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
1308
|
if (m.mtbfMs !== void 0) {
|
|
961
1309
|
mover.mtbfMs = m.mtbfMs;
|
|
962
1310
|
mover.mttrMs = m.mttrMs;
|
|
@@ -987,10 +1335,81 @@ var FlowEngine = class {
|
|
|
987
1335
|
* 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
|
|
988
1336
|
*/
|
|
989
1337
|
hydrateObserved(snap, orders = []) {
|
|
990
|
-
for (const n of snap.nodes)
|
|
1338
|
+
for (const n of snap.nodes) {
|
|
1339
|
+
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 });
|
|
1340
|
+
}
|
|
991
1341
|
this.items.clear();
|
|
992
|
-
for (const it of snap.items)
|
|
993
|
-
|
|
1342
|
+
for (const it of snap.items) {
|
|
1343
|
+
this.items.set(it.epc, {
|
|
1344
|
+
epc: it.epc,
|
|
1345
|
+
location: it.location,
|
|
1346
|
+
disposition: it.disposition ?? DISP.sellable,
|
|
1347
|
+
gtin: it.gtin,
|
|
1348
|
+
gtinKey: it.gtinKey,
|
|
1349
|
+
lot: it.lot,
|
|
1350
|
+
qty: it.qty ?? 1,
|
|
1351
|
+
uom: it.uom,
|
|
1352
|
+
parent: it.parent,
|
|
1353
|
+
expiry: it.expiry,
|
|
1354
|
+
ilmd: it.ilmd
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
for (const m of snap.movers) {
|
|
1358
|
+
const oee = m.oee;
|
|
1359
|
+
this.movers.set(m.id, {
|
|
1360
|
+
id: m.id,
|
|
1361
|
+
kind: m.kind,
|
|
1362
|
+
location: m.location ?? "",
|
|
1363
|
+
status: m.status ?? "idle",
|
|
1364
|
+
taskId: null,
|
|
1365
|
+
runMs: oee?.runMs ?? 0,
|
|
1366
|
+
setupMs: oee?.setupMs ?? 0,
|
|
1367
|
+
downMs: oee?.downMs ?? 0,
|
|
1368
|
+
goodCount: oee?.goodCount ?? 0,
|
|
1369
|
+
scrapCount: oee?.scrapCount ?? 0
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
for (const p of snap.persons ?? []) {
|
|
1373
|
+
this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null });
|
|
1374
|
+
}
|
|
1375
|
+
for (const a of snap.assets ?? []) {
|
|
1376
|
+
this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.location, status: "idle", taskId: null, carrying: a.carrying });
|
|
1377
|
+
}
|
|
1378
|
+
for (const t of snap.tasks ?? []) {
|
|
1379
|
+
if (t.status === "completed") continue;
|
|
1380
|
+
const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
|
|
1381
|
+
this.tasks.set(t.id, {
|
|
1382
|
+
id: t.id,
|
|
1383
|
+
kind: t.kind,
|
|
1384
|
+
status: known && t.status === "in-progress" ? "in-progress" : "created",
|
|
1385
|
+
itemEpc: t.itemRefs?.[0] ?? "",
|
|
1386
|
+
fromNode: t.fromNode ?? "",
|
|
1387
|
+
toNode: t.toNode ?? "",
|
|
1388
|
+
resource: known && t.status === "in-progress" ? t.resourceRef ?? null : null,
|
|
1389
|
+
remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
|
|
1390
|
+
durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
|
|
1391
|
+
orderId: t.orderId,
|
|
1392
|
+
intent: t.intent
|
|
1393
|
+
});
|
|
1394
|
+
if (known && t.status === "in-progress" && t.resourceRef) {
|
|
1395
|
+
const mv = this.movers.get(t.resourceRef);
|
|
1396
|
+
if (mv) {
|
|
1397
|
+
mv.status = "busy";
|
|
1398
|
+
mv.taskId = t.id;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (known && t.status === "in-progress") {
|
|
1402
|
+
const restored = this.tasks.get(t.id);
|
|
1403
|
+
if (restored) restored.personnel = t.personnel ? [...t.personnel] : void 0;
|
|
1404
|
+
for (const id of t.personnel ?? []) {
|
|
1405
|
+
const pp = this.persons.get(id);
|
|
1406
|
+
if (pp) {
|
|
1407
|
+
pp.status = "busy";
|
|
1408
|
+
pp.taskId = t.id;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
994
1413
|
for (const o of orders) {
|
|
995
1414
|
const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
|
|
996
1415
|
const remaining = lines.reduce((s, l) => s + l.requested, 0);
|
|
@@ -1121,7 +1540,7 @@ var FlowEngine = class {
|
|
|
1121
1540
|
start: () => {
|
|
1122
1541
|
if (this.generating) return;
|
|
1123
1542
|
this.generating = true;
|
|
1124
|
-
for (const g of this.gens) g.nextMs = this.
|
|
1543
|
+
for (const g of this.gens) g.nextMs = this.nextFireMs(g.spec, this.clockMs);
|
|
1125
1544
|
},
|
|
1126
1545
|
pause: () => {
|
|
1127
1546
|
this.generating = false;
|
|
@@ -1149,12 +1568,22 @@ var FlowEngine = class {
|
|
|
1149
1568
|
nodes: [...this.nodes.values()].map((n) => ({ ...n })),
|
|
1150
1569
|
items: [...this.items.values()].map((i) => ({ epc: i.epc, gtin: i.gtin, qty: i.qty, location: i.location, disposition: i.disposition, expiry: i.expiry })),
|
|
1151
1570
|
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 };
|
|
1571
|
+
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, ...this.offShift(m) ? { offShift: true } : {} };
|
|
1153
1572
|
const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
|
|
1154
1573
|
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
1574
|
return s;
|
|
1156
1575
|
}),
|
|
1157
|
-
|
|
1576
|
+
assets: [...this.assets.values()].map((a) => {
|
|
1577
|
+
const st = { id: a.id, assetClass: a.assetClass, location: a.location, status: a.status, taskId: a.taskId ?? void 0 };
|
|
1578
|
+
if (a.carrying) st.carrying = a.carrying;
|
|
1579
|
+
return st;
|
|
1580
|
+
}),
|
|
1581
|
+
persons: [...this.persons.values()].map((p) => {
|
|
1582
|
+
const st = { id: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0 };
|
|
1583
|
+
if (this.personOffShift(p)) st.offShift = true;
|
|
1584
|
+
return st;
|
|
1585
|
+
}),
|
|
1586
|
+
tasks: [...this.tasks.values()].map((t) => ({ id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc], fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? void 0, orderId: t.orderId, progress: t.status === "in-progress" ? this.progressOf(t) : void 0, ...t.personnel?.length ? { personnel: t.personnel.slice() } : {} })),
|
|
1158
1587
|
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
1588
|
attentions: this.computeAttentions()
|
|
1160
1589
|
};
|
|
@@ -1191,6 +1620,8 @@ var FlowEngine = class {
|
|
|
1191
1620
|
}
|
|
1192
1621
|
clone.rng.state = this.rng.state;
|
|
1193
1622
|
clone.durationEstimator = this.durationEstimator;
|
|
1623
|
+
clone.operationSpecs = this.operationSpecs;
|
|
1624
|
+
clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
|
|
1194
1625
|
return clone;
|
|
1195
1626
|
}
|
|
1196
1627
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
@@ -1200,9 +1631,121 @@ var FlowEngine = class {
|
|
|
1200
1631
|
randInt(min, max) {
|
|
1201
1632
|
return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
|
|
1202
1633
|
}
|
|
1203
|
-
/**
|
|
1634
|
+
/**
|
|
1635
|
+
* 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
|
|
1636
|
+
* 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
|
|
1637
|
+
*/
|
|
1638
|
+
loadOperations(ops = []) {
|
|
1639
|
+
for (const o of ops) if (o?.key) this.operationSpecs.set(o.key, o);
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
|
|
1643
|
+
*
|
|
1644
|
+
* 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 박아 둔 상수를 이긴다.
|
|
1645
|
+
* 셋 중 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
|
|
1646
|
+
* "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
|
|
1647
|
+
*/
|
|
1204
1648
|
durationOf(ctx, fallbackMs) {
|
|
1205
|
-
|
|
1649
|
+
const estimated = this.durationEstimator?.estimate(ctx);
|
|
1650
|
+
if (typeof estimated === "number") {
|
|
1651
|
+
this.noteSpecUse(ctx.kind, "measured");
|
|
1652
|
+
return estimated;
|
|
1653
|
+
}
|
|
1654
|
+
if (estimated) {
|
|
1655
|
+
this.noteSpecUse(ctx.kind, "measured", estimated.spread?.distribution);
|
|
1656
|
+
return this.sampleSpread(estimated.meanMs, estimated.spread);
|
|
1657
|
+
}
|
|
1658
|
+
const spec = this.operationSpecs.get(ctx.kind);
|
|
1659
|
+
const declared = parseIsoDuration(spec?.duration);
|
|
1660
|
+
if (declared === void 0) {
|
|
1661
|
+
this.noteSpecUse(ctx.kind, "default");
|
|
1662
|
+
return fallbackMs;
|
|
1663
|
+
}
|
|
1664
|
+
this.noteSpecUse(ctx.kind, "declared");
|
|
1665
|
+
return this.applyVariability(declared, spec?.variability);
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* 소요시간 변동 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 표준 밖 확장이므로 미지정이면 상수.
|
|
1669
|
+
* 모수가 모자라면(uniform 에 min/max 없음 등) 변동을 발명하지 않고 평균을 그대로 쓴다.
|
|
1670
|
+
*/
|
|
1671
|
+
applyVariability(meanMs, v) {
|
|
1672
|
+
if (!v || v.distribution === "constant") return meanMs;
|
|
1673
|
+
if (v.distribution === "exponential") return -Math.log(1 - this.rng()) * meanMs;
|
|
1674
|
+
const minMs = parseIsoDuration(v.min);
|
|
1675
|
+
const maxMs = parseIsoDuration(v.max);
|
|
1676
|
+
if (minMs === void 0 || maxMs === void 0) return meanMs;
|
|
1677
|
+
return this.sampleSpread(meanMs, { distribution: v.distribution, minMs, maxMs, modeMs: parseIsoDuration(v.mode) });
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* 퍼짐 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 선언 명세(ISO 표기)와 실측 분포(ms)가
|
|
1681
|
+
* 같은 수식을 쓴다: 한쪽만 고치면 두 경로가 다른 답을 낸다.
|
|
1682
|
+
* 모수가 모자라거나 뒤집혀 있으면 **퍼짐을 발명하지 않고** 평균을 그대로 쓴다.
|
|
1683
|
+
*/
|
|
1684
|
+
sampleSpread(meanMs, spread) {
|
|
1685
|
+
if (!spread) return meanMs;
|
|
1686
|
+
const { minMs, maxMs } = spread;
|
|
1687
|
+
if (!Number.isFinite(minMs) || !Number.isFinite(maxMs) || maxMs < minMs) return meanMs;
|
|
1688
|
+
if (spread.distribution === "uniform") return minMs + this.rng() * (maxMs - minMs);
|
|
1689
|
+
const mode = Math.min(maxMs, Math.max(minMs, spread.modeMs ?? meanMs));
|
|
1690
|
+
const u = this.rng();
|
|
1691
|
+
const span = maxMs - minMs;
|
|
1692
|
+
if (span <= 0) return minMs;
|
|
1693
|
+
const c = (mode - minMs) / span;
|
|
1694
|
+
return u < c ? minMs + Math.sqrt(u * span * (mode - minMs)) : maxMs - Math.sqrt((1 - u) * span * (maxMs - mode));
|
|
1695
|
+
}
|
|
1696
|
+
/** 명세 모수(숫자) — 선언 없으면 undefined(0 으로 꾸미지 않는다). 소비처가 기본값을 정한다. */
|
|
1697
|
+
paramNumber(opKey, id) {
|
|
1698
|
+
const p = this.operationSpecs.get(opKey)?.parameters?.find((x) => x.id === id);
|
|
1699
|
+
if (!p) return void 0;
|
|
1700
|
+
const n = Number(p.value);
|
|
1701
|
+
if (!Number.isFinite(n)) return void 0;
|
|
1702
|
+
this.noteParamUse(opKey, id);
|
|
1703
|
+
return n;
|
|
1704
|
+
}
|
|
1705
|
+
/** 명세 모수(기간) — ISO 8601 문자열을 밀리초로. 선언 없으면 undefined. */
|
|
1706
|
+
paramDuration(opKey, id) {
|
|
1707
|
+
const p = this.operationSpecs.get(opKey)?.parameters?.find((x) => x.id === id);
|
|
1708
|
+
const ms2 = parseIsoDuration(p?.value);
|
|
1709
|
+
if (ms2 !== void 0) this.noteParamUse(opKey, id);
|
|
1710
|
+
return ms2;
|
|
1711
|
+
}
|
|
1712
|
+
noteSpecUse(kind, duration, variability) {
|
|
1713
|
+
const cur = this.specUse.get(kind);
|
|
1714
|
+
if (cur) {
|
|
1715
|
+
cur.duration = duration;
|
|
1716
|
+
if (variability) cur.variability = variability;
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
this.specUse.set(kind, { duration, ...variability ? { variability } : {}, params: /* @__PURE__ */ new Set() });
|
|
1720
|
+
}
|
|
1721
|
+
noteParamUse(kind, id) {
|
|
1722
|
+
const cur = this.specUse.get(kind);
|
|
1723
|
+
if (cur) cur.params.add(id);
|
|
1724
|
+
else this.specUse.set(kind, { duration: "default", params: /* @__PURE__ */ new Set([id]) });
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 박아 둔 상수인가.**
|
|
1728
|
+
*
|
|
1729
|
+
* 시뮬레이션 결과를 받는 쪽이 이걸 봐야 한다: 소요시간이 전부 기본값이면 그 예측으로 말할 수 있는 것은
|
|
1730
|
+
* "같은 조건에서의 상대 비교" 뿐이고 "몇 시에 끝난다" 는 근거가 없다. 그 구분을 숫자로 드러낸다.
|
|
1731
|
+
*/
|
|
1732
|
+
specCoverage() {
|
|
1733
|
+
const operations = [...this.specUse.entries()].map(([kind, u]) => {
|
|
1734
|
+
const spec = this.operationSpecs.get(kind);
|
|
1735
|
+
const variability = u.variability ?? spec?.variability?.distribution;
|
|
1736
|
+
return {
|
|
1737
|
+
kind,
|
|
1738
|
+
duration: u.duration,
|
|
1739
|
+
...variability ? { variability } : {},
|
|
1740
|
+
parameters: [...u.params].sort()
|
|
1741
|
+
};
|
|
1742
|
+
});
|
|
1743
|
+
return {
|
|
1744
|
+
operations,
|
|
1745
|
+
measuredDurations: operations.filter((o) => o.duration === "measured").length,
|
|
1746
|
+
declaredDurations: operations.filter((o) => o.duration === "declared").length,
|
|
1747
|
+
defaultDurations: operations.filter((o) => o.duration === "default").length
|
|
1748
|
+
};
|
|
1206
1749
|
}
|
|
1207
1750
|
/** skuMix 에서 weight 로 gtin 선택(rng) — 도착/오더 자극의 품목 결정. */
|
|
1208
1751
|
pickGtin(mix) {
|
|
@@ -1316,8 +1859,38 @@ var FlowEngine = class {
|
|
|
1316
1859
|
const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
|
|
1317
1860
|
for (const h of this.handlers) h(e);
|
|
1318
1861
|
}
|
|
1862
|
+
/**
|
|
1863
|
+
* 작업 전이 방출 — **커널이 아는 것을 미러도 알게** 한다.
|
|
1864
|
+
*
|
|
1865
|
+
* 예전에는 진척·남은 시간·의도를 싣지 않아, 미러 상태를 씨앗으로 한 예측이 "진행 중인 일이 없는
|
|
1866
|
+
* 현장" 에서 출발했고, 소비처는 무자원 체류를 기록 누락으로 오해할 수밖에 없었다.
|
|
1867
|
+
* 진척은 진행 중일 때만 뜻이 있으므로 그때만 싣는다(생성·완료 시점의 0/1 은 노이즈).
|
|
1868
|
+
*/
|
|
1319
1869
|
emitTask(t) {
|
|
1320
|
-
|
|
1870
|
+
const inProgress = t.status === "in-progress";
|
|
1871
|
+
const done = Math.max(0, (t.durationMs ?? 0) - (t.remainingMs ?? 0));
|
|
1872
|
+
this.emitOp(OP_EVENT.task, {
|
|
1873
|
+
taskId: t.id,
|
|
1874
|
+
orderId: t.orderId,
|
|
1875
|
+
kind: t.kind,
|
|
1876
|
+
status: t.status,
|
|
1877
|
+
fromNode: t.fromNode,
|
|
1878
|
+
toNode: t.toNode,
|
|
1879
|
+
itemRefs: [t.itemEpc],
|
|
1880
|
+
resourceRef: t.resource ?? void 0,
|
|
1881
|
+
intent: t.intent,
|
|
1882
|
+
...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
|
|
1883
|
+
...t.assets?.length ? { assets: t.assets.slice() } : {},
|
|
1884
|
+
...t.durationMs ? { durationMs: t.durationMs } : {},
|
|
1885
|
+
...inProgress ? { remainingMs: t.remainingMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
1889
|
+
emitAsset(a) {
|
|
1890
|
+
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 });
|
|
1891
|
+
}
|
|
1892
|
+
emitPerson(p) {
|
|
1893
|
+
this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0, ...this.personOffShift(p) ? { offShift: true } : {} });
|
|
1321
1894
|
}
|
|
1322
1895
|
emitMover(m, motion) {
|
|
1323
1896
|
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion });
|
|
@@ -1326,9 +1899,189 @@ var FlowEngine = class {
|
|
|
1326
1899
|
this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held });
|
|
1327
1900
|
}
|
|
1328
1901
|
// ── 내부 mechanics ─────────────────────────────────────────────────────────
|
|
1902
|
+
/**
|
|
1903
|
+
* 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
|
|
1904
|
+
*
|
|
1905
|
+
* 예전에는 `poisson` 만 구현하고 나머지는 전부 상수로 떨어졌다. 계약이 `uniform`·`profile` 을
|
|
1906
|
+
* 선언하고 있었으므로, 그것을 지정한 사람은 자기가 요청한 분포로 도는 줄 알았다 — **조용한 거짓**이다.
|
|
1907
|
+
*
|
|
1908
|
+
* constant 간격이 일정(평균 그대로)
|
|
1909
|
+
* poisson 무기억 도착(지수 간격) — 평균 유지
|
|
1910
|
+
* uniform 0..2×평균 균등 — 평균을 유지하면서 흔들린다(교과서적 U(0,2μ))
|
|
1911
|
+
* profile 시간대별 배율(`profile[시]`)로 도착률을 조절 — 하루 안의 수요 곡선
|
|
1912
|
+
*/
|
|
1329
1913
|
intervalMs(spec) {
|
|
1330
|
-
const
|
|
1331
|
-
|
|
1914
|
+
const perHour = spec.rate.meanPerHour * this.profileFactor(spec.rate);
|
|
1915
|
+
if (!(perHour > 0)) return Number.POSITIVE_INFINITY;
|
|
1916
|
+
const base = 36e5 / perHour;
|
|
1917
|
+
switch (spec.rate.distribution) {
|
|
1918
|
+
case "poisson":
|
|
1919
|
+
return -Math.log(1 - this.rng()) * base;
|
|
1920
|
+
case "uniform":
|
|
1921
|
+
return this.rng() * 2 * base;
|
|
1922
|
+
case "profile":
|
|
1923
|
+
case "constant":
|
|
1924
|
+
default:
|
|
1925
|
+
return base;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* 시간대 배율 — `profile[시]`. `profile` 분포일 때만 적용하며, 배열이 짧으면 **순환**한다
|
|
1930
|
+
* (24개면 하루, 8개면 8시간 주기). 미지정·다른 분포면 1(무영향).
|
|
1931
|
+
* 시(hour)는 **시뮬 시각 자신의 프레임**(BASE_EPOCH 기준 UTC)이다 — 계약에 표준시가 없으므로
|
|
1932
|
+
* 현지 시간대 해석은 아직 하지 않는다(꾸미지 않는다).
|
|
1933
|
+
*/
|
|
1934
|
+
/**
|
|
1935
|
+
* 다음 발화 시각 — **발화가 없는 시간대를 영원한 침묵으로 만들지 않는다.**
|
|
1936
|
+
*
|
|
1937
|
+
* 배율 0(그 시간대 도착 없음)이면 간격이 무한이 된다. 그것을 그대로 예약하면 이후 어떤 시간대가
|
|
1938
|
+
* 와도 깨어나지 않는다 — 그래서 **다음 정시로 미뤄 다시 판정**한다(시간대가 바뀌면 배율도 바뀐다).
|
|
1939
|
+
* 시나리오 시작과 구동 루프가 같은 규칙을 쓰도록 한 곳에 둔다(예전에는 시작 경로만 따로였다).
|
|
1940
|
+
*/
|
|
1941
|
+
nextFireMs(spec, from) {
|
|
1942
|
+
const interval = this.intervalMs(spec);
|
|
1943
|
+
if (Number.isFinite(interval)) return from + interval;
|
|
1944
|
+
const hourMs = 36e5;
|
|
1945
|
+
return Math.floor(from / hourMs) * hourMs + hourMs;
|
|
1946
|
+
}
|
|
1947
|
+
profileFactor(rate) {
|
|
1948
|
+
if (rate.distribution !== "profile") return 1;
|
|
1949
|
+
const p = rate.profile;
|
|
1950
|
+
if (!p?.length) return 1;
|
|
1951
|
+
const f = p[this.hourOfDay() % p.length];
|
|
1952
|
+
return Number.isFinite(f) && f >= 0 ? f : 1;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* 필요 물리 자산을 확보한다 — 인원과 **같은 규칙**(등급으로 요구, 부분 투입 없음, 확정은 나중).
|
|
1956
|
+
* 자산은 사람과 달리 교대가 없고 **자리**가 있다(빈 팔레트가 어디 있는지가 다음 문제이지만,
|
|
1957
|
+
* 지금은 자리를 따지지 않는다 — 따지려면 자산 이송 작업이 먼저 있어야 한다).
|
|
1958
|
+
*/
|
|
1959
|
+
claimAssets(t) {
|
|
1960
|
+
const need = this.operationSpecs.get(t.kind)?.physicalAssetSpecification;
|
|
1961
|
+
if (!need?.length) return [];
|
|
1962
|
+
const picked = [];
|
|
1963
|
+
for (const req of need) {
|
|
1964
|
+
const want = Math.max(0, Math.floor(req.quantity ?? 0));
|
|
1965
|
+
if (!want) continue;
|
|
1966
|
+
const avail = [...this.assets.values()].filter(
|
|
1967
|
+
(a) => a.status === "idle" && !picked.includes(a.id) && (req.assetClass === void 0 || a.assetClass === req.assetClass)
|
|
1968
|
+
);
|
|
1969
|
+
if (avail.length < want) return null;
|
|
1970
|
+
for (let i = 0; i < want; i++) picked.push(avail[i].id);
|
|
1971
|
+
}
|
|
1972
|
+
return picked;
|
|
1973
|
+
}
|
|
1974
|
+
/** 확보한 자산을 작업에 묶는다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
|
|
1975
|
+
assignAssets(t, gear) {
|
|
1976
|
+
if (!gear.length) return;
|
|
1977
|
+
t.assets = gear;
|
|
1978
|
+
for (const id of gear) {
|
|
1979
|
+
const a = this.assets.get(id);
|
|
1980
|
+
if (!a) continue;
|
|
1981
|
+
a.status = "in-use";
|
|
1982
|
+
a.taskId = t.id;
|
|
1983
|
+
if (t.itemEpc) a.carrying = t.itemEpc;
|
|
1984
|
+
this.emitAsset(a);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
|
|
1989
|
+
* 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
|
|
1990
|
+
*/
|
|
1991
|
+
releaseAssets(t) {
|
|
1992
|
+
for (const id of t.assets ?? []) {
|
|
1993
|
+
const a = this.assets.get(id);
|
|
1994
|
+
if (!a) continue;
|
|
1995
|
+
a.status = "idle";
|
|
1996
|
+
a.taskId = null;
|
|
1997
|
+
a.location = t.toNode || a.location;
|
|
1998
|
+
a.carrying = void 0;
|
|
1999
|
+
this.emitAsset(a);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* 필요 인원을 확보한다 — **등급으로 요구하고 등급으로 고른다**(특정인 지목이 아니다).
|
|
2004
|
+
* 요구가 없으면 빈 배열, 모자라면 `null`(작업은 기다린다 — **부분 투입으로 시작하지 않는다**).
|
|
2005
|
+
* 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
|
|
2006
|
+
* (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
|
|
2007
|
+
*/
|
|
2008
|
+
claimPersonnel(t) {
|
|
2009
|
+
const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
|
|
2010
|
+
if (!need?.length) return [];
|
|
2011
|
+
const picked = [];
|
|
2012
|
+
for (const req of need) {
|
|
2013
|
+
const want = Math.max(0, Math.floor(req.quantity ?? 0));
|
|
2014
|
+
if (!want) continue;
|
|
2015
|
+
const avail = [...this.persons.values()].filter(
|
|
2016
|
+
(p) => p.status === "idle" && !picked.includes(p.id) && !this.personOffShift(p) && (req.personnelClass === void 0 || p.personnelClass === req.personnelClass)
|
|
2017
|
+
);
|
|
2018
|
+
if (avail.length < want) return null;
|
|
2019
|
+
for (let i = 0; i < want; i++) picked.push(avail[i].id);
|
|
2020
|
+
}
|
|
2021
|
+
return picked;
|
|
2022
|
+
}
|
|
2023
|
+
/** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
|
|
2024
|
+
assignCrew(t, crew) {
|
|
2025
|
+
if (!crew.length) return;
|
|
2026
|
+
t.personnel = crew;
|
|
2027
|
+
for (const id of crew) {
|
|
2028
|
+
const p = this.persons.get(id);
|
|
2029
|
+
if (!p) continue;
|
|
2030
|
+
p.status = "busy";
|
|
2031
|
+
p.taskId = t.id;
|
|
2032
|
+
this.emitPerson(p);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
/** 작업이 끝나면 사람을 놓아 준다 — 설비 해제와 별개 경로. */
|
|
2036
|
+
releaseCrew(t) {
|
|
2037
|
+
for (const id of t.personnel ?? []) {
|
|
2038
|
+
const p = this.persons.get(id);
|
|
2039
|
+
if (!p) continue;
|
|
2040
|
+
p.status = "idle";
|
|
2041
|
+
p.taskId = null;
|
|
2042
|
+
this.emitPerson(p);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
/** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
|
|
2046
|
+
personOffShift(p) {
|
|
2047
|
+
const w = p.window;
|
|
2048
|
+
if (!w) return false;
|
|
2049
|
+
const h = this.hourOfDay();
|
|
2050
|
+
return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
|
|
2054
|
+
* 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
|
|
2055
|
+
*/
|
|
2056
|
+
stationFull(nodeId) {
|
|
2057
|
+
if (!nodeId) return false;
|
|
2058
|
+
const limit = this.nodes.get(nodeId)?.parallelism;
|
|
2059
|
+
if (!(typeof limit === "number" && limit > 0)) return false;
|
|
2060
|
+
let running = 0;
|
|
2061
|
+
for (const t of this.tasks.values()) if (t.status === "in-progress" && t.toNode === nodeId) running++;
|
|
2062
|
+
return running >= limit;
|
|
2063
|
+
}
|
|
2064
|
+
/** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
|
|
2065
|
+
offShift(m) {
|
|
2066
|
+
const w = m.window;
|
|
2067
|
+
if (!w) return false;
|
|
2068
|
+
const h = this.hourOfDay();
|
|
2069
|
+
return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
|
|
2070
|
+
}
|
|
2071
|
+
/** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
|
|
2072
|
+
hourOfDay() {
|
|
2073
|
+
return new Date(BASE_EPOCH + this.clockMs).getUTCHours();
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
|
|
2077
|
+
* `startHour <= endHour` 면 같은 날 구간, 넘어가면 자정을 가로지르는 구간(야간 교대: 22→6).
|
|
2078
|
+
* 미지정이면 언제나 참(24시간 가동).
|
|
2079
|
+
*/
|
|
2080
|
+
inWindow(spec) {
|
|
2081
|
+
const w = spec.window;
|
|
2082
|
+
if (!w) return true;
|
|
2083
|
+
const h = this.hourOfDay();
|
|
2084
|
+
return w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour;
|
|
1332
2085
|
}
|
|
1333
2086
|
/** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
|
|
1334
2087
|
sampleExp(meanMs) {
|
|
@@ -1369,10 +2122,19 @@ var FlowEngine = class {
|
|
|
1369
2122
|
generate() {
|
|
1370
2123
|
for (const g of this.gens) {
|
|
1371
2124
|
while (this.clockMs >= g.nextMs) {
|
|
2125
|
+
const next = this.nextFireMs(g.spec, g.nextMs);
|
|
2126
|
+
if (this.intervalMs(g.spec) === Number.POSITIVE_INFINITY) {
|
|
2127
|
+
g.nextMs = next;
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
if (!this.inWindow(g.spec)) {
|
|
2131
|
+
g.nextMs = next;
|
|
2132
|
+
continue;
|
|
2133
|
+
}
|
|
1372
2134
|
const stimulus = g.spec.stimulus ?? (g.spec.kind === "outbound-order" ? "order" : "arrival");
|
|
1373
2135
|
if (stimulus === "order") this.onOrder(g.spec);
|
|
1374
2136
|
else this.onArrival(g.spec);
|
|
1375
|
-
g.nextMs
|
|
2137
|
+
g.nextMs = next;
|
|
1376
2138
|
}
|
|
1377
2139
|
}
|
|
1378
2140
|
}
|
|
@@ -1382,13 +2144,22 @@ var FlowEngine = class {
|
|
|
1382
2144
|
processTasks(dt) {
|
|
1383
2145
|
for (const t of this.tasks.values()) {
|
|
1384
2146
|
if (t.status !== "created") continue;
|
|
2147
|
+
const crew = this.claimPersonnel(t);
|
|
2148
|
+
if (crew === null) continue;
|
|
2149
|
+
const gear = this.claimAssets(t);
|
|
2150
|
+
if (gear === null) continue;
|
|
1385
2151
|
if (t.intent === "dwell") {
|
|
1386
2152
|
t.status = "in-progress";
|
|
1387
2153
|
t.remainingMs = t.durationMs;
|
|
2154
|
+
this.assignCrew(t, crew);
|
|
2155
|
+
this.assignAssets(t, gear);
|
|
1388
2156
|
this.emitTask(t);
|
|
1389
2157
|
continue;
|
|
1390
2158
|
}
|
|
1391
|
-
|
|
2159
|
+
if (this.stationFull(t.toNode)) continue;
|
|
2160
|
+
const mover = [...this.movers.values()].find(
|
|
2161
|
+
(m) => m.status === "idle" && !m.held && !this.offShift(m) && (t.resourceType === void 0 || m.kind === t.resourceType)
|
|
2162
|
+
);
|
|
1392
2163
|
if (!mover) continue;
|
|
1393
2164
|
if (t.setupMs && t.changeoverKey !== void 0 && mover.lastChangeoverKey !== void 0 && mover.lastChangeoverKey !== t.changeoverKey) {
|
|
1394
2165
|
t.appliedSetupMs = t.setupMs;
|
|
@@ -1400,6 +2171,8 @@ var FlowEngine = class {
|
|
|
1400
2171
|
t.status = "in-progress";
|
|
1401
2172
|
t.resource = mover.id;
|
|
1402
2173
|
t.remainingMs = t.durationMs;
|
|
2174
|
+
this.assignCrew(t, crew);
|
|
2175
|
+
this.assignAssets(t, gear);
|
|
1403
2176
|
this.emitTask(t);
|
|
1404
2177
|
if (t.intent === "process") this.emitMover(mover);
|
|
1405
2178
|
else this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
|
|
@@ -1411,6 +2184,8 @@ var FlowEngine = class {
|
|
|
1411
2184
|
if (t.remainingMs > 0) continue;
|
|
1412
2185
|
this.onTaskComplete(t);
|
|
1413
2186
|
t.status = "completed";
|
|
2187
|
+
this.releaseCrew(t);
|
|
2188
|
+
this.releaseAssets(t);
|
|
1414
2189
|
if (!t.resource) {
|
|
1415
2190
|
this.emitTask(t);
|
|
1416
2191
|
continue;
|
|
@@ -1454,7 +2229,18 @@ var WmsKernel = class extends FlowEngine {
|
|
|
1454
2229
|
dock.occupancy++;
|
|
1455
2230
|
this.emit(transactionEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
|
|
1456
2231
|
this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
|
|
1457
|
-
this.emit(objectEvent({
|
|
2232
|
+
this.emit(objectEvent({
|
|
2233
|
+
eventTime,
|
|
2234
|
+
action: "ADD",
|
|
2235
|
+
bizStep: BIZSTEP.receiving,
|
|
2236
|
+
disposition: DISP.in_progress,
|
|
2237
|
+
epcList: [epc],
|
|
2238
|
+
quantityList: qtyList,
|
|
2239
|
+
readPoint: dock.id,
|
|
2240
|
+
bizLocation: dock.id,
|
|
2241
|
+
bizTransactionList: poTxn,
|
|
2242
|
+
ilmd: { [ILMD_ATTR.expiry]: expiry }
|
|
2243
|
+
}));
|
|
1458
2244
|
const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews("storage") });
|
|
1459
2245
|
if (!binId) return;
|
|
1460
2246
|
const id = `task-${++this.taskSeq}`;
|
|
@@ -1744,13 +2530,13 @@ var YmsKernel = class extends FlowEngine {
|
|
|
1744
2530
|
};
|
|
1745
2531
|
|
|
1746
2532
|
// src/mes-kernel.ts
|
|
1747
|
-
var
|
|
1748
|
-
var
|
|
2533
|
+
var DEFAULT_CYCLE_MS = 4e4;
|
|
2534
|
+
var DEFAULT_SETUP_MS = 15e3;
|
|
1749
2535
|
var MES_CMD = { changeover: "mes.changeover" };
|
|
1750
2536
|
var CP2 = "0614141";
|
|
1751
2537
|
var WIP_ITEMREF = "066666";
|
|
1752
2538
|
var WIP_GTIN = sgtinClass(CP2, WIP_ITEMREF);
|
|
1753
|
-
var
|
|
2539
|
+
var DEFAULT_YIELD = 0.8;
|
|
1754
2540
|
var PART_A = { itemRef: "055551", gtin: sgtinClass(CP2, "055551") };
|
|
1755
2541
|
var PART_B = { itemRef: "055552", gtin: sgtinClass(CP2, "055552") };
|
|
1756
2542
|
var PRODUCTS = [
|
|
@@ -1774,6 +2560,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1774
2560
|
constructor(tenantId, policy = firstFitPolicy, mesSpec) {
|
|
1775
2561
|
super(tenantId, policy);
|
|
1776
2562
|
this.mesSpec = mesSpec;
|
|
2563
|
+
if (mesSpec?.definition?.operations) this.loadOperations(mesSpec.definition.operations);
|
|
1777
2564
|
}
|
|
1778
2565
|
productOf(gtin) {
|
|
1779
2566
|
return PRODUCTS.find((p) => p.gtin === gtin);
|
|
@@ -1781,7 +2568,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1781
2568
|
/**
|
|
1782
2569
|
* MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
|
|
1783
2570
|
* 자동 체인지오버(task.changeoverKey 상이 시 셋업)의 수동 버전 — 운영자가 사전 전환(툴링 교체) 지시.
|
|
1784
|
-
* 이미 그 제품이면 no-op, 아니면 셋업(
|
|
2571
|
+
* 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
|
|
1785
2572
|
* (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
|
|
1786
2573
|
*/
|
|
1787
2574
|
handleCommand(cmd) {
|
|
@@ -1791,7 +2578,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1791
2578
|
const m = this.movers.get(a.resourceId);
|
|
1792
2579
|
if (!m) return { commandId: cmd.commandId, accepted: false, errorCode: "resource-not-found", errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
|
|
1793
2580
|
if (m.lastChangeoverKey !== a.gtin) {
|
|
1794
|
-
m.setupMs +=
|
|
2581
|
+
m.setupMs += DEFAULT_SETUP_MS;
|
|
1795
2582
|
m.lastChangeoverKey = a.gtin;
|
|
1796
2583
|
this.emitMover(m);
|
|
1797
2584
|
}
|
|
@@ -1848,7 +2635,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1848
2635
|
/** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
|
|
1849
2636
|
emitStation(o, stage, itemEpc, changeoverKey) {
|
|
1850
2637
|
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 },
|
|
2638
|
+
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
2639
|
this.tasks.set(task.id, task);
|
|
1853
2640
|
this.emitTask(task);
|
|
1854
2641
|
}
|
|
@@ -1873,7 +2660,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1873
2660
|
}
|
|
1874
2661
|
const fgStore = this.nodeByType("fg-store");
|
|
1875
2662
|
const wip = order.allocated[0];
|
|
1876
|
-
const good = this.rng() <
|
|
2663
|
+
const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
|
|
1877
2664
|
this.recordOutput(t.resource, good);
|
|
1878
2665
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
1879
2666
|
const outputEpc = sgtinUri(CP2, product.ref, ++this.prodSeq);
|
|
@@ -1947,7 +2734,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1947
2734
|
}
|
|
1948
2735
|
emitStationDef(o, op, itemEpc) {
|
|
1949
2736
|
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 },
|
|
2737
|
+
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
2738
|
this.tasks.set(task.id, task);
|
|
1952
2739
|
this.emitTask(task);
|
|
1953
2740
|
}
|
|
@@ -1974,7 +2761,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1974
2761
|
}
|
|
1975
2762
|
const fgStore = this.nodeByType("fg-store");
|
|
1976
2763
|
const wip = order.allocated[0];
|
|
1977
|
-
const good = this.rng() <
|
|
2764
|
+
const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
|
|
1978
2765
|
this.recordOutput(t.resource, good);
|
|
1979
2766
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
1980
2767
|
const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
|
|
@@ -2002,6 +2789,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
2002
2789
|
EPCIS_CONTEXT,
|
|
2003
2790
|
EventJournal,
|
|
2004
2791
|
FlowEngine,
|
|
2792
|
+
ILMD_ATTR,
|
|
2005
2793
|
MES_BIZSTEP,
|
|
2006
2794
|
MES_NODE_TYPES,
|
|
2007
2795
|
MES_PART_GTINS,
|
|
@@ -2010,6 +2798,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
2010
2798
|
MES_TYPES,
|
|
2011
2799
|
MesKernel,
|
|
2012
2800
|
OP_EVENT,
|
|
2801
|
+
OP_PARAM,
|
|
2013
2802
|
StateProjector,
|
|
2014
2803
|
TwinHistory,
|
|
2015
2804
|
TwinObserver,
|
|
@@ -2035,9 +2824,12 @@ var MesKernel = class extends FlowEngine {
|
|
|
2035
2824
|
gdtiUri,
|
|
2036
2825
|
graiUri,
|
|
2037
2826
|
ingest,
|
|
2827
|
+
lgtinClass,
|
|
2038
2828
|
mapRecord,
|
|
2039
2829
|
monteCarloForecast,
|
|
2040
2830
|
objectEvent,
|
|
2831
|
+
parseEpc,
|
|
2832
|
+
parseIsoDuration,
|
|
2041
2833
|
partialFitPolicy,
|
|
2042
2834
|
replay,
|
|
2043
2835
|
sgtinClass,
|