@operato/twin-kernel 0.2.0 → 0.2.2
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 +46 -1
- package/dist/contract.js +17 -0
- package/dist/domain-definition.d.ts +13 -0
- package/dist/flow-engine.d.ts +105 -1
- package/dist/flow-engine.js +292 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/kernel.js +8 -3
- package/dist/mes-kernel.js +4 -6
- package/dist/observed-reducer.d.ts +103 -0
- package/dist/observed-reducer.js +384 -0
- package/dist/state-projector.d.ts +2 -100
- package/dist/state-projector.js +5 -345
- package/dist/yms-kernel.js +4 -2
- package/dist-cjs/index.cjs +333 -52
- package/package.json +1 -1
package/dist/flow-engine.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
|
|
10
10
|
* (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowNode 단일 base 방향과 정합.)
|
|
11
11
|
*/
|
|
12
|
-
import { OP_EVENT, CMD } from "./contract.js";
|
|
13
|
-
import {
|
|
12
|
+
import { OP_EVENT, CMD, nodeStatusOf } from "./contract.js";
|
|
13
|
+
import { ObservedReducer } from "./observed-reducer.js";
|
|
14
|
+
import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR } from "./epcis.js";
|
|
14
15
|
import { parseIsoDuration } from "./iso-duration.js";
|
|
15
16
|
const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
|
|
16
17
|
function mulberry32(seed) {
|
|
@@ -134,6 +135,13 @@ export class FlowEngine {
|
|
|
134
135
|
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
135
136
|
*/
|
|
136
137
|
operationSpecs = new Map();
|
|
138
|
+
/** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
|
|
139
|
+
observer;
|
|
140
|
+
/** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
|
|
141
|
+
observedDirty = false;
|
|
142
|
+
observeMode = false;
|
|
143
|
+
/** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
|
|
144
|
+
boardDef;
|
|
137
145
|
/** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
|
|
138
146
|
specUse = new Map();
|
|
139
147
|
epcSeq = 0;
|
|
@@ -141,6 +149,10 @@ export class FlowEngine {
|
|
|
141
149
|
orderSeq = 0;
|
|
142
150
|
soSeq = 0;
|
|
143
151
|
handlers = [];
|
|
152
|
+
/** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
|
|
153
|
+
handlersRef() {
|
|
154
|
+
return this.handlers;
|
|
155
|
+
}
|
|
144
156
|
gens = [];
|
|
145
157
|
generating = false;
|
|
146
158
|
speed = 1;
|
|
@@ -151,6 +163,7 @@ export class FlowEngine {
|
|
|
151
163
|
}
|
|
152
164
|
// ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
|
|
153
165
|
loadBoard(def) {
|
|
166
|
+
this.boardDef = def;
|
|
154
167
|
for (const n of def.nodes)
|
|
155
168
|
this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
|
|
156
169
|
for (const p of def.persons ?? [])
|
|
@@ -199,9 +212,10 @@ export class FlowEngine {
|
|
|
199
212
|
this.items.clear();
|
|
200
213
|
for (const it of snap.items) {
|
|
201
214
|
this.items.set(it.epc, {
|
|
215
|
+
/* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
|
|
202
216
|
epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable,
|
|
203
|
-
gtin: it.gtin,
|
|
204
|
-
parent: it.parent, expiry: it.expiry, ilmd: it.ilmd
|
|
217
|
+
gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom,
|
|
218
|
+
parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
|
|
205
219
|
});
|
|
206
220
|
}
|
|
207
221
|
for (const m of snap.movers) {
|
|
@@ -247,6 +261,7 @@ export class FlowEngine {
|
|
|
247
261
|
fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
|
|
248
262
|
resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
|
|
249
263
|
remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
|
|
264
|
+
startedAtSimMs: t.startedAtSimMs,
|
|
250
265
|
durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
|
|
251
266
|
orderId: t.orderId,
|
|
252
267
|
intent: t.intent
|
|
@@ -273,7 +288,14 @@ export class FlowEngine {
|
|
|
273
288
|
}
|
|
274
289
|
}
|
|
275
290
|
}
|
|
276
|
-
|
|
291
|
+
/* 오더 원값은 이제 **스냅샷에 있다** — 따로 넘겨받은 것이 없으면 스냅샷에서 읽는다.
|
|
292
|
+
* (예전에는 상태가 progress 로 압축돼 호출부가 저널을 뒤져 원값을 넘겨야 했다.) */
|
|
293
|
+
const observedOrders = orders.length
|
|
294
|
+
? orders
|
|
295
|
+
: (snap.orders ?? [])
|
|
296
|
+
.filter(o => o.requested !== undefined)
|
|
297
|
+
.map(o => ({ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines }));
|
|
298
|
+
for (const o of observedOrders) {
|
|
277
299
|
const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
|
|
278
300
|
const remaining = lines.reduce((s, l) => s + l.requested, 0);
|
|
279
301
|
if (remaining <= 0)
|
|
@@ -434,13 +456,21 @@ export class FlowEngine {
|
|
|
434
456
|
this.processTasks(dt);
|
|
435
457
|
}
|
|
436
458
|
getSnapshot() {
|
|
459
|
+
this.settleObserved();
|
|
437
460
|
return {
|
|
438
461
|
revision: this.revision,
|
|
439
462
|
simClockMs: this.clockMs,
|
|
440
|
-
|
|
441
|
-
|
|
463
|
+
/* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
|
|
464
|
+
* 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
|
|
465
|
+
nodes: [...this.nodes.values()].map(n => {
|
|
466
|
+
const { status, ...rest } = n;
|
|
467
|
+
/* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
|
|
468
|
+
const derived = nodeStatusOf(n);
|
|
469
|
+
return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
|
|
470
|
+
}),
|
|
471
|
+
items: [...this.items.values()].map(i => this.itemState(i)),
|
|
442
472
|
movers: [...this.movers.values()].map(m => {
|
|
443
|
-
const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(this.offShift(m) ? { offShift: true } : {}) };
|
|
473
|
+
const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, origin: 'master', ...(this.offShift(m) ? { offShift: true } : {}) };
|
|
444
474
|
const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
|
|
445
475
|
if (t && t.status === 'in-progress' && t.intent !== 'process')
|
|
446
476
|
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 };
|
|
@@ -458,8 +488,26 @@ export class FlowEngine {
|
|
|
458
488
|
st.offShift = true;
|
|
459
489
|
return st;
|
|
460
490
|
}),
|
|
461
|
-
|
|
462
|
-
|
|
491
|
+
/* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
|
|
492
|
+
* 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
|
|
493
|
+
* 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
|
|
494
|
+
tasks: [...this.tasks.values()].map(t => ({
|
|
495
|
+
id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
|
|
496
|
+
fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
|
|
497
|
+
...(t.intent ? { intent: t.intent } : {}),
|
|
498
|
+
...(t.durationMs ? { durationMs: t.durationMs } : {}),
|
|
499
|
+
...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
|
|
500
|
+
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
501
|
+
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
502
|
+
...(t.resources?.length ? { resources: t.resources.slice() } : {})
|
|
503
|
+
})),
|
|
504
|
+
orders: [...this.orders.values()].map(o => ({
|
|
505
|
+
id: o.id, kind: o.kind, status: o.status,
|
|
506
|
+
progress: o.requested ? o.fulfilled / o.requested : 0,
|
|
507
|
+
requested: o.requested, fulfilled: o.fulfilled,
|
|
508
|
+
...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
|
|
509
|
+
held: o.held
|
|
510
|
+
})),
|
|
463
511
|
attentions: this.computeAttentions()
|
|
464
512
|
};
|
|
465
513
|
}
|
|
@@ -485,6 +533,7 @@ export class FlowEngine {
|
|
|
485
533
|
* rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
|
|
486
534
|
*/
|
|
487
535
|
fork(tenantId = this.tenantId) {
|
|
536
|
+
this.settleObserved(); // 관측으로 굴러온 커널을 fork 하려면 먼저 상태로 옮겨야 한다
|
|
488
537
|
const Ctor = this.constructor;
|
|
489
538
|
const clone = new Ctor(tenantId, this.policy);
|
|
490
539
|
// 상태·시나리오(gens/generating)·시퀀스 전부 복제 → 원본의 완전한 continuation.
|
|
@@ -510,6 +559,54 @@ export class FlowEngine {
|
|
|
510
559
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
511
560
|
now() { return new Date(BASE_EPOCH + this.clockMs).toISOString(); }
|
|
512
561
|
randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
|
|
562
|
+
/**
|
|
563
|
+
* 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
|
|
564
|
+
*
|
|
565
|
+
* 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
|
|
566
|
+
* 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
|
|
567
|
+
* 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
|
|
568
|
+
*
|
|
569
|
+
* 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
|
|
570
|
+
* (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
|
|
571
|
+
* 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
|
|
572
|
+
*
|
|
573
|
+
* **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
|
|
574
|
+
* reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
|
|
575
|
+
*
|
|
576
|
+
* `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
|
|
577
|
+
*/
|
|
578
|
+
apply(envelope) {
|
|
579
|
+
if (!this.observer) {
|
|
580
|
+
this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
|
|
581
|
+
this.observeMode = true;
|
|
582
|
+
}
|
|
583
|
+
this.observer.apply(envelope);
|
|
584
|
+
/* **구독자에게 그대로 흘린다** — 호스트가 시뮬·관측 두 모드에서 같은 배선을 쓰게 하기 위해서다
|
|
585
|
+
* (`onEvent` 하나로 저널·방송이 붙는다). 관측 모드에서 이것은 **재방출**이지 새 사실이 아니다:
|
|
586
|
+
* 원천이 이미 그 이벤트를 갖고 있으므로, 호스트가 인입과 재방출을 **둘 다 저널에 적으면 중복**이
|
|
587
|
+
* 된다. 저널은 인입에서 한 번만 적는다. */
|
|
588
|
+
for (const h of this.observedHandlers())
|
|
589
|
+
h(envelope);
|
|
590
|
+
/* **이벤트마다 상태를 통째로 옮기지 않는다.** 옮기는 비용은 O(상태 크기)라, 이벤트 하나에 그것을
|
|
591
|
+
* 치르면 유입이 늘수록 감당이 안 된다. 필요해지는 순간(스냅샷·fork)에 **한 번만** 옮긴다. */
|
|
592
|
+
this.observedDirty = true;
|
|
593
|
+
this.revision++;
|
|
594
|
+
}
|
|
595
|
+
/** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
|
|
596
|
+
settleObserved() {
|
|
597
|
+
if (!this.observedDirty || !this.observer)
|
|
598
|
+
return;
|
|
599
|
+
this.observedDirty = false;
|
|
600
|
+
this.hydrateObserved(this.observer.snapshot());
|
|
601
|
+
}
|
|
602
|
+
/** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
|
|
603
|
+
observedHandlers() {
|
|
604
|
+
return this.handlersRef();
|
|
605
|
+
}
|
|
606
|
+
/** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
|
|
607
|
+
get observing() {
|
|
608
|
+
return this.observeMode;
|
|
609
|
+
}
|
|
513
610
|
/**
|
|
514
611
|
* 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
|
|
515
612
|
* 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
|
|
@@ -694,6 +791,52 @@ export class FlowEngine {
|
|
|
694
791
|
bizTransactionList: opts.bizTransactionList
|
|
695
792
|
}));
|
|
696
793
|
}
|
|
794
|
+
/**
|
|
795
|
+
* 할당(예약) — **처분 변화를 이벤트로 낸다.**
|
|
796
|
+
*
|
|
797
|
+
* 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
|
|
798
|
+
* 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
|
|
799
|
+
* 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
|
|
800
|
+
*
|
|
801
|
+
* 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
|
|
802
|
+
* 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
803
|
+
*
|
|
804
|
+
* `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
|
|
805
|
+
* 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
|
|
806
|
+
*/
|
|
807
|
+
reserve(epcs, bizStep) {
|
|
808
|
+
this.observeDisposition(epcs, DISP.reserved, bizStep);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
|
|
812
|
+
*
|
|
813
|
+
* 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
|
|
814
|
+
* 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
|
|
815
|
+
*
|
|
816
|
+
* 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
817
|
+
* 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
|
|
818
|
+
*/
|
|
819
|
+
observeDisposition(epcs, disposition, bizStep, at) {
|
|
820
|
+
const byLocation = new Map();
|
|
821
|
+
for (const epc of epcs) {
|
|
822
|
+
const it = this.items.get(epc);
|
|
823
|
+
if (!it || it.disposition === disposition)
|
|
824
|
+
continue;
|
|
825
|
+
it.disposition = disposition;
|
|
826
|
+
const where = at ?? it.location ?? '';
|
|
827
|
+
const bin = byLocation.get(where);
|
|
828
|
+
if (bin)
|
|
829
|
+
bin.push(epc);
|
|
830
|
+
else
|
|
831
|
+
byLocation.set(where, [epc]);
|
|
832
|
+
}
|
|
833
|
+
for (const [where, list] of byLocation) {
|
|
834
|
+
this.emit(objectEvent({
|
|
835
|
+
eventTime: this.now(), action: 'OBSERVE', bizStep, disposition,
|
|
836
|
+
epcList: list, ...(where ? { readPoint: where, bizLocation: where } : {})
|
|
837
|
+
}));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
697
840
|
/**
|
|
698
841
|
* containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
|
|
699
842
|
* consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
|
|
@@ -712,6 +855,14 @@ export class FlowEngine {
|
|
|
712
855
|
this.items.delete(c);
|
|
713
856
|
}
|
|
714
857
|
}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
/* 흡수하지 않는 조립 — 자식은 독립 물품으로 남되 **소속을 상태에도 남긴다.**
|
|
861
|
+
* 예전에는 이벤트만 내고 상태를 안 바꿔서, 미러는 소속을 알고 시뮬은 몰랐다(같은 사실, 다른 답). */
|
|
862
|
+
for (const c of children) {
|
|
863
|
+
const it = this.items.get(c);
|
|
864
|
+
if (it)
|
|
865
|
+
it.parent = parent;
|
|
715
866
|
}
|
|
716
867
|
}
|
|
717
868
|
/**
|
|
@@ -721,6 +872,12 @@ export class FlowEngine {
|
|
|
721
872
|
*/
|
|
722
873
|
disaggregate(parent, children, opts) {
|
|
723
874
|
this.emit(aggregationEvent({ eventTime: this.now(), action: 'DELETE', bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
|
|
875
|
+
/* 분해 — 소속이 끊어진다(자식이 독립으로 남든 새로 등장하든). */
|
|
876
|
+
for (const c of children) {
|
|
877
|
+
const it = this.items.get(c);
|
|
878
|
+
if (it)
|
|
879
|
+
it.parent = undefined;
|
|
880
|
+
}
|
|
724
881
|
if (opts.materialize) {
|
|
725
882
|
const m = opts.materialize;
|
|
726
883
|
for (const c of children) {
|
|
@@ -789,8 +946,9 @@ export class FlowEngine {
|
|
|
789
946
|
intent: t.intent,
|
|
790
947
|
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
791
948
|
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
949
|
+
...(t.resources?.length ? { resources: t.resources.slice() } : {}),
|
|
792
950
|
...(t.durationMs ? { durationMs: t.durationMs } : {}),
|
|
793
|
-
...(inProgress ? { remainingMs: t.remainingMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
|
|
951
|
+
...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
|
|
794
952
|
});
|
|
795
953
|
}
|
|
796
954
|
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
@@ -800,8 +958,18 @@ export class FlowEngine {
|
|
|
800
958
|
emitPerson(p) {
|
|
801
959
|
this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined, ...(this.personOffShift(p) ? { offShift: true } : {}) });
|
|
802
960
|
}
|
|
803
|
-
|
|
804
|
-
|
|
961
|
+
/** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
|
|
962
|
+
* 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
|
|
963
|
+
emitMover(m, motion) {
|
|
964
|
+
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
|
|
965
|
+
}
|
|
966
|
+
/** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
|
|
967
|
+
emitOrder(o) {
|
|
968
|
+
this.emitOp(OP_EVENT.order, {
|
|
969
|
+
orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held,
|
|
970
|
+
...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {})
|
|
971
|
+
});
|
|
972
|
+
}
|
|
805
973
|
// ── 내부 mechanics ─────────────────────────────────────────────────────────
|
|
806
974
|
/**
|
|
807
975
|
* 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
|
|
@@ -892,8 +1060,14 @@ export class FlowEngine {
|
|
|
892
1060
|
continue;
|
|
893
1061
|
a.status = 'in-use';
|
|
894
1062
|
a.taskId = t.id;
|
|
895
|
-
if (t.itemEpc)
|
|
1063
|
+
if (t.itemEpc) {
|
|
896
1064
|
a.carrying = t.itemEpc;
|
|
1065
|
+
/* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
|
|
1066
|
+
* 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
|
|
1067
|
+
const it = this.items.get(t.itemEpc);
|
|
1068
|
+
if (it)
|
|
1069
|
+
it.carriedBy = a.id;
|
|
1070
|
+
}
|
|
897
1071
|
this.emitAsset(a);
|
|
898
1072
|
}
|
|
899
1073
|
}
|
|
@@ -909,6 +1083,11 @@ export class FlowEngine {
|
|
|
909
1083
|
a.status = 'idle';
|
|
910
1084
|
a.taskId = null;
|
|
911
1085
|
a.location = t.toNode || a.location;
|
|
1086
|
+
if (a.carrying) {
|
|
1087
|
+
const it = this.items.get(a.carrying);
|
|
1088
|
+
if (it)
|
|
1089
|
+
it.carriedBy = undefined;
|
|
1090
|
+
}
|
|
912
1091
|
a.carrying = undefined;
|
|
913
1092
|
this.emitAsset(a);
|
|
914
1093
|
}
|
|
@@ -939,6 +1118,31 @@ export class FlowEngine {
|
|
|
939
1118
|
}
|
|
940
1119
|
return picked;
|
|
941
1120
|
}
|
|
1121
|
+
/**
|
|
1122
|
+
* 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
|
|
1123
|
+
* 명세(`equipmentSpecification`)가 없으면 기존 거동: `resourceType` 한 대(없으면 아무 유휴 설비).
|
|
1124
|
+
* 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
|
|
1125
|
+
*/
|
|
1126
|
+
claimEquipment(t) {
|
|
1127
|
+
const free = (kind, picked = []) => [...this.movers.values()].filter(m => m.status === 'idle' && !m.held && !this.offShift(m) && !picked.includes(m.id) && (kind === undefined || m.kind === kind));
|
|
1128
|
+
const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
|
|
1129
|
+
if (!need?.length) {
|
|
1130
|
+
const one = free(t.resourceType)[0];
|
|
1131
|
+
return one ? [one.id] : null;
|
|
1132
|
+
}
|
|
1133
|
+
const picked = [];
|
|
1134
|
+
for (const req of need) {
|
|
1135
|
+
const want = Math.max(0, Math.floor(req.quantity ?? 0));
|
|
1136
|
+
if (!want)
|
|
1137
|
+
continue;
|
|
1138
|
+
const avail = free(req.equipmentClass, picked);
|
|
1139
|
+
if (avail.length < want)
|
|
1140
|
+
return null; // 한 등급이라도 모자라면 시작하지 않는다
|
|
1141
|
+
for (let i = 0; i < want; i++)
|
|
1142
|
+
picked.push(avail[i].id);
|
|
1143
|
+
}
|
|
1144
|
+
return picked.length ? picked : null;
|
|
1145
|
+
}
|
|
942
1146
|
/** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
|
|
943
1147
|
assignCrew(t, crew) {
|
|
944
1148
|
if (!crew.length)
|
|
@@ -1048,6 +1252,35 @@ export class FlowEngine {
|
|
|
1048
1252
|
}
|
|
1049
1253
|
}
|
|
1050
1254
|
}
|
|
1255
|
+
/**
|
|
1256
|
+
* 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
|
|
1257
|
+
*
|
|
1258
|
+
* 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
|
|
1259
|
+
* 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
|
|
1260
|
+
* 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
|
|
1261
|
+
*/
|
|
1262
|
+
itemState(i) {
|
|
1263
|
+
const parsedClass = i.gtin ? parseEpc(i.gtin) : undefined;
|
|
1264
|
+
const parsedSelf = parseEpc(i.epc);
|
|
1265
|
+
const lot = parsedClass?.lot ??
|
|
1266
|
+
parsedSelf.lot ??
|
|
1267
|
+
(typeof i.ilmd?.[ILMD_ATTR.lot] === 'string' ? i.ilmd[ILMD_ATTR.lot] : undefined);
|
|
1268
|
+
const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
|
|
1269
|
+
return {
|
|
1270
|
+
epc: i.epc,
|
|
1271
|
+
...(i.gtin ? { gtin: i.gtin } : {}),
|
|
1272
|
+
...(gtinKey ? { gtinKey } : {}),
|
|
1273
|
+
...(lot ? { lot } : {}),
|
|
1274
|
+
location: i.location,
|
|
1275
|
+
...(i.disposition ? { disposition: i.disposition } : {}),
|
|
1276
|
+
...(i.parent ? { parent: i.parent } : {}),
|
|
1277
|
+
...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
|
|
1278
|
+
...(i.qty !== undefined ? { qty: i.qty } : {}),
|
|
1279
|
+
...(i.uom ? { uom: i.uom } : {}),
|
|
1280
|
+
...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
|
|
1281
|
+
...(i.ilmd ? { ilmd: i.ilmd } : {})
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1051
1284
|
progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
|
|
1052
1285
|
generate() {
|
|
1053
1286
|
for (const g of this.gens) {
|
|
@@ -1080,8 +1313,19 @@ export class FlowEngine {
|
|
|
1080
1313
|
if (o.status === 'created' && !o.held)
|
|
1081
1314
|
this.allocate(o);
|
|
1082
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* 작업 진행 — **진행을 먼저, 배정을 나중에.**
|
|
1318
|
+
*
|
|
1319
|
+
* 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
|
|
1320
|
+
* 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
|
|
1321
|
+
* tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
|
|
1322
|
+
*/
|
|
1083
1323
|
processTasks(dt) {
|
|
1084
|
-
|
|
1324
|
+
this.advanceTasks(dt);
|
|
1325
|
+
this.assignTasks();
|
|
1326
|
+
}
|
|
1327
|
+
assignTasks() {
|
|
1328
|
+
// created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
|
|
1085
1329
|
for (const t of this.tasks.values()) {
|
|
1086
1330
|
if (t.status !== 'created')
|
|
1087
1331
|
continue;
|
|
@@ -1097,6 +1341,7 @@ export class FlowEngine {
|
|
|
1097
1341
|
if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
|
|
1098
1342
|
t.status = 'in-progress';
|
|
1099
1343
|
t.remainingMs = t.durationMs;
|
|
1344
|
+
t.startedAtSimMs = this.clockMs;
|
|
1100
1345
|
this.assignCrew(t, crew);
|
|
1101
1346
|
this.assignAssets(t, gear);
|
|
1102
1347
|
this.emitTask(t);
|
|
@@ -1106,10 +1351,12 @@ export class FlowEngine {
|
|
|
1106
1351
|
* 이 제약이 없으면 대기가 생기지 않아 병목이 사라지고 예측이 낙관 쪽으로 치우친다. */
|
|
1107
1352
|
if (this.stationFull(t.toNode))
|
|
1108
1353
|
continue;
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1354
|
+
/* 필요 설비 — 명세가 있으면 등급별 대수만큼(부분 확보 없이), 없으면 기존대로 한 대.
|
|
1355
|
+
* 확보 실패는 `null` — 다음 작업으로 넘어간다(다른 타입은 가용할 수 있다). */
|
|
1356
|
+
const rigs = this.claimEquipment(t);
|
|
1357
|
+
if (!rigs)
|
|
1358
|
+
continue;
|
|
1359
|
+
const mover = this.movers.get(rigs[0]);
|
|
1113
1360
|
// 체인지오버: task 의 changeoverKey 가 무버 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
|
|
1114
1361
|
if (t.setupMs && t.changeoverKey !== undefined && mover.lastChangeoverKey !== undefined && mover.lastChangeoverKey !== t.changeoverKey) {
|
|
1115
1362
|
t.appliedSetupMs = t.setupMs;
|
|
@@ -1117,11 +1364,17 @@ export class FlowEngine {
|
|
|
1117
1364
|
}
|
|
1118
1365
|
if (t.changeoverKey !== undefined)
|
|
1119
1366
|
mover.lastChangeoverKey = t.changeoverKey;
|
|
1120
|
-
|
|
1121
|
-
|
|
1367
|
+
for (const id of rigs) {
|
|
1368
|
+
const m = this.movers.get(id);
|
|
1369
|
+
m.status = 'busy';
|
|
1370
|
+
m.taskId = t.id;
|
|
1371
|
+
}
|
|
1122
1372
|
t.status = 'in-progress';
|
|
1123
1373
|
t.resource = mover.id;
|
|
1124
1374
|
t.remainingMs = t.durationMs;
|
|
1375
|
+
t.startedAtSimMs = this.clockMs;
|
|
1376
|
+
if (rigs.length > 1)
|
|
1377
|
+
t.resources = rigs.slice(); // 대표만으로는 함께 잡힌 설비가 사라진다
|
|
1125
1378
|
this.assignCrew(t, crew);
|
|
1126
1379
|
this.assignAssets(t, gear);
|
|
1127
1380
|
this.emitTask(t);
|
|
@@ -1131,7 +1384,9 @@ export class FlowEngine {
|
|
|
1131
1384
|
else
|
|
1132
1385
|
this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
|
|
1133
1386
|
}
|
|
1134
|
-
|
|
1387
|
+
}
|
|
1388
|
+
/** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
|
|
1389
|
+
advanceTasks(dt) {
|
|
1135
1390
|
for (const t of this.tasks.values()) {
|
|
1136
1391
|
if (t.status !== 'in-progress')
|
|
1137
1392
|
continue;
|
|
@@ -1157,6 +1412,21 @@ export class FlowEngine {
|
|
|
1157
1412
|
mover.taskId = null;
|
|
1158
1413
|
if (t.intent !== 'process')
|
|
1159
1414
|
mover.location = t.toNode; // 운반만 위치 이동; process 는 제자리
|
|
1415
|
+
/* 함께 잡힌 설비도 같은 규칙으로 놓아 준다 — 대표만 풀면 나머지가 영원히 묶인다. */
|
|
1416
|
+
for (const id of t.resources ?? []) {
|
|
1417
|
+
if (id === mover.id)
|
|
1418
|
+
continue;
|
|
1419
|
+
const m = this.movers.get(id);
|
|
1420
|
+
if (!m)
|
|
1421
|
+
continue;
|
|
1422
|
+
m.setupMs += setup;
|
|
1423
|
+
m.runMs += t.durationMs - setup;
|
|
1424
|
+
m.status = 'idle';
|
|
1425
|
+
m.taskId = null;
|
|
1426
|
+
if (t.intent !== 'process')
|
|
1427
|
+
m.location = t.toNode;
|
|
1428
|
+
this.emitMover(m);
|
|
1429
|
+
}
|
|
1160
1430
|
this.emitTask(t);
|
|
1161
1431
|
this.emitMover(mover);
|
|
1162
1432
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './domain-catalog.ts';
|
|
|
12
12
|
export * from './allocation-policy.ts';
|
|
13
13
|
export * from './duration-estimator.ts';
|
|
14
14
|
export * from './iso-duration.ts';
|
|
15
|
+
export * from './observed-reducer.ts';
|
|
15
16
|
export * from './state-projector.ts';
|
|
16
17
|
export * from './task-fold.ts';
|
|
17
18
|
export * from './face2-adapter.ts';
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./domain-catalog.js";
|
|
|
12
12
|
export * from "./allocation-policy.js";
|
|
13
13
|
export * from "./duration-estimator.js";
|
|
14
14
|
export * from "./iso-duration.js";
|
|
15
|
+
export * from "./observed-reducer.js";
|
|
15
16
|
export * from "./state-projector.js";
|
|
16
17
|
export * from "./task-fold.js";
|
|
17
18
|
export * from "./face2-adapter.js";
|
package/dist/kernel.js
CHANGED
|
@@ -33,7 +33,10 @@ export class WmsKernel extends FlowEngine {
|
|
|
33
33
|
const poTxn = [{ type: BTT.po, bizTransaction: po }];
|
|
34
34
|
// 로트 만료(FEFO 용, 결정적 — rng 무소비로 byte-identical 유지). 편차로 도착순≠만료순 → FEFO 가 유의미.
|
|
35
35
|
const expiry = this.clockMs + SHELF_MS - (this.epcSeq % 5) * SHELF_JITTER_MS;
|
|
36
|
-
|
|
36
|
+
/* 방출한 마스터데이터를 **상태에도 들고 있는다** — 자기가 선언한 것을 자기가 모르면, 미러는 알고
|
|
37
|
+
* 시뮬은 모르는 비대칭이 생긴다(파리티 테스트가 잡은 바로 그 종류). */
|
|
38
|
+
const ilmd = { [ILMD_ATTR.expiry]: expiry };
|
|
39
|
+
this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
|
|
37
40
|
dock.occupancy++;
|
|
38
41
|
this.emit(transactionEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
|
|
39
42
|
this.emit(aggregationEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
|
|
@@ -42,7 +45,7 @@ export class WmsKernel extends FlowEngine {
|
|
|
42
45
|
this.emit(objectEvent({
|
|
43
46
|
eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, disposition: DISP.in_progress,
|
|
44
47
|
epcList: [epc], quantityList: qtyList, readPoint: dock.id, bizLocation: dock.id, bizTransactionList: poTxn,
|
|
45
|
-
ilmd
|
|
48
|
+
ilmd
|
|
46
49
|
}));
|
|
47
50
|
const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews('storage') });
|
|
48
51
|
if (!binId)
|
|
@@ -115,13 +118,15 @@ export class WmsKernel extends FlowEngine {
|
|
|
115
118
|
.map(i => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
|
|
116
119
|
const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
|
|
117
120
|
for (const epc of chosen) {
|
|
118
|
-
this.items.get(epc).disposition = DISP.reserved;
|
|
119
121
|
o.allocated.push(epc);
|
|
120
122
|
chosenAll.push(epc);
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
125
|
if (chosenAll.length === 0)
|
|
124
126
|
return;
|
|
127
|
+
/* 할당은 아직 집은 것이 아니다 — 물건은 보관 자리에 있고 처분만 '예약' 으로 바뀐다.
|
|
128
|
+
* 그래서 단계는 storing 이다(피킹 관측은 실제로 집을 때 따로 난다). */
|
|
129
|
+
this.reserve(chosenAll, BIZSTEP.storing);
|
|
125
130
|
this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
|
|
126
131
|
for (const epc of chosenAll) {
|
|
127
132
|
const it = this.items.get(epc);
|
package/dist/mes-kernel.js
CHANGED
|
@@ -134,10 +134,9 @@ export class MesKernel extends FlowEngine {
|
|
|
134
134
|
return; // 부품 부족 → 대기
|
|
135
135
|
picks.push(...chosen);
|
|
136
136
|
}
|
|
137
|
-
for (const epc of picks)
|
|
138
|
-
this.items.get(epc).disposition = DISP.reserved;
|
|
137
|
+
for (const epc of picks)
|
|
139
138
|
o.allocated.push(epc);
|
|
140
|
-
|
|
139
|
+
this.reserve(picks, MES_BIZSTEP.producing);
|
|
141
140
|
this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
|
|
142
141
|
this.emitStation(o, s0, o.allocated[0], product.gtin);
|
|
143
142
|
o.status = 'op-' + s0.kind;
|
|
@@ -243,10 +242,9 @@ export class MesKernel extends FlowEngine {
|
|
|
243
242
|
return; // 자재 부족 → 대기
|
|
244
243
|
picks.push(...chosen);
|
|
245
244
|
}
|
|
246
|
-
for (const epc of picks)
|
|
247
|
-
this.items.get(epc).disposition = DISP.reserved;
|
|
245
|
+
for (const epc of picks)
|
|
248
246
|
o.allocated.push(epc);
|
|
249
|
-
|
|
247
|
+
this.reserve(picks, MES_BIZSTEP.producing);
|
|
250
248
|
this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
|
|
251
249
|
this.emitStationDef(o, ops[0], o.allocated[0]);
|
|
252
250
|
o.status = 'op-' + ops[0].key;
|