@operato/twin-kernel 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.d.ts +29 -1
- package/dist/contract.js +17 -0
- package/dist/flow-engine.d.ts +95 -1
- package/dist/flow-engine.js +215 -13
- 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 +374 -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 +253 -41
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -8,6 +8,20 @@ export interface CanonicalEnvelope<T = unknown> {
|
|
|
8
8
|
data: T;
|
|
9
9
|
}
|
|
10
10
|
export type TaskStatus = 'created' | 'assigned' | 'in-progress' | 'completed';
|
|
11
|
+
/**
|
|
12
|
+
* 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
|
|
13
|
+
*
|
|
14
|
+
* 예전에는 시뮬이 `'idle'` 로 두고 한 번도 바꾸지 않았고(변경 지점 0), 미러에는 노드 상태 채널이
|
|
15
|
+
* 없어 비어 있었다. 화면은 그 값을 그대로 보여 주고 있었다 — **정보처럼 보이는데 정보가 아니었다.**
|
|
16
|
+
*
|
|
17
|
+
* 문턱은 병목 주목(`deriveAttentions`)이 쓰는 것과 **같다**: 90% 이상이면 임박, 100% 이상이면 포화.
|
|
18
|
+
* 규칙이 둘이면 화면과 주목이 다른 말을 한다. 용량을 모르면 상태도 모른다(undefined — 꾸미지 않는다).
|
|
19
|
+
*/
|
|
20
|
+
export declare const NODE_SATURATION_NEAR = 0.9;
|
|
21
|
+
export declare function nodeStatusOf(n: {
|
|
22
|
+
occupancy?: number;
|
|
23
|
+
capacity?: number;
|
|
24
|
+
}): 'available' | 'near-full' | 'full' | undefined;
|
|
11
25
|
export interface NodeState {
|
|
12
26
|
id: string;
|
|
13
27
|
type: string;
|
|
@@ -29,6 +43,7 @@ export interface NodeState {
|
|
|
29
43
|
*/
|
|
30
44
|
parallelism?: number;
|
|
31
45
|
occupancy: number;
|
|
46
|
+
/** 포화도 파생 상태 — `nodeStatusOf` 가 낸다(두 구동이 같은 함수를 쓴다). 용량 미상이면 없다. */
|
|
32
47
|
status?: string;
|
|
33
48
|
parentId?: string;
|
|
34
49
|
/**
|
|
@@ -182,9 +197,18 @@ export interface TaskState {
|
|
|
182
197
|
resourceRef?: string;
|
|
183
198
|
orderId?: string;
|
|
184
199
|
progress?: number;
|
|
185
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* 남은 시간·총 소요(ms) — 진행 중인 작업을 이어서 굴리는 데 필요(씨앗의 충실도).
|
|
202
|
+
* `remainingMs` 는 **마지막 전이 시점의 값**이다. 델타는 매 tick 오지 않으므로(설계) 미러가 든 값은
|
|
203
|
+
* 그때의 것이고, 지금 값은 `startedAtSimMs` 로 보간한다 — 모션(MoverMotion)과 같은 규율.
|
|
204
|
+
*/
|
|
186
205
|
remainingMs?: number;
|
|
187
206
|
durationMs?: number;
|
|
207
|
+
/**
|
|
208
|
+
* 착수 시각(절대 sim-clock) — **보간 앵커.** 이것이 없으면 미러는 "그때 얼마 남았었나" 만 알고
|
|
209
|
+
* "지금 얼마 남았나" 를 못 낸다. progress = (now − startedAtSimMs) / durationMs.
|
|
210
|
+
*/
|
|
211
|
+
startedAtSimMs?: number;
|
|
188
212
|
/** 작업 의도 — 무자원이 설계인지(체류) 기록 누락인지 구별하는 근거. */
|
|
189
213
|
intent?: 'transport' | 'process' | 'dwell';
|
|
190
214
|
/**
|
|
@@ -380,6 +404,8 @@ export interface QualityDelta {
|
|
|
380
404
|
}
|
|
381
405
|
export interface TaskStatusDelta {
|
|
382
406
|
taskId: string;
|
|
407
|
+
/** 착수 시각(절대 sim-clock) — 보간 앵커. TaskState 와 같은 뜻. */
|
|
408
|
+
startedAtSimMs?: number;
|
|
383
409
|
/** 투입된 사람들 — 미러가 인원 배정을 그대로 비추려면 델타에 실려야 한다. */
|
|
384
410
|
personnel?: string[];
|
|
385
411
|
/** 투입된 물리 자산(팔레트 등). */
|
|
@@ -414,6 +440,8 @@ export interface EquipmentStatusDelta {
|
|
|
414
440
|
kind: string;
|
|
415
441
|
status: string;
|
|
416
442
|
location?: string;
|
|
443
|
+
/** 지금 붙어 있는 작업 — 사람·자산 델타와 같은 자리. 없으면 미러가 작업↔자원 연결을 모른다. */
|
|
444
|
+
taskId?: string;
|
|
417
445
|
motion?: MoverMotion;
|
|
418
446
|
}
|
|
419
447
|
/** 관측된 오더 라인(SKU 데맨드) — 실 시스템 오더는 품목 라인을 가짐. 이행 예측(남은 데맨드 재계획)에 필요. */
|
package/dist/contract.js
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
* Face 1 — 3채널 계약 (walking skeleton 범위).
|
|
3
3
|
* 설계 SoT: operato-twin/design/integration/face1-contract.md
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
|
|
7
|
+
*
|
|
8
|
+
* 예전에는 시뮬이 `'idle'` 로 두고 한 번도 바꾸지 않았고(변경 지점 0), 미러에는 노드 상태 채널이
|
|
9
|
+
* 없어 비어 있었다. 화면은 그 값을 그대로 보여 주고 있었다 — **정보처럼 보이는데 정보가 아니었다.**
|
|
10
|
+
*
|
|
11
|
+
* 문턱은 병목 주목(`deriveAttentions`)이 쓰는 것과 **같다**: 90% 이상이면 임박, 100% 이상이면 포화.
|
|
12
|
+
* 규칙이 둘이면 화면과 주목이 다른 말을 한다. 용량을 모르면 상태도 모른다(undefined — 꾸미지 않는다).
|
|
13
|
+
*/
|
|
14
|
+
export const NODE_SATURATION_NEAR = 0.9;
|
|
15
|
+
export function nodeStatusOf(n) {
|
|
16
|
+
const cap = n.capacity;
|
|
17
|
+
if (!(typeof cap === 'number' && cap > 0))
|
|
18
|
+
return undefined;
|
|
19
|
+
const r = (n.occupancy ?? 0) / cap;
|
|
20
|
+
return r >= 1 ? 'full' : r >= NODE_SATURATION_NEAR ? 'near-full' : 'available';
|
|
21
|
+
}
|
|
5
22
|
// ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
|
|
6
23
|
// EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·movers(equipment)·orders 의 운영 상태는
|
|
7
24
|
// 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attention, BoardDef, Command, CommandAck, EventHandler, MoverMotion, OeeMetrics, AssetState, GeneratorSpec, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, NodeState, ItemState, MoverState, OrderStatusDelta, TaskState } from './contract.ts';
|
|
1
|
+
import type { Attention, BoardDef, CanonicalEnvelope, Command, CommandAck, EventHandler, MoverMotion, OeeMetrics, AssetState, GeneratorSpec, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, NodeState, ItemState, MoverState, OrderStatusDelta, TaskState } from './contract.ts';
|
|
2
2
|
import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
|
|
3
3
|
import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
|
|
4
4
|
import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
|
|
@@ -12,13 +12,29 @@ export interface FlowNode {
|
|
|
12
12
|
status: string;
|
|
13
13
|
parentId?: string;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* 시뮬이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
|
|
17
|
+
*
|
|
18
|
+
* 미러(투영기)는 로트·단위·소속·마스터데이터까지 들고 있는데 시뮬은 여섯 필드뿐이었다. 그래서 같은
|
|
19
|
+
* 사실을 두 구동이 다르게 말했다(파리티 테스트가 잡았다). 여기서 갖는 것은 **보유해야 하는 상태**다 —
|
|
20
|
+
* 품번 키·로트처럼 식별자에서 순수하게 나오는 값은 저장하지 않고 스냅샷에서 파생한다(둘을 다 저장하면
|
|
21
|
+
* 어긋날 수 있다).
|
|
22
|
+
*/
|
|
15
23
|
export interface FlowItem {
|
|
16
24
|
epc: string;
|
|
17
25
|
location: string;
|
|
18
26
|
disposition: string;
|
|
19
27
|
gtin?: string;
|
|
20
28
|
qty?: number;
|
|
29
|
+
/** 수량 단위(UN/CEFACT). 개수면 없다 — 없는 것을 'EA' 로 꾸미지 않는다. */
|
|
30
|
+
uom?: string;
|
|
31
|
+
/** 소속 물류단위(팔레트 SSCC 등) — 조립으로 맺어진다. 3D 적재 표현의 재료. */
|
|
32
|
+
parent?: string;
|
|
33
|
+
/** 이 물류단위를 싣고 있는 반복사용 자산(GRAI) — `parent` 와 다른 축. */
|
|
34
|
+
carriedBy?: string;
|
|
21
35
|
expiry?: number;
|
|
36
|
+
/** 개체·로트 마스터데이터 원문 — 생겨날 때 정해지고 뒤 이벤트가 지우지 않는다. */
|
|
37
|
+
ilmd?: Record<string, unknown>;
|
|
22
38
|
}
|
|
23
39
|
/** 물리 자산 — 반복사용(팔레트·랙·용기). 설비도 물품도 아니다(GRAI vs SSCC 구분은 계약 주석 참조). */
|
|
24
40
|
export interface FlowAsset {
|
|
@@ -73,6 +89,8 @@ export interface FlowTask {
|
|
|
73
89
|
fromNode: string;
|
|
74
90
|
toNode: string;
|
|
75
91
|
resource: string | null;
|
|
92
|
+
/** 착수 시각(절대 sim-clock) — 보간 앵커(모션과 같은 규율). */
|
|
93
|
+
startedAtSimMs?: number;
|
|
76
94
|
/** 이 작업에 투입된 사람들 — 설비와 별개 축(설비 1대 + 작업자 2명이 동시에 잡힌다). */
|
|
77
95
|
personnel?: string[];
|
|
78
96
|
/** 이 작업에 투입된 물리 자산(팔레트 등). */
|
|
@@ -178,6 +196,13 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
178
196
|
* 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
|
|
179
197
|
*/
|
|
180
198
|
protected operationSpecs: Map<string, OperationDef>;
|
|
199
|
+
/** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
|
|
200
|
+
private observer?;
|
|
201
|
+
/** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
|
|
202
|
+
private observedDirty;
|
|
203
|
+
private observeMode;
|
|
204
|
+
/** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
|
|
205
|
+
protected boardDef?: BoardDef;
|
|
181
206
|
/** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
|
|
182
207
|
private specUse;
|
|
183
208
|
protected epcSeq: number;
|
|
@@ -185,6 +210,8 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
185
210
|
protected orderSeq: number;
|
|
186
211
|
protected soSeq: number;
|
|
187
212
|
private handlers;
|
|
213
|
+
/** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
|
|
214
|
+
private handlersRef;
|
|
188
215
|
private gens;
|
|
189
216
|
private generating;
|
|
190
217
|
private speed;
|
|
@@ -260,6 +287,29 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
260
287
|
fork(tenantId?: string): this;
|
|
261
288
|
protected now(): string;
|
|
262
289
|
protected randInt(min: number, max: number): number;
|
|
290
|
+
/**
|
|
291
|
+
* 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
|
|
292
|
+
*
|
|
293
|
+
* 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
|
|
294
|
+
* 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
|
|
295
|
+
* 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
|
|
296
|
+
*
|
|
297
|
+
* 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
|
|
298
|
+
* (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
|
|
299
|
+
* 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
|
|
300
|
+
*
|
|
301
|
+
* **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
|
|
302
|
+
* reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
|
|
303
|
+
*
|
|
304
|
+
* `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
|
|
305
|
+
*/
|
|
306
|
+
apply(envelope: CanonicalEnvelope): void;
|
|
307
|
+
/** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
|
|
308
|
+
private settleObserved;
|
|
309
|
+
/** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
|
|
310
|
+
private observedHandlers;
|
|
311
|
+
/** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
|
|
312
|
+
get observing(): boolean;
|
|
263
313
|
/**
|
|
264
314
|
* 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
|
|
265
315
|
* 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
|
|
@@ -335,6 +385,30 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
335
385
|
bizLocation?: string;
|
|
336
386
|
bizTransactionList?: BizTransactionElement[];
|
|
337
387
|
}): void;
|
|
388
|
+
/**
|
|
389
|
+
* 할당(예약) — **처분 변화를 이벤트로 낸다.**
|
|
390
|
+
*
|
|
391
|
+
* 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
|
|
392
|
+
* 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
|
|
393
|
+
* 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
|
|
394
|
+
*
|
|
395
|
+
* 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
|
|
396
|
+
* 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
397
|
+
*
|
|
398
|
+
* `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
|
|
399
|
+
* 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
|
|
400
|
+
*/
|
|
401
|
+
protected reserve(epcs: string[], bizStep: string): void;
|
|
402
|
+
/**
|
|
403
|
+
* 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
|
|
404
|
+
*
|
|
405
|
+
* 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
|
|
406
|
+
* 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
|
|
407
|
+
*
|
|
408
|
+
* 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
409
|
+
* 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
|
|
410
|
+
*/
|
|
411
|
+
protected observeDisposition(epcs: string[], disposition: string, bizStep: string, at?: string): void;
|
|
338
412
|
/**
|
|
339
413
|
* containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
|
|
340
414
|
* consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
|
|
@@ -382,6 +456,8 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
382
456
|
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
383
457
|
protected emitAsset(a: FlowAsset): void;
|
|
384
458
|
protected emitPerson(p: FlowPerson): void;
|
|
459
|
+
/** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
|
|
460
|
+
* 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
|
|
385
461
|
protected emitMover(m: FlowMover, motion?: MoverMotion): void;
|
|
386
462
|
protected emitOrder(o: FlowOrder): void;
|
|
387
463
|
/**
|
|
@@ -460,9 +536,27 @@ export declare abstract class FlowEngine implements TwinKernel {
|
|
|
460
536
|
* down 중 무버는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
|
|
461
537
|
*/
|
|
462
538
|
private processFailures;
|
|
539
|
+
/**
|
|
540
|
+
* 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
|
|
541
|
+
*
|
|
542
|
+
* 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
|
|
543
|
+
* 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
|
|
544
|
+
* 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
|
|
545
|
+
*/
|
|
546
|
+
protected itemState(i: FlowItem): ItemState;
|
|
463
547
|
protected progressOf(t: FlowTask): number;
|
|
464
548
|
private generate;
|
|
465
549
|
private processOrders;
|
|
550
|
+
/**
|
|
551
|
+
* 작업 진행 — **진행을 먼저, 배정을 나중에.**
|
|
552
|
+
*
|
|
553
|
+
* 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
|
|
554
|
+
* 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
|
|
555
|
+
* tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
|
|
556
|
+
*/
|
|
466
557
|
private processTasks;
|
|
558
|
+
private assignTasks;
|
|
559
|
+
/** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
|
|
560
|
+
private advanceTasks;
|
|
467
561
|
}
|
|
468
562
|
export {};
|
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
|
|
@@ -434,13 +449,21 @@ export class FlowEngine {
|
|
|
434
449
|
this.processTasks(dt);
|
|
435
450
|
}
|
|
436
451
|
getSnapshot() {
|
|
452
|
+
this.settleObserved();
|
|
437
453
|
return {
|
|
438
454
|
revision: this.revision,
|
|
439
455
|
simClockMs: this.clockMs,
|
|
440
|
-
|
|
441
|
-
|
|
456
|
+
/* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
|
|
457
|
+
* 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
|
|
458
|
+
nodes: [...this.nodes.values()].map(n => {
|
|
459
|
+
const { status, ...rest } = n;
|
|
460
|
+
/* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
|
|
461
|
+
const derived = nodeStatusOf(n);
|
|
462
|
+
return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
|
|
463
|
+
}),
|
|
464
|
+
items: [...this.items.values()].map(i => this.itemState(i)),
|
|
442
465
|
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 } : {}) };
|
|
466
|
+
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
467
|
const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
|
|
445
468
|
if (t && t.status === 'in-progress' && t.intent !== 'process')
|
|
446
469
|
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,7 +481,18 @@ export class FlowEngine {
|
|
|
458
481
|
st.offShift = true;
|
|
459
482
|
return st;
|
|
460
483
|
}),
|
|
461
|
-
|
|
484
|
+
/* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
|
|
485
|
+
* 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
|
|
486
|
+
* 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
|
|
487
|
+
tasks: [...this.tasks.values()].map(t => ({
|
|
488
|
+
id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
|
|
489
|
+
fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
|
|
490
|
+
...(t.intent ? { intent: t.intent } : {}),
|
|
491
|
+
...(t.durationMs ? { durationMs: t.durationMs } : {}),
|
|
492
|
+
...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
|
|
493
|
+
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
494
|
+
...(t.assets?.length ? { assets: t.assets.slice() } : {})
|
|
495
|
+
})),
|
|
462
496
|
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 })),
|
|
463
497
|
attentions: this.computeAttentions()
|
|
464
498
|
};
|
|
@@ -485,6 +519,7 @@ export class FlowEngine {
|
|
|
485
519
|
* rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
|
|
486
520
|
*/
|
|
487
521
|
fork(tenantId = this.tenantId) {
|
|
522
|
+
this.settleObserved(); // 관측으로 굴러온 커널을 fork 하려면 먼저 상태로 옮겨야 한다
|
|
488
523
|
const Ctor = this.constructor;
|
|
489
524
|
const clone = new Ctor(tenantId, this.policy);
|
|
490
525
|
// 상태·시나리오(gens/generating)·시퀀스 전부 복제 → 원본의 완전한 continuation.
|
|
@@ -510,6 +545,54 @@ export class FlowEngine {
|
|
|
510
545
|
// ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
|
|
511
546
|
now() { return new Date(BASE_EPOCH + this.clockMs).toISOString(); }
|
|
512
547
|
randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
|
|
548
|
+
/**
|
|
549
|
+
* 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
|
|
550
|
+
*
|
|
551
|
+
* 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
|
|
552
|
+
* 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
|
|
553
|
+
* 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
|
|
554
|
+
*
|
|
555
|
+
* 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
|
|
556
|
+
* (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
|
|
557
|
+
* 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
|
|
558
|
+
*
|
|
559
|
+
* **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
|
|
560
|
+
* reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
|
|
561
|
+
*
|
|
562
|
+
* `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
|
|
563
|
+
*/
|
|
564
|
+
apply(envelope) {
|
|
565
|
+
if (!this.observer) {
|
|
566
|
+
this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
|
|
567
|
+
this.observeMode = true;
|
|
568
|
+
}
|
|
569
|
+
this.observer.apply(envelope);
|
|
570
|
+
/* **구독자에게 그대로 흘린다** — 호스트가 시뮬·관측 두 모드에서 같은 배선을 쓰게 하기 위해서다
|
|
571
|
+
* (`onEvent` 하나로 저널·방송이 붙는다). 관측 모드에서 이것은 **재방출**이지 새 사실이 아니다:
|
|
572
|
+
* 원천이 이미 그 이벤트를 갖고 있으므로, 호스트가 인입과 재방출을 **둘 다 저널에 적으면 중복**이
|
|
573
|
+
* 된다. 저널은 인입에서 한 번만 적는다. */
|
|
574
|
+
for (const h of this.observedHandlers())
|
|
575
|
+
h(envelope);
|
|
576
|
+
/* **이벤트마다 상태를 통째로 옮기지 않는다.** 옮기는 비용은 O(상태 크기)라, 이벤트 하나에 그것을
|
|
577
|
+
* 치르면 유입이 늘수록 감당이 안 된다. 필요해지는 순간(스냅샷·fork)에 **한 번만** 옮긴다. */
|
|
578
|
+
this.observedDirty = true;
|
|
579
|
+
this.revision++;
|
|
580
|
+
}
|
|
581
|
+
/** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
|
|
582
|
+
settleObserved() {
|
|
583
|
+
if (!this.observedDirty || !this.observer)
|
|
584
|
+
return;
|
|
585
|
+
this.observedDirty = false;
|
|
586
|
+
this.hydrateObserved(this.observer.snapshot());
|
|
587
|
+
}
|
|
588
|
+
/** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
|
|
589
|
+
observedHandlers() {
|
|
590
|
+
return this.handlersRef();
|
|
591
|
+
}
|
|
592
|
+
/** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
|
|
593
|
+
get observing() {
|
|
594
|
+
return this.observeMode;
|
|
595
|
+
}
|
|
513
596
|
/**
|
|
514
597
|
* 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
|
|
515
598
|
* 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
|
|
@@ -694,6 +777,52 @@ export class FlowEngine {
|
|
|
694
777
|
bizTransactionList: opts.bizTransactionList
|
|
695
778
|
}));
|
|
696
779
|
}
|
|
780
|
+
/**
|
|
781
|
+
* 할당(예약) — **처분 변화를 이벤트로 낸다.**
|
|
782
|
+
*
|
|
783
|
+
* 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
|
|
784
|
+
* 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
|
|
785
|
+
* 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
|
|
786
|
+
*
|
|
787
|
+
* 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
|
|
788
|
+
* 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
789
|
+
*
|
|
790
|
+
* `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
|
|
791
|
+
* 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
|
|
792
|
+
*/
|
|
793
|
+
reserve(epcs, bizStep) {
|
|
794
|
+
this.observeDisposition(epcs, DISP.reserved, bizStep);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
|
|
798
|
+
*
|
|
799
|
+
* 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
|
|
800
|
+
* 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
|
|
801
|
+
*
|
|
802
|
+
* 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
|
|
803
|
+
* 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
|
|
804
|
+
*/
|
|
805
|
+
observeDisposition(epcs, disposition, bizStep, at) {
|
|
806
|
+
const byLocation = new Map();
|
|
807
|
+
for (const epc of epcs) {
|
|
808
|
+
const it = this.items.get(epc);
|
|
809
|
+
if (!it || it.disposition === disposition)
|
|
810
|
+
continue;
|
|
811
|
+
it.disposition = disposition;
|
|
812
|
+
const where = at ?? it.location ?? '';
|
|
813
|
+
const bin = byLocation.get(where);
|
|
814
|
+
if (bin)
|
|
815
|
+
bin.push(epc);
|
|
816
|
+
else
|
|
817
|
+
byLocation.set(where, [epc]);
|
|
818
|
+
}
|
|
819
|
+
for (const [where, list] of byLocation) {
|
|
820
|
+
this.emit(objectEvent({
|
|
821
|
+
eventTime: this.now(), action: 'OBSERVE', bizStep, disposition,
|
|
822
|
+
epcList: list, ...(where ? { readPoint: where, bizLocation: where } : {})
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
}
|
|
697
826
|
/**
|
|
698
827
|
* containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
|
|
699
828
|
* consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
|
|
@@ -712,6 +841,14 @@ export class FlowEngine {
|
|
|
712
841
|
this.items.delete(c);
|
|
713
842
|
}
|
|
714
843
|
}
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
/* 흡수하지 않는 조립 — 자식은 독립 물품으로 남되 **소속을 상태에도 남긴다.**
|
|
847
|
+
* 예전에는 이벤트만 내고 상태를 안 바꿔서, 미러는 소속을 알고 시뮬은 몰랐다(같은 사실, 다른 답). */
|
|
848
|
+
for (const c of children) {
|
|
849
|
+
const it = this.items.get(c);
|
|
850
|
+
if (it)
|
|
851
|
+
it.parent = parent;
|
|
715
852
|
}
|
|
716
853
|
}
|
|
717
854
|
/**
|
|
@@ -721,6 +858,12 @@ export class FlowEngine {
|
|
|
721
858
|
*/
|
|
722
859
|
disaggregate(parent, children, opts) {
|
|
723
860
|
this.emit(aggregationEvent({ eventTime: this.now(), action: 'DELETE', bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
|
|
861
|
+
/* 분해 — 소속이 끊어진다(자식이 독립으로 남든 새로 등장하든). */
|
|
862
|
+
for (const c of children) {
|
|
863
|
+
const it = this.items.get(c);
|
|
864
|
+
if (it)
|
|
865
|
+
it.parent = undefined;
|
|
866
|
+
}
|
|
724
867
|
if (opts.materialize) {
|
|
725
868
|
const m = opts.materialize;
|
|
726
869
|
for (const c of children) {
|
|
@@ -790,7 +933,7 @@ export class FlowEngine {
|
|
|
790
933
|
...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
|
|
791
934
|
...(t.assets?.length ? { assets: t.assets.slice() } : {}),
|
|
792
935
|
...(t.durationMs ? { durationMs: t.durationMs } : {}),
|
|
793
|
-
...(inProgress ? { remainingMs: t.remainingMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
|
|
936
|
+
...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
|
|
794
937
|
});
|
|
795
938
|
}
|
|
796
939
|
/** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
|
|
@@ -800,7 +943,11 @@ export class FlowEngine {
|
|
|
800
943
|
emitPerson(p) {
|
|
801
944
|
this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined, ...(this.personOffShift(p) ? { offShift: true } : {}) });
|
|
802
945
|
}
|
|
803
|
-
|
|
946
|
+
/** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
|
|
947
|
+
* 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
|
|
948
|
+
emitMover(m, motion) {
|
|
949
|
+
this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
|
|
950
|
+
}
|
|
804
951
|
emitOrder(o) { this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held }); }
|
|
805
952
|
// ── 내부 mechanics ─────────────────────────────────────────────────────────
|
|
806
953
|
/**
|
|
@@ -892,8 +1039,14 @@ export class FlowEngine {
|
|
|
892
1039
|
continue;
|
|
893
1040
|
a.status = 'in-use';
|
|
894
1041
|
a.taskId = t.id;
|
|
895
|
-
if (t.itemEpc)
|
|
1042
|
+
if (t.itemEpc) {
|
|
896
1043
|
a.carrying = t.itemEpc;
|
|
1044
|
+
/* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
|
|
1045
|
+
* 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
|
|
1046
|
+
const it = this.items.get(t.itemEpc);
|
|
1047
|
+
if (it)
|
|
1048
|
+
it.carriedBy = a.id;
|
|
1049
|
+
}
|
|
897
1050
|
this.emitAsset(a);
|
|
898
1051
|
}
|
|
899
1052
|
}
|
|
@@ -909,6 +1062,11 @@ export class FlowEngine {
|
|
|
909
1062
|
a.status = 'idle';
|
|
910
1063
|
a.taskId = null;
|
|
911
1064
|
a.location = t.toNode || a.location;
|
|
1065
|
+
if (a.carrying) {
|
|
1066
|
+
const it = this.items.get(a.carrying);
|
|
1067
|
+
if (it)
|
|
1068
|
+
it.carriedBy = undefined;
|
|
1069
|
+
}
|
|
912
1070
|
a.carrying = undefined;
|
|
913
1071
|
this.emitAsset(a);
|
|
914
1072
|
}
|
|
@@ -1048,6 +1206,35 @@ export class FlowEngine {
|
|
|
1048
1206
|
}
|
|
1049
1207
|
}
|
|
1050
1208
|
}
|
|
1209
|
+
/**
|
|
1210
|
+
* 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
|
|
1211
|
+
*
|
|
1212
|
+
* 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
|
|
1213
|
+
* 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
|
|
1214
|
+
* 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
|
|
1215
|
+
*/
|
|
1216
|
+
itemState(i) {
|
|
1217
|
+
const parsedClass = i.gtin ? parseEpc(i.gtin) : undefined;
|
|
1218
|
+
const parsedSelf = parseEpc(i.epc);
|
|
1219
|
+
const lot = parsedClass?.lot ??
|
|
1220
|
+
parsedSelf.lot ??
|
|
1221
|
+
(typeof i.ilmd?.[ILMD_ATTR.lot] === 'string' ? i.ilmd[ILMD_ATTR.lot] : undefined);
|
|
1222
|
+
const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
|
|
1223
|
+
return {
|
|
1224
|
+
epc: i.epc,
|
|
1225
|
+
...(i.gtin ? { gtin: i.gtin } : {}),
|
|
1226
|
+
...(gtinKey ? { gtinKey } : {}),
|
|
1227
|
+
...(lot ? { lot } : {}),
|
|
1228
|
+
location: i.location,
|
|
1229
|
+
...(i.disposition ? { disposition: i.disposition } : {}),
|
|
1230
|
+
...(i.parent ? { parent: i.parent } : {}),
|
|
1231
|
+
...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
|
|
1232
|
+
...(i.qty !== undefined ? { qty: i.qty } : {}),
|
|
1233
|
+
...(i.uom ? { uom: i.uom } : {}),
|
|
1234
|
+
...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
|
|
1235
|
+
...(i.ilmd ? { ilmd: i.ilmd } : {})
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1051
1238
|
progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
|
|
1052
1239
|
generate() {
|
|
1053
1240
|
for (const g of this.gens) {
|
|
@@ -1080,8 +1267,19 @@ export class FlowEngine {
|
|
|
1080
1267
|
if (o.status === 'created' && !o.held)
|
|
1081
1268
|
this.allocate(o);
|
|
1082
1269
|
}
|
|
1270
|
+
/**
|
|
1271
|
+
* 작업 진행 — **진행을 먼저, 배정을 나중에.**
|
|
1272
|
+
*
|
|
1273
|
+
* 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
|
|
1274
|
+
* 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
|
|
1275
|
+
* tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
|
|
1276
|
+
*/
|
|
1083
1277
|
processTasks(dt) {
|
|
1084
|
-
|
|
1278
|
+
this.advanceTasks(dt);
|
|
1279
|
+
this.assignTasks();
|
|
1280
|
+
}
|
|
1281
|
+
assignTasks() {
|
|
1282
|
+
// created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
|
|
1085
1283
|
for (const t of this.tasks.values()) {
|
|
1086
1284
|
if (t.status !== 'created')
|
|
1087
1285
|
continue;
|
|
@@ -1097,6 +1295,7 @@ export class FlowEngine {
|
|
|
1097
1295
|
if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
|
|
1098
1296
|
t.status = 'in-progress';
|
|
1099
1297
|
t.remainingMs = t.durationMs;
|
|
1298
|
+
t.startedAtSimMs = this.clockMs;
|
|
1100
1299
|
this.assignCrew(t, crew);
|
|
1101
1300
|
this.assignAssets(t, gear);
|
|
1102
1301
|
this.emitTask(t);
|
|
@@ -1122,6 +1321,7 @@ export class FlowEngine {
|
|
|
1122
1321
|
t.status = 'in-progress';
|
|
1123
1322
|
t.resource = mover.id;
|
|
1124
1323
|
t.remainingMs = t.durationMs;
|
|
1324
|
+
t.startedAtSimMs = this.clockMs;
|
|
1125
1325
|
this.assignCrew(t, crew);
|
|
1126
1326
|
this.assignAssets(t, gear);
|
|
1127
1327
|
this.emitTask(t);
|
|
@@ -1131,7 +1331,9 @@ export class FlowEngine {
|
|
|
1131
1331
|
else
|
|
1132
1332
|
this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
|
|
1133
1333
|
}
|
|
1134
|
-
|
|
1334
|
+
}
|
|
1335
|
+
/** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
|
|
1336
|
+
advanceTasks(dt) {
|
|
1135
1337
|
for (const t of this.tasks.values()) {
|
|
1136
1338
|
if (t.status !== 'in-progress')
|
|
1137
1339
|
continue;
|
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";
|