@operato/twin-kernel 0.0.6 → 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 +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 +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/task-fold.d.ts +56 -0
- package/dist/task-fold.js +60 -0
- package/dist/twin-observer.d.ts +2 -0
- package/dist/twin-observer.js +9 -3
- package/dist-cjs/index.cjs +993 -159
- 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,
|
|
@@ -61,12 +63,16 @@ __export(index_exports, {
|
|
|
61
63
|
deriveAttentions: () => deriveAttentions,
|
|
62
64
|
fefoPolicy: () => fefoPolicy,
|
|
63
65
|
firstFitPolicy: () => firstFitPolicy,
|
|
66
|
+
foldTaskRecords: () => foldTaskRecords,
|
|
64
67
|
gdtiUri: () => gdtiUri,
|
|
65
68
|
graiUri: () => graiUri,
|
|
66
69
|
ingest: () => ingest,
|
|
70
|
+
lgtinClass: () => lgtinClass,
|
|
67
71
|
mapRecord: () => mapRecord,
|
|
68
72
|
monteCarloForecast: () => monteCarloForecast,
|
|
69
73
|
objectEvent: () => objectEvent,
|
|
74
|
+
parseEpc: () => parseEpc,
|
|
75
|
+
parseIsoDuration: () => parseIsoDuration,
|
|
70
76
|
partialFitPolicy: () => partialFitPolicy,
|
|
71
77
|
replay: () => replay,
|
|
72
78
|
sgtinClass: () => sgtinClass,
|
|
@@ -84,6 +90,10 @@ module.exports = __toCommonJS(index_exports);
|
|
|
84
90
|
var OP_EVENT = {
|
|
85
91
|
task: "task.status",
|
|
86
92
|
equipment: "equipment.status",
|
|
93
|
+
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
94
|
+
person: "person.status",
|
|
95
|
+
/** 물리 자산 상태 전이 — 어디 있나·무엇을 싣고 있나(빈 팔레트인가). */
|
|
96
|
+
asset: "asset.status",
|
|
87
97
|
order: "order.status",
|
|
88
98
|
quality: "quality.output"
|
|
89
99
|
// 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
|
|
@@ -110,6 +120,13 @@ var CMD = {
|
|
|
110
120
|
};
|
|
111
121
|
|
|
112
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
|
+
};
|
|
113
130
|
var INTENTS = ["transport", "process", "dwell"];
|
|
114
131
|
function dupes(keys) {
|
|
115
132
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -178,6 +195,9 @@ function compareStates(predicted, actual) {
|
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
// src/counterfactual.ts
|
|
198
|
+
function clockOf(twin) {
|
|
199
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
200
|
+
}
|
|
181
201
|
var TwinHistory = class {
|
|
182
202
|
live;
|
|
183
203
|
tickMs;
|
|
@@ -188,7 +208,7 @@ var TwinHistory = class {
|
|
|
188
208
|
}
|
|
189
209
|
/** 현재를 체크포인트로 저장(호스트가 주기적으로 호출). */
|
|
190
210
|
checkpoint() {
|
|
191
|
-
this.checkpoints.push({ simMs: this.live
|
|
211
|
+
this.checkpoints.push({ simMs: clockOf(this.live), twin: this.live.fork() });
|
|
192
212
|
}
|
|
193
213
|
get count() {
|
|
194
214
|
return this.checkpoints.length;
|
|
@@ -200,7 +220,7 @@ var TwinHistory = class {
|
|
|
200
220
|
if (!best) return void 0;
|
|
201
221
|
const t = best.twin.fork();
|
|
202
222
|
let guard = 0;
|
|
203
|
-
while (t
|
|
223
|
+
while (clockOf(t) < simMs && guard++ < 1e6) t.tick(this.tickMs);
|
|
204
224
|
return t;
|
|
205
225
|
}
|
|
206
226
|
};
|
|
@@ -214,7 +234,7 @@ function counterfactualAt(history, atSimMs, opts) {
|
|
|
214
234
|
const baseline = base.fork();
|
|
215
235
|
const run = (t) => {
|
|
216
236
|
let g = 0;
|
|
217
|
-
while (t
|
|
237
|
+
while (clockOf(t) < target && g++ < 1e6) t.tick(step);
|
|
218
238
|
};
|
|
219
239
|
run(withAlt);
|
|
220
240
|
run(baseline);
|
|
@@ -222,8 +242,11 @@ function counterfactualAt(history, atSimMs, opts) {
|
|
|
222
242
|
}
|
|
223
243
|
|
|
224
244
|
// src/forecast.ts
|
|
245
|
+
function clockOf2(twin) {
|
|
246
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
247
|
+
}
|
|
225
248
|
function monteCarloForecast(twin, opts) {
|
|
226
|
-
const now = twin
|
|
249
|
+
const now = clockOf2(twin);
|
|
227
250
|
const step = opts.tickMs ?? 1e3;
|
|
228
251
|
const baseSeed = opts.scenario.seed ?? 1;
|
|
229
252
|
const samples = [];
|
|
@@ -233,7 +256,7 @@ function monteCarloForecast(twin, opts) {
|
|
|
233
256
|
fc.scenario.start();
|
|
234
257
|
const target = now + opts.horizonMs;
|
|
235
258
|
let guard = 0;
|
|
236
|
-
while (fc
|
|
259
|
+
while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
|
|
237
260
|
samples.push(opts.metric(fc.getSnapshot()));
|
|
238
261
|
}
|
|
239
262
|
return summarize(opts.runs, samples);
|
|
@@ -246,6 +269,9 @@ function summarize(runs, samples) {
|
|
|
246
269
|
}
|
|
247
270
|
|
|
248
271
|
// src/twin-observer.ts
|
|
272
|
+
function clockOf3(twin) {
|
|
273
|
+
return typeof twin.clockMs === "number" ? twin.clockMs : twin.getSnapshot().simClockMs;
|
|
274
|
+
}
|
|
249
275
|
var TwinObserver = class {
|
|
250
276
|
live;
|
|
251
277
|
opts;
|
|
@@ -256,7 +282,7 @@ var TwinObserver = class {
|
|
|
256
282
|
}
|
|
257
283
|
/** 관측 1회 — 만기 예측을 실제와 대조(발산 알림) + 새 예측 생성. 호스트가 주기적으로 호출. */
|
|
258
284
|
observe() {
|
|
259
|
-
const now = this.live
|
|
285
|
+
const now = clockOf3(this.live);
|
|
260
286
|
const due = this.pending.filter((p) => p.horizonSimMs <= now);
|
|
261
287
|
this.pending = this.pending.filter((p) => p.horizonSimMs > now);
|
|
262
288
|
for (const p of due) {
|
|
@@ -267,8 +293,9 @@ var TwinObserver = class {
|
|
|
267
293
|
const step = this.opts.tickMs ?? 1e3;
|
|
268
294
|
const target = now + this.opts.horizonMs;
|
|
269
295
|
let guard = 0;
|
|
270
|
-
while (fc
|
|
271
|
-
|
|
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 });
|
|
272
299
|
}
|
|
273
300
|
/** 대기 중(아직 만기 안 된) 예측 수 — 진단용. */
|
|
274
301
|
get pendingCount() {
|
|
@@ -276,19 +303,241 @@ var TwinObserver = class {
|
|
|
276
303
|
}
|
|
277
304
|
};
|
|
278
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
|
+
|
|
279
520
|
// src/state-projector.ts
|
|
521
|
+
var UNKNOWN_TYPE = "unknown";
|
|
280
522
|
var StateProjector = class {
|
|
523
|
+
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
281
524
|
master = /* @__PURE__ */ new Map();
|
|
282
525
|
items = /* @__PURE__ */ new Map();
|
|
283
526
|
aggregation = /* @__PURE__ */ new Map();
|
|
284
527
|
// parent SSCC → child EPCs
|
|
285
528
|
tasks = /* @__PURE__ */ new Map();
|
|
286
529
|
movers = /* @__PURE__ */ new Map();
|
|
530
|
+
persons = /* @__PURE__ */ new Map();
|
|
531
|
+
assets = /* @__PURE__ */ new Map();
|
|
287
532
|
orders = /* @__PURE__ */ new Map();
|
|
288
533
|
revision = 0;
|
|
534
|
+
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
535
|
+
corrections = [];
|
|
289
536
|
constructor(board) {
|
|
290
|
-
for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parentId: n.parentId });
|
|
291
|
-
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" });
|
|
292
541
|
}
|
|
293
542
|
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
294
543
|
applyMaster(u) {
|
|
@@ -297,48 +546,166 @@ var StateProjector = class {
|
|
|
297
546
|
return;
|
|
298
547
|
}
|
|
299
548
|
const cur = this.master.get(u.node.id);
|
|
549
|
+
const capacity = u.node.capacity ?? cur?.capacity;
|
|
300
550
|
this.master.set(u.node.id, {
|
|
301
551
|
id: u.node.id,
|
|
302
|
-
type: u.node.type ?? cur?.type ??
|
|
303
|
-
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"
|
|
304
557
|
});
|
|
305
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();
|
|
306
600
|
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
307
601
|
apply(e) {
|
|
308
602
|
this.revision++;
|
|
309
603
|
if (e.eventType.startsWith("epcis.")) {
|
|
310
|
-
this.applyEpcis(e.data);
|
|
604
|
+
this.applyEpcis(e.data, e);
|
|
311
605
|
return;
|
|
312
606
|
}
|
|
313
607
|
switch (e.eventType) {
|
|
314
608
|
case OP_EVENT.task: {
|
|
315
609
|
const d = e.data;
|
|
316
|
-
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
|
+
});
|
|
317
629
|
break;
|
|
318
630
|
}
|
|
319
631
|
case OP_EVENT.equipment: {
|
|
320
632
|
const d = e.data;
|
|
321
|
-
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);
|
|
322
664
|
break;
|
|
323
665
|
}
|
|
324
666
|
case OP_EVENT.order: {
|
|
325
667
|
const d = e.data;
|
|
668
|
+
if (this.stale(`order:${d.orderId}`, e)) return;
|
|
326
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 });
|
|
327
670
|
break;
|
|
328
671
|
}
|
|
329
672
|
}
|
|
330
673
|
}
|
|
331
|
-
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
|
+
}
|
|
332
684
|
if (ev.type === "AggregationEvent") {
|
|
333
|
-
if (ev.action === "ADD" && ev.childEPCs?.length)
|
|
334
|
-
|
|
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
|
+
}
|
|
335
699
|
return;
|
|
336
700
|
}
|
|
337
701
|
if (ev.type === "TransactionEvent") return;
|
|
338
702
|
if (ev.type === "TransformationEvent") {
|
|
339
703
|
for (const epc of ev.inputEPCList ?? []) this.remove(epc);
|
|
340
704
|
const loc2 = ev.readPoint?.id ?? "";
|
|
341
|
-
|
|
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
|
+
}
|
|
342
709
|
return;
|
|
343
710
|
}
|
|
344
711
|
if (ev.action === "DELETE") {
|
|
@@ -346,12 +713,58 @@ var StateProjector = class {
|
|
|
346
713
|
return;
|
|
347
714
|
}
|
|
348
715
|
const loc = ev.readPoint?.id;
|
|
349
|
-
const
|
|
716
|
+
const q = ev.quantityList?.[0];
|
|
717
|
+
this.touchLocation(loc);
|
|
350
718
|
for (const epc of ev.epcList) {
|
|
351
|
-
|
|
352
|
-
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
|
+
}
|
|
353
726
|
}
|
|
354
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
|
+
}
|
|
355
768
|
/** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
|
|
356
769
|
remove(epc) {
|
|
357
770
|
this.items.delete(epc);
|
|
@@ -367,8 +780,20 @@ var StateProjector = class {
|
|
|
367
780
|
for (const it of this.items.values()) occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
|
|
368
781
|
return {
|
|
369
782
|
revision: this.revision,
|
|
370
|
-
|
|
371
|
-
|
|
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 })),
|
|
372
797
|
tasks: [...this.tasks.values()].map((t) => ({ ...t })),
|
|
373
798
|
movers: [...this.movers.values()].map((m) => ({ ...m })),
|
|
374
799
|
orders: [...this.orders.values()].map((o) => ({ ...o }))
|
|
@@ -408,114 +833,6 @@ function replay(board, events) {
|
|
|
408
833
|
return proj.snapshot();
|
|
409
834
|
}
|
|
410
835
|
|
|
411
|
-
// src/epcis.ts
|
|
412
|
-
var EPCIS_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld";
|
|
413
|
-
var UTC_OFFSET = "+00:00";
|
|
414
|
-
var DISP = {
|
|
415
|
-
in_progress: "urn:epcglobal:cbv:disp:in_progress",
|
|
416
|
-
sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
|
|
417
|
-
reserved: "urn:epcglobal:cbv:disp:reserved",
|
|
418
|
-
in_transit: "urn:epcglobal:cbv:disp:in_transit",
|
|
419
|
-
non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
|
|
420
|
-
// 불량/scrap
|
|
421
|
-
};
|
|
422
|
-
function ssccUri(companyPrefix, serial) {
|
|
423
|
-
return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
|
|
424
|
-
}
|
|
425
|
-
function sgtinClass(companyPrefix, itemRef) {
|
|
426
|
-
return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
|
|
427
|
-
}
|
|
428
|
-
function gdtiUri(companyPrefix, docType, serial) {
|
|
429
|
-
return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
|
|
430
|
-
}
|
|
431
|
-
function header(type, eventTime, bizStep) {
|
|
432
|
-
return { "@context": EPCIS_CONTEXT, type, eventTime, eventTimeZoneOffset: UTC_OFFSET, bizStep };
|
|
433
|
-
}
|
|
434
|
-
function common(type, eventTime, action, bizStep) {
|
|
435
|
-
return { ...header(type, eventTime, bizStep), action };
|
|
436
|
-
}
|
|
437
|
-
function objectEvent(p) {
|
|
438
|
-
const e = { ...common("ObjectEvent", p.eventTime, p.action, p.bizStep), epcList: p.epcList };
|
|
439
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
440
|
-
if (p.quantityList) e.quantityList = p.quantityList;
|
|
441
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
442
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
443
|
-
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
444
|
-
return e;
|
|
445
|
-
}
|
|
446
|
-
function aggregationEvent(p) {
|
|
447
|
-
const e = { ...common("AggregationEvent", p.eventTime, p.action, p.bizStep), parentID: p.parentID };
|
|
448
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
449
|
-
if (p.childEPCs) e.childEPCs = p.childEPCs;
|
|
450
|
-
if (p.childQuantityList) e.childQuantityList = p.childQuantityList;
|
|
451
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
452
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
453
|
-
return e;
|
|
454
|
-
}
|
|
455
|
-
function transactionEvent(p) {
|
|
456
|
-
const e = {
|
|
457
|
-
...common("TransactionEvent", p.eventTime, p.action, p.bizStep),
|
|
458
|
-
bizTransactionList: p.bizTransactionList
|
|
459
|
-
};
|
|
460
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
461
|
-
if (p.parentID) e.parentID = p.parentID;
|
|
462
|
-
if (p.epcList) e.epcList = p.epcList;
|
|
463
|
-
if (p.quantityList) e.quantityList = p.quantityList;
|
|
464
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
465
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
466
|
-
return e;
|
|
467
|
-
}
|
|
468
|
-
function transformationEvent(p) {
|
|
469
|
-
const e = header("TransformationEvent", p.eventTime, p.bizStep);
|
|
470
|
-
if (p.disposition) e.disposition = p.disposition;
|
|
471
|
-
if (p.inputEPCList) e.inputEPCList = p.inputEPCList;
|
|
472
|
-
if (p.inputQuantityList) e.inputQuantityList = p.inputQuantityList;
|
|
473
|
-
if (p.outputEPCList) e.outputEPCList = p.outputEPCList;
|
|
474
|
-
if (p.outputQuantityList) e.outputQuantityList = p.outputQuantityList;
|
|
475
|
-
if (p.transformationID) e.transformationID = p.transformationID;
|
|
476
|
-
if (p.readPoint) e.readPoint = { id: p.readPoint };
|
|
477
|
-
if (p.bizLocation) e.bizLocation = { id: p.bizLocation };
|
|
478
|
-
if (p.bizTransactionList) e.bizTransactionList = p.bizTransactionList;
|
|
479
|
-
return e;
|
|
480
|
-
}
|
|
481
|
-
var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
482
|
-
var TZ_RE = /^[+-]\d{2}:\d{2}$/;
|
|
483
|
-
var ACTIONS = ["ADD", "OBSERVE", "DELETE"];
|
|
484
|
-
function validateEpcisEvent(e) {
|
|
485
|
-
const v = [];
|
|
486
|
-
if (e["@context"] !== EPCIS_CONTEXT) v.push("@context \uB204\uB77D/\uBD88\uC77C\uCE58");
|
|
487
|
-
if (!["ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"].includes(e.type)) v.push(`\uC54C \uC218 \uC5C6\uB294 type: ${e.type}`);
|
|
488
|
-
if (typeof e.eventTime !== "string" || !ISO_RE.test(e.eventTime)) v.push("eventTime ISO8601 \uC544\uB2D8");
|
|
489
|
-
if (typeof e.eventTimeZoneOffset !== "string" || !TZ_RE.test(e.eventTimeZoneOffset)) v.push("eventTimeZoneOffset \uD615\uC2DD \uC624\uB958");
|
|
490
|
-
if (typeof e.bizStep !== "string" || !e.bizStep) v.push("bizStep \uB204\uB77D");
|
|
491
|
-
if (e.type === "ObjectEvent") {
|
|
492
|
-
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
493
|
-
if (!Array.isArray(e.epcList)) v.push("ObjectEvent.epcList \uB204\uB77D");
|
|
494
|
-
else if (e.epcList.length === 0 && !e.quantityList?.length) v.push("ObjectEvent: epcList/quantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
495
|
-
} else if (e.type === "AggregationEvent") {
|
|
496
|
-
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
497
|
-
if (!e.parentID) v.push("AggregationEvent.parentID \uB204\uB77D");
|
|
498
|
-
if (!e.childEPCs?.length && !e.childQuantityList?.length) v.push("AggregationEvent: childEPCs/childQuantityList \uB458 \uB2E4 \uBE44\uC5B4\uC788\uC74C");
|
|
499
|
-
} else if (e.type === "TransactionEvent") {
|
|
500
|
-
if (!ACTIONS.includes(e.action)) v.push(`action \uBD80\uC815: ${e.action}`);
|
|
501
|
-
if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0) v.push("TransactionEvent.bizTransactionList \uB204\uB77D");
|
|
502
|
-
} else if (e.type === "TransformationEvent") {
|
|
503
|
-
if (!e.inputEPCList?.length && !e.inputQuantityList?.length) v.push("TransformationEvent: input \uBE44\uC5B4\uC788\uC74C");
|
|
504
|
-
if (!e.outputEPCList?.length && !e.outputQuantityList?.length) v.push("TransformationEvent: output \uBE44\uC5B4\uC788\uC74C");
|
|
505
|
-
}
|
|
506
|
-
const qtyLists = [
|
|
507
|
-
"quantityList" in e ? e.quantityList : void 0,
|
|
508
|
-
"childQuantityList" in e ? e.childQuantityList : void 0,
|
|
509
|
-
"inputQuantityList" in e ? e.inputQuantityList : void 0,
|
|
510
|
-
"outputQuantityList" in e ? e.outputQuantityList : void 0
|
|
511
|
-
];
|
|
512
|
-
for (const list of qtyLists) for (const q of list ?? []) {
|
|
513
|
-
if (!q.epcClass?.startsWith("urn:epc:idpat:") && !q.epcClass?.startsWith("urn:epc:class:")) v.push(`quantity epcClass \uBD80\uC815: ${q.epcClass}`);
|
|
514
|
-
if (typeof q.quantity !== "number" || q.quantity < 0) v.push("quantity \uBD80\uC815");
|
|
515
|
-
}
|
|
516
|
-
return v;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
836
|
// src/wms-profile.ts
|
|
520
837
|
var BIZSTEP = {
|
|
521
838
|
receiving: "urn:epcglobal:cbv:bizstep:receiving",
|
|
@@ -679,7 +996,66 @@ var fefoPolicy = {
|
|
|
679
996
|
};
|
|
680
997
|
|
|
681
998
|
// src/duration-estimator.ts
|
|
682
|
-
var constantDuration = (
|
|
999
|
+
var constantDuration = (ms2) => ({ estimate: () => ms2 });
|
|
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
|
+
|
|
1020
|
+
// src/task-fold.ts
|
|
1021
|
+
function ms(value) {
|
|
1022
|
+
if (!value) return null;
|
|
1023
|
+
const t = Date.parse(value);
|
|
1024
|
+
return Number.isFinite(t) ? t : null;
|
|
1025
|
+
}
|
|
1026
|
+
function deltaOf(row) {
|
|
1027
|
+
const p = row.payload;
|
|
1028
|
+
return row.data ?? p?.data ?? p ?? {};
|
|
1029
|
+
}
|
|
1030
|
+
function foldTaskRecords(rows) {
|
|
1031
|
+
const byTask = /* @__PURE__ */ new Map();
|
|
1032
|
+
let rowsRead = 0;
|
|
1033
|
+
let latestMs = 0;
|
|
1034
|
+
for (const row of rows) {
|
|
1035
|
+
const at = ms(row.eventTime);
|
|
1036
|
+
if (at === null) continue;
|
|
1037
|
+
const d = deltaOf(row);
|
|
1038
|
+
const id = d.taskId;
|
|
1039
|
+
if (!id) continue;
|
|
1040
|
+
rowsRead++;
|
|
1041
|
+
latestMs = Math.max(latestMs, at);
|
|
1042
|
+
let rec = byTask.get(id);
|
|
1043
|
+
if (!rec) {
|
|
1044
|
+
rec = { taskId: id, facets: {} };
|
|
1045
|
+
byTask.set(id, rec);
|
|
1046
|
+
}
|
|
1047
|
+
const f = rec.facets;
|
|
1048
|
+
if (f.resource === void 0 && d.resourceRef) f.resource = d.resourceRef;
|
|
1049
|
+
if (f.kind === void 0 && d.kind) f.kind = d.kind;
|
|
1050
|
+
if (f.order === void 0 && d.orderId) f.order = d.orderId;
|
|
1051
|
+
const node = d.toNode ?? d.fromNode;
|
|
1052
|
+
if (node) f.node = node;
|
|
1053
|
+
if (d.status === "created") rec.createdMs = at;
|
|
1054
|
+
else if (d.status === "in-progress") rec.startedMs = rec.startedMs != null ? Math.min(rec.startedMs, at) : at;
|
|
1055
|
+
else if (d.status === "completed") rec.completedMs = rec.completedMs != null ? Math.max(rec.completedMs, at) : at;
|
|
1056
|
+
}
|
|
1057
|
+
return { records: [...byTask.values()], rowsRead, latestMs };
|
|
1058
|
+
}
|
|
683
1059
|
|
|
684
1060
|
// src/face2-adapter.ts
|
|
685
1061
|
function get(obj, path) {
|
|
@@ -890,14 +1266,25 @@ var FlowEngine = class {
|
|
|
890
1266
|
nodes = /* @__PURE__ */ new Map();
|
|
891
1267
|
items = /* @__PURE__ */ new Map();
|
|
892
1268
|
movers = /* @__PURE__ */ new Map();
|
|
1269
|
+
/** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
|
|
1270
|
+
persons = /* @__PURE__ */ new Map();
|
|
1271
|
+
/** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
|
|
1272
|
+
assets = /* @__PURE__ */ new Map();
|
|
893
1273
|
tasks = /* @__PURE__ */ new Map();
|
|
894
1274
|
orders = /* @__PURE__ */ new Map();
|
|
895
1275
|
revision = 0;
|
|
896
1276
|
clockMs = 0;
|
|
897
1277
|
rng = mulberry32(1);
|
|
898
1278
|
policy;
|
|
899
|
-
/** duration 시임(선택) — 미주입 시 도메인 상수.
|
|
1279
|
+
/** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
|
|
900
1280
|
durationEstimator;
|
|
1281
|
+
/**
|
|
1282
|
+
* 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
|
|
1283
|
+
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
1284
|
+
*/
|
|
1285
|
+
operationSpecs = /* @__PURE__ */ new Map();
|
|
1286
|
+
/** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
|
|
1287
|
+
specUse = /* @__PURE__ */ new Map();
|
|
901
1288
|
epcSeq = 0;
|
|
902
1289
|
taskSeq = 0;
|
|
903
1290
|
orderSeq = 0;
|
|
@@ -913,9 +1300,11 @@ var FlowEngine = class {
|
|
|
913
1300
|
}
|
|
914
1301
|
// ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
|
|
915
1302
|
loadBoard(def) {
|
|
916
|
-
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 });
|
|
917
1306
|
for (const m of def.movers) {
|
|
918
|
-
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 };
|
|
919
1308
|
if (m.mtbfMs !== void 0) {
|
|
920
1309
|
mover.mtbfMs = m.mtbfMs;
|
|
921
1310
|
mover.mttrMs = m.mttrMs;
|
|
@@ -946,10 +1335,81 @@ var FlowEngine = class {
|
|
|
946
1335
|
* 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
|
|
947
1336
|
*/
|
|
948
1337
|
hydrateObserved(snap, orders = []) {
|
|
949
|
-
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
|
+
}
|
|
950
1341
|
this.items.clear();
|
|
951
|
-
for (const it of snap.items)
|
|
952
|
-
|
|
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
|
+
}
|
|
953
1413
|
for (const o of orders) {
|
|
954
1414
|
const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
|
|
955
1415
|
const remaining = lines.reduce((s, l) => s + l.requested, 0);
|
|
@@ -1080,7 +1540,7 @@ var FlowEngine = class {
|
|
|
1080
1540
|
start: () => {
|
|
1081
1541
|
if (this.generating) return;
|
|
1082
1542
|
this.generating = true;
|
|
1083
|
-
for (const g of this.gens) g.nextMs = this.
|
|
1543
|
+
for (const g of this.gens) g.nextMs = this.nextFireMs(g.spec, this.clockMs);
|
|
1084
1544
|
},
|
|
1085
1545
|
pause: () => {
|
|
1086
1546
|
this.generating = false;
|
|
@@ -1108,12 +1568,22 @@ var FlowEngine = class {
|
|
|
1108
1568
|
nodes: [...this.nodes.values()].map((n) => ({ ...n })),
|
|
1109
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 })),
|
|
1110
1570
|
movers: [...this.movers.values()].map((m) => {
|
|
1111
|
-
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 } : {} };
|
|
1112
1572
|
const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
|
|
1113
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 };
|
|
1114
1574
|
return s;
|
|
1115
1575
|
}),
|
|
1116
|
-
|
|
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() } : {} })),
|
|
1117
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 })),
|
|
1118
1588
|
attentions: this.computeAttentions()
|
|
1119
1589
|
};
|
|
@@ -1150,6 +1620,8 @@ var FlowEngine = class {
|
|
|
1150
1620
|
}
|
|
1151
1621
|
clone.rng.state = this.rng.state;
|
|
1152
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) }]));
|
|
1153
1625
|
return clone;
|
|
1154
1626
|
}
|
|
1155
1627
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
@@ -1159,9 +1631,121 @@ var FlowEngine = class {
|
|
|
1159
1631
|
randInt(min, max) {
|
|
1160
1632
|
return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
|
|
1161
1633
|
}
|
|
1162
|
-
/**
|
|
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
|
+
*/
|
|
1163
1648
|
durationOf(ctx, fallbackMs) {
|
|
1164
|
-
|
|
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
|
+
};
|
|
1165
1749
|
}
|
|
1166
1750
|
/** skuMix 에서 weight 로 gtin 선택(rng) — 도착/오더 자극의 품목 결정. */
|
|
1167
1751
|
pickGtin(mix) {
|
|
@@ -1275,8 +1859,38 @@ var FlowEngine = class {
|
|
|
1275
1859
|
const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
|
|
1276
1860
|
for (const h of this.handlers) h(e);
|
|
1277
1861
|
}
|
|
1862
|
+
/**
|
|
1863
|
+
* 작업 전이 방출 — **커널이 아는 것을 미러도 알게** 한다.
|
|
1864
|
+
*
|
|
1865
|
+
* 예전에는 진척·남은 시간·의도를 싣지 않아, 미러 상태를 씨앗으로 한 예측이 "진행 중인 일이 없는
|
|
1866
|
+
* 현장" 에서 출발했고, 소비처는 무자원 체류를 기록 누락으로 오해할 수밖에 없었다.
|
|
1867
|
+
* 진척은 진행 중일 때만 뜻이 있으므로 그때만 싣는다(생성·완료 시점의 0/1 은 노이즈).
|
|
1868
|
+
*/
|
|
1278
1869
|
emitTask(t) {
|
|
1279
|
-
|
|
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 } : {} });
|
|
1280
1894
|
}
|
|
1281
1895
|
emitMover(m, motion) {
|
|
1282
1896
|
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion });
|
|
@@ -1285,9 +1899,189 @@ var FlowEngine = class {
|
|
|
1285
1899
|
this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held });
|
|
1286
1900
|
}
|
|
1287
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
|
+
*/
|
|
1288
1913
|
intervalMs(spec) {
|
|
1289
|
-
const
|
|
1290
|
-
|
|
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;
|
|
1291
2085
|
}
|
|
1292
2086
|
/** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
|
|
1293
2087
|
sampleExp(meanMs) {
|
|
@@ -1328,10 +2122,19 @@ var FlowEngine = class {
|
|
|
1328
2122
|
generate() {
|
|
1329
2123
|
for (const g of this.gens) {
|
|
1330
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
|
+
}
|
|
1331
2134
|
const stimulus = g.spec.stimulus ?? (g.spec.kind === "outbound-order" ? "order" : "arrival");
|
|
1332
2135
|
if (stimulus === "order") this.onOrder(g.spec);
|
|
1333
2136
|
else this.onArrival(g.spec);
|
|
1334
|
-
g.nextMs
|
|
2137
|
+
g.nextMs = next;
|
|
1335
2138
|
}
|
|
1336
2139
|
}
|
|
1337
2140
|
}
|
|
@@ -1341,13 +2144,22 @@ var FlowEngine = class {
|
|
|
1341
2144
|
processTasks(dt) {
|
|
1342
2145
|
for (const t of this.tasks.values()) {
|
|
1343
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;
|
|
1344
2151
|
if (t.intent === "dwell") {
|
|
1345
2152
|
t.status = "in-progress";
|
|
1346
2153
|
t.remainingMs = t.durationMs;
|
|
2154
|
+
this.assignCrew(t, crew);
|
|
2155
|
+
this.assignAssets(t, gear);
|
|
1347
2156
|
this.emitTask(t);
|
|
1348
2157
|
continue;
|
|
1349
2158
|
}
|
|
1350
|
-
|
|
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
|
+
);
|
|
1351
2163
|
if (!mover) continue;
|
|
1352
2164
|
if (t.setupMs && t.changeoverKey !== void 0 && mover.lastChangeoverKey !== void 0 && mover.lastChangeoverKey !== t.changeoverKey) {
|
|
1353
2165
|
t.appliedSetupMs = t.setupMs;
|
|
@@ -1359,6 +2171,8 @@ var FlowEngine = class {
|
|
|
1359
2171
|
t.status = "in-progress";
|
|
1360
2172
|
t.resource = mover.id;
|
|
1361
2173
|
t.remainingMs = t.durationMs;
|
|
2174
|
+
this.assignCrew(t, crew);
|
|
2175
|
+
this.assignAssets(t, gear);
|
|
1362
2176
|
this.emitTask(t);
|
|
1363
2177
|
if (t.intent === "process") this.emitMover(mover);
|
|
1364
2178
|
else this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
|
|
@@ -1370,6 +2184,8 @@ var FlowEngine = class {
|
|
|
1370
2184
|
if (t.remainingMs > 0) continue;
|
|
1371
2185
|
this.onTaskComplete(t);
|
|
1372
2186
|
t.status = "completed";
|
|
2187
|
+
this.releaseCrew(t);
|
|
2188
|
+
this.releaseAssets(t);
|
|
1373
2189
|
if (!t.resource) {
|
|
1374
2190
|
this.emitTask(t);
|
|
1375
2191
|
continue;
|
|
@@ -1413,7 +2229,18 @@ var WmsKernel = class extends FlowEngine {
|
|
|
1413
2229
|
dock.occupancy++;
|
|
1414
2230
|
this.emit(transactionEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
|
|
1415
2231
|
this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
|
|
1416
|
-
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
|
+
}));
|
|
1417
2244
|
const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews("storage") });
|
|
1418
2245
|
if (!binId) return;
|
|
1419
2246
|
const id = `task-${++this.taskSeq}`;
|
|
@@ -1703,13 +2530,13 @@ var YmsKernel = class extends FlowEngine {
|
|
|
1703
2530
|
};
|
|
1704
2531
|
|
|
1705
2532
|
// src/mes-kernel.ts
|
|
1706
|
-
var
|
|
1707
|
-
var
|
|
2533
|
+
var DEFAULT_CYCLE_MS = 4e4;
|
|
2534
|
+
var DEFAULT_SETUP_MS = 15e3;
|
|
1708
2535
|
var MES_CMD = { changeover: "mes.changeover" };
|
|
1709
2536
|
var CP2 = "0614141";
|
|
1710
2537
|
var WIP_ITEMREF = "066666";
|
|
1711
2538
|
var WIP_GTIN = sgtinClass(CP2, WIP_ITEMREF);
|
|
1712
|
-
var
|
|
2539
|
+
var DEFAULT_YIELD = 0.8;
|
|
1713
2540
|
var PART_A = { itemRef: "055551", gtin: sgtinClass(CP2, "055551") };
|
|
1714
2541
|
var PART_B = { itemRef: "055552", gtin: sgtinClass(CP2, "055552") };
|
|
1715
2542
|
var PRODUCTS = [
|
|
@@ -1733,6 +2560,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1733
2560
|
constructor(tenantId, policy = firstFitPolicy, mesSpec) {
|
|
1734
2561
|
super(tenantId, policy);
|
|
1735
2562
|
this.mesSpec = mesSpec;
|
|
2563
|
+
if (mesSpec?.definition?.operations) this.loadOperations(mesSpec.definition.operations);
|
|
1736
2564
|
}
|
|
1737
2565
|
productOf(gtin) {
|
|
1738
2566
|
return PRODUCTS.find((p) => p.gtin === gtin);
|
|
@@ -1740,7 +2568,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1740
2568
|
/**
|
|
1741
2569
|
* MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
|
|
1742
2570
|
* 자동 체인지오버(task.changeoverKey 상이 시 셋업)의 수동 버전 — 운영자가 사전 전환(툴링 교체) 지시.
|
|
1743
|
-
* 이미 그 제품이면 no-op, 아니면 셋업(
|
|
2571
|
+
* 이미 그 제품이면 no-op, 아니면 셋업(기본값, OEE 가용성 손실) + lastChangeoverKey 각인
|
|
1744
2572
|
* (이후 그 제품 task 는 자동 셋업 생략). command → 변이 → State 델타(폐루프).
|
|
1745
2573
|
*/
|
|
1746
2574
|
handleCommand(cmd) {
|
|
@@ -1750,7 +2578,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1750
2578
|
const m = this.movers.get(a.resourceId);
|
|
1751
2579
|
if (!m) return { commandId: cmd.commandId, accepted: false, errorCode: "resource-not-found", errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
|
|
1752
2580
|
if (m.lastChangeoverKey !== a.gtin) {
|
|
1753
|
-
m.setupMs +=
|
|
2581
|
+
m.setupMs += DEFAULT_SETUP_MS;
|
|
1754
2582
|
m.lastChangeoverKey = a.gtin;
|
|
1755
2583
|
this.emitMover(m);
|
|
1756
2584
|
}
|
|
@@ -1807,7 +2635,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1807
2635
|
/** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
|
|
1808
2636
|
emitStation(o, stage, itemEpc, changeoverKey) {
|
|
1809
2637
|
const node = this.nodeByType(stage.node);
|
|
1810
|
-
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" };
|
|
1811
2639
|
this.tasks.set(task.id, task);
|
|
1812
2640
|
this.emitTask(task);
|
|
1813
2641
|
}
|
|
@@ -1832,7 +2660,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1832
2660
|
}
|
|
1833
2661
|
const fgStore = this.nodeByType("fg-store");
|
|
1834
2662
|
const wip = order.allocated[0];
|
|
1835
|
-
const good = this.rng() <
|
|
2663
|
+
const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
|
|
1836
2664
|
this.recordOutput(t.resource, good);
|
|
1837
2665
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
1838
2666
|
const outputEpc = sgtinUri(CP2, product.ref, ++this.prodSeq);
|
|
@@ -1906,7 +2734,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1906
2734
|
}
|
|
1907
2735
|
emitStationDef(o, op, itemEpc) {
|
|
1908
2736
|
const node = this.nodeByType(op.nodeType);
|
|
1909
|
-
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 };
|
|
1910
2738
|
this.tasks.set(task.id, task);
|
|
1911
2739
|
this.emitTask(task);
|
|
1912
2740
|
}
|
|
@@ -1933,7 +2761,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1933
2761
|
}
|
|
1934
2762
|
const fgStore = this.nodeByType("fg-store");
|
|
1935
2763
|
const wip = order.allocated[0];
|
|
1936
|
-
const good = this.rng() <
|
|
2764
|
+
const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
|
|
1937
2765
|
this.recordOutput(t.resource, good);
|
|
1938
2766
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
1939
2767
|
const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
|
|
@@ -1961,6 +2789,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1961
2789
|
EPCIS_CONTEXT,
|
|
1962
2790
|
EventJournal,
|
|
1963
2791
|
FlowEngine,
|
|
2792
|
+
ILMD_ATTR,
|
|
1964
2793
|
MES_BIZSTEP,
|
|
1965
2794
|
MES_NODE_TYPES,
|
|
1966
2795
|
MES_PART_GTINS,
|
|
@@ -1969,6 +2798,7 @@ var MesKernel = class extends FlowEngine {
|
|
|
1969
2798
|
MES_TYPES,
|
|
1970
2799
|
MesKernel,
|
|
1971
2800
|
OP_EVENT,
|
|
2801
|
+
OP_PARAM,
|
|
1972
2802
|
StateProjector,
|
|
1973
2803
|
TwinHistory,
|
|
1974
2804
|
TwinObserver,
|
|
@@ -1990,12 +2820,16 @@ var MesKernel = class extends FlowEngine {
|
|
|
1990
2820
|
deriveAttentions,
|
|
1991
2821
|
fefoPolicy,
|
|
1992
2822
|
firstFitPolicy,
|
|
2823
|
+
foldTaskRecords,
|
|
1993
2824
|
gdtiUri,
|
|
1994
2825
|
graiUri,
|
|
1995
2826
|
ingest,
|
|
2827
|
+
lgtinClass,
|
|
1996
2828
|
mapRecord,
|
|
1997
2829
|
monteCarloForecast,
|
|
1998
2830
|
objectEvent,
|
|
2831
|
+
parseEpc,
|
|
2832
|
+
parseIsoDuration,
|
|
1999
2833
|
partialFitPolicy,
|
|
2000
2834
|
replay,
|
|
2001
2835
|
sgtinClass,
|