@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { AssetState, BoardDef, CanonicalEnvelope, NodeState, ItemState, MoverState, PersonState, TaskState, OrderState } from './contract.ts';
|
|
2
|
+
/**
|
|
3
|
+
* 마스터 동기 — 선언적 로케이션 upsert/remove.
|
|
4
|
+
*
|
|
5
|
+
* 구조(로케이션·용량·구역 소속)는 이벤트가 말해 주는 것이 아니라 **원 시스템의 마스터**에서 온다.
|
|
6
|
+
* 최초 생성은 Face2 마스터 인제스트(host `ingestMaster`)가 하고, 그 뒤 현장이 바뀐 것(랙 증설·구역
|
|
7
|
+
* 재편·용량 변경)은 이 경로로 **기동 중에도** 반영된다 — 없으면 재기동해야 구조가 갱신된다.
|
|
8
|
+
*/
|
|
9
|
+
export interface MasterUpdate {
|
|
10
|
+
op: 'upsert' | 'remove';
|
|
11
|
+
node: {
|
|
12
|
+
id: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
capacity?: number;
|
|
15
|
+
parallelism?: number;
|
|
16
|
+
parentId?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface ProjectedState {
|
|
20
|
+
revision: number;
|
|
21
|
+
/**
|
|
22
|
+
* 받은 정정 선언들 — 상태에 반영하지 않은 것을 **밝힌다**.
|
|
23
|
+
* 비어 있지 않으면 "원 시스템이 정정을 보냈고 우리는 아직 반영하지 못했다" 는 뜻이다(조용한 무시 금지).
|
|
24
|
+
*/
|
|
25
|
+
corrections?: {
|
|
26
|
+
declaredAt: string;
|
|
27
|
+
reason?: string;
|
|
28
|
+
correctiveEventIDs: string[];
|
|
29
|
+
eventID?: string;
|
|
30
|
+
}[];
|
|
31
|
+
nodes: NodeState[];
|
|
32
|
+
items: ItemState[];
|
|
33
|
+
/** 사람 — 등급·교대·투입. 인원을 선언하지 않은 트윈에서는 빈 배열. */
|
|
34
|
+
persons: PersonState[];
|
|
35
|
+
/** 물리 자산(반복사용) — 자산을 선언하지 않은 트윈에서는 빈 배열. */
|
|
36
|
+
assets: AssetState[];
|
|
37
|
+
tasks: TaskState[];
|
|
38
|
+
movers: MoverState[];
|
|
39
|
+
orders: OrderState[];
|
|
40
|
+
}
|
|
41
|
+
export declare class ObservedReducer {
|
|
42
|
+
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
43
|
+
private master;
|
|
44
|
+
private items;
|
|
45
|
+
private aggregation;
|
|
46
|
+
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
47
|
+
private pendingParent;
|
|
48
|
+
private tasks;
|
|
49
|
+
private movers;
|
|
50
|
+
private persons;
|
|
51
|
+
private assets;
|
|
52
|
+
private orders;
|
|
53
|
+
revision: number;
|
|
54
|
+
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
55
|
+
private corrections;
|
|
56
|
+
constructor(board: BoardDef);
|
|
57
|
+
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
58
|
+
applyMaster(u: MasterUpdate): void;
|
|
59
|
+
/**
|
|
60
|
+
* 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
|
|
61
|
+
*
|
|
62
|
+
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
|
|
63
|
+
* 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
|
|
64
|
+
* 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
|
|
65
|
+
* 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
|
|
66
|
+
*
|
|
67
|
+
* 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
|
|
68
|
+
* (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
|
|
69
|
+
*/
|
|
70
|
+
private touchLocation;
|
|
71
|
+
/**
|
|
72
|
+
* 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
|
|
73
|
+
*
|
|
74
|
+
* 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
|
|
75
|
+
* 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
|
|
76
|
+
* 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
|
|
77
|
+
*
|
|
78
|
+
* 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
|
|
79
|
+
* 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
|
|
80
|
+
*/
|
|
81
|
+
private stale;
|
|
82
|
+
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
83
|
+
private lastAt;
|
|
84
|
+
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
85
|
+
apply(e: CanonicalEnvelope): void;
|
|
86
|
+
private applyEpcis;
|
|
87
|
+
/**
|
|
88
|
+
* 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
|
|
89
|
+
* 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
|
|
93
|
+
*
|
|
94
|
+
* 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
|
|
95
|
+
* `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
|
|
96
|
+
*/
|
|
97
|
+
private expiryOf;
|
|
98
|
+
private mergeItem;
|
|
99
|
+
/** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
|
|
100
|
+
private remove;
|
|
101
|
+
/** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
|
|
102
|
+
snapshot(): ProjectedState;
|
|
103
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Observed Reducer — **이벤트를 접어 상태를 만드는 단 하나의 규칙.**
|
|
3
|
+
*
|
|
4
|
+
* 이 파일은 원래 미러 전용(`StateProjector`)이었다. 그런데 커널도 이벤트로 굴러갈 수 있게 되면서
|
|
5
|
+
* (`FlowEngine.apply`, 통합 P0) **두 구동이 같은 규칙을 써야** 한다 — 규칙이 두 벌이면 반드시
|
|
6
|
+
* 갈라진다(2026-08-01 하루에 아홉 곳, 적합성 하네스가 그 뒤 넷 더). 그래서 이름을 규칙 쪽으로 옮겼다.
|
|
7
|
+
* `StateProjector` 는 같은 것의 옛 이름으로 남는다(소비처 호환).
|
|
8
|
+
*
|
|
9
|
+
* (아래는 원래 주석 — 두 모드의 계약이 같다는 설계 근거.)
|
|
10
|
+
*
|
|
11
|
+
* State Projector — 이벤트 스트림 → State 투영 (모니터링의 "수동 미러").
|
|
12
|
+
*
|
|
13
|
+
* sim 모드: 커널이 State 를 능동 생산(내부 상태 + 이벤트 방출).
|
|
14
|
+
* live 모드: 외부(실 WMS)에서 이벤트가 도착 → 이 projector 가 State 를 재구성.
|
|
15
|
+
* → "계약 동일, 데이터원만 스왑"(execution-model.md §5, ADR-0010). 같은 이벤트면 같은 State.
|
|
16
|
+
*
|
|
17
|
+
* 두 갈래 이벤트를 함께 접는다:
|
|
18
|
+
* - EPCIS(epcis.*) → 재고/위치/조립 (What/Where)
|
|
19
|
+
* - 운영 델타(task/equipment/order.status) → tasks·movers·orders (EPCIS 로 재구성 불가한 절반)
|
|
20
|
+
* 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
|
|
21
|
+
*/
|
|
22
|
+
import { OP_EVENT, nodeStatusOf } from "./contract.js";
|
|
23
|
+
import { ILMD_ATTR, parseEpc } from "./epcis.js";
|
|
24
|
+
/**
|
|
25
|
+
* 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
|
|
26
|
+
*
|
|
27
|
+
* 예전에는 epc·gtin·location·disposition 넷만 들고 있어서, 표준 이벤트로 정확히 받은 **수량·단위·
|
|
28
|
+
* 소속 팔레트**를 버렸다(2026-08-01 감사). 그 결과 미러 트윈의 재고가 "팔레트 1개" 로 세어졌다.
|
|
29
|
+
* 로트는 LGTIN 이면 식별자에서 파생한다. 만료(expiry)는 표준 자리가 `ilmd` 인데 아직 미지원이라
|
|
30
|
+
* **꾸미지 않고 비워 둔다**(없는 것을 만들지 않는다).
|
|
31
|
+
*/
|
|
32
|
+
/** 종류를 모르는 로케이션 — 관측으로 알게 됐지만 마스터가 아직 말해 주지 않은 자리. */
|
|
33
|
+
const UNKNOWN_TYPE = 'unknown';
|
|
34
|
+
export class ObservedReducer {
|
|
35
|
+
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
36
|
+
master = new Map();
|
|
37
|
+
items = new Map();
|
|
38
|
+
aggregation = new Map();
|
|
39
|
+
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
40
|
+
pendingParent = new Map(); // 자식 EPC → 부모(물류단위)
|
|
41
|
+
tasks = new Map();
|
|
42
|
+
movers = new Map();
|
|
43
|
+
persons = new Map();
|
|
44
|
+
assets = new Map();
|
|
45
|
+
orders = new Map();
|
|
46
|
+
revision = 0;
|
|
47
|
+
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
48
|
+
corrections = [];
|
|
49
|
+
constructor(board) {
|
|
50
|
+
for (const n of board.nodes)
|
|
51
|
+
this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
|
|
52
|
+
// 무버 기준선(마스터) — equipment.status 델타로 갱신됨.
|
|
53
|
+
for (const m of board.movers)
|
|
54
|
+
this.movers.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeNode, origin: 'master' });
|
|
55
|
+
// 사람 기준선(마스터) — person.status 델타로 갱신됨.
|
|
56
|
+
for (const p of board.persons ?? [])
|
|
57
|
+
this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle' });
|
|
58
|
+
for (const a of board.assets ?? [])
|
|
59
|
+
this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: 'idle' });
|
|
60
|
+
}
|
|
61
|
+
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
62
|
+
applyMaster(u) {
|
|
63
|
+
if (u.op === 'remove') {
|
|
64
|
+
this.master.delete(u.node.id);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const cur = this.master.get(u.node.id);
|
|
68
|
+
/* **모르는 용량을 0 으로 뭉개지 않는다.** 0 은 "자리가 없다" 는 사실 주장이고, 미지정은 "모른다" 다.
|
|
69
|
+
* 계약(`NodeState.capacity`)이 선택 필드로 둔 이유가 이것이며, 0 으로 채우면 포화 판정이 거짓으로
|
|
70
|
+
* 성립하고 배정 정책이 그 노드를 영구히 배제한다. 그리고 upsert 가 **구역 소속(parentId)을 지우지
|
|
71
|
+
* 않는다** — 마스터가 말하지 않은 것은 기존 값을 지키는 것이 upsert 의 뜻이다. */
|
|
72
|
+
const capacity = u.node.capacity ?? cur?.capacity;
|
|
73
|
+
const parallelism = u.node.parallelism ?? cur?.parallelism;
|
|
74
|
+
this.master.set(u.node.id, {
|
|
75
|
+
id: u.node.id,
|
|
76
|
+
type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
|
|
77
|
+
...(capacity === undefined ? {} : { capacity }),
|
|
78
|
+
...(parallelism === undefined ? {} : { parallelism }),
|
|
79
|
+
...(u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {}),
|
|
80
|
+
/* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
|
|
81
|
+
origin: 'master'
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
|
|
86
|
+
*
|
|
87
|
+
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
|
|
88
|
+
* 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
|
|
89
|
+
* 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
|
|
90
|
+
* 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
|
|
91
|
+
*
|
|
92
|
+
* 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
|
|
93
|
+
* (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
|
|
94
|
+
*/
|
|
95
|
+
touchLocation(id) {
|
|
96
|
+
if (!id || this.master.has(id))
|
|
97
|
+
return;
|
|
98
|
+
this.master.set(id, { id, type: UNKNOWN_TYPE, origin: 'observed' });
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
|
|
102
|
+
*
|
|
103
|
+
* 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
|
|
104
|
+
* 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
|
|
105
|
+
* 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
|
|
106
|
+
*
|
|
107
|
+
* 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
|
|
108
|
+
* 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
|
|
109
|
+
*/
|
|
110
|
+
stale(key, e) {
|
|
111
|
+
const at = Date.parse(String(e.eventTime ?? ''));
|
|
112
|
+
if (!Number.isFinite(at))
|
|
113
|
+
return false;
|
|
114
|
+
const recorded = Date.parse(String(e.data?.recordTime ?? ''));
|
|
115
|
+
const seen = this.lastAt.get(key);
|
|
116
|
+
if (seen === undefined) {
|
|
117
|
+
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : undefined });
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (at < seen.at)
|
|
121
|
+
return true;
|
|
122
|
+
if (at === seen.at && Number.isFinite(recorded) && seen.recorded !== undefined && recorded < seen.recorded)
|
|
123
|
+
return true;
|
|
124
|
+
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : seen.recorded });
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
128
|
+
lastAt = new Map();
|
|
129
|
+
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
130
|
+
apply(e) {
|
|
131
|
+
this.revision++;
|
|
132
|
+
if (e.eventType.startsWith('epcis.')) {
|
|
133
|
+
this.applyEpcis(e.data, e);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
switch (e.eventType) {
|
|
137
|
+
case OP_EVENT.task: {
|
|
138
|
+
const d = e.data;
|
|
139
|
+
if (this.stale(`task:${d.taskId}`, e))
|
|
140
|
+
return;
|
|
141
|
+
/* 커널이 실어 보낸 것을 그대로 담는다 — 진척·남은 시간·의도가 있어야 이 상태를 씨앗으로
|
|
142
|
+
* 예측을 이어 굴릴 수 있고, 무자원이 설계인지(체류) 구별할 수 있다. */
|
|
143
|
+
this.touchLocation(d.fromNode);
|
|
144
|
+
this.touchLocation(d.toNode);
|
|
145
|
+
this.tasks.set(d.taskId, {
|
|
146
|
+
id: d.taskId, kind: d.kind, status: d.status, fromNode: d.fromNode, toNode: d.toNode,
|
|
147
|
+
itemRefs: d.itemRefs, resourceRef: d.resourceRef, orderId: d.orderId,
|
|
148
|
+
intent: d.intent, progress: d.progress, remainingMs: d.remainingMs, durationMs: d.durationMs,
|
|
149
|
+
startedAtSimMs: d.startedAtSimMs,
|
|
150
|
+
...(d.personnel?.length ? { personnel: d.personnel.slice() } : {}),
|
|
151
|
+
...(d.assets?.length ? { assets: d.assets.slice() } : {}),
|
|
152
|
+
...(d.resources?.length ? { resources: d.resources.slice() } : {})
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case OP_EVENT.equipment: {
|
|
157
|
+
const d = e.data;
|
|
158
|
+
if (this.stale(`mover:${d.moverId}`, e))
|
|
159
|
+
return;
|
|
160
|
+
/* 마스터에 없던 자원도 관측으로 자란다(예전부터 그랬다) — 이제 그 사실을 출처로 표시하고,
|
|
161
|
+
* 자원이 있다고 말하는 자리도 구조로 승격한다(로케이션만 자라지 않던 비대칭 해소). */
|
|
162
|
+
this.touchLocation(d.location);
|
|
163
|
+
const known = this.movers.get(d.moverId);
|
|
164
|
+
this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, taskId: d.taskId, motion: d.motion, origin: known?.origin ?? 'observed' });
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case OP_EVENT.person: {
|
|
168
|
+
const d = e.data;
|
|
169
|
+
if (this.stale(`person:${d.personId}`, e))
|
|
170
|
+
return;
|
|
171
|
+
/* 사람도 관측으로 자란다(자원과 같은 정책) — 마스터에 없던 사람이 이벤트에 나오면 승격한다. */
|
|
172
|
+
this.persons.set(d.personId, {
|
|
173
|
+
id: d.personId,
|
|
174
|
+
personnelClass: d.personnelClass ?? this.persons.get(d.personId)?.personnelClass,
|
|
175
|
+
status: d.status,
|
|
176
|
+
taskId: d.taskId,
|
|
177
|
+
...(d.offShift ? { offShift: true } : {})
|
|
178
|
+
});
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case OP_EVENT.asset: {
|
|
182
|
+
const d = e.data;
|
|
183
|
+
if (this.stale(`asset:${d.assetId}`, e))
|
|
184
|
+
return;
|
|
185
|
+
const cur = this.assets.get(d.assetId);
|
|
186
|
+
this.assets.set(d.assetId, {
|
|
187
|
+
id: d.assetId,
|
|
188
|
+
assetClass: d.assetClass ?? cur?.assetClass,
|
|
189
|
+
location: d.location ?? cur?.location,
|
|
190
|
+
status: d.status,
|
|
191
|
+
taskId: d.taskId,
|
|
192
|
+
...(d.carrying ? { carrying: d.carrying } : {})
|
|
193
|
+
});
|
|
194
|
+
this.touchLocation(d.location);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case OP_EVENT.order: {
|
|
198
|
+
const d = e.data;
|
|
199
|
+
if (this.stale(`order:${d.orderId}`, e))
|
|
200
|
+
return;
|
|
201
|
+
/* **진척으로 압축하지 않는다.** 예전에는 progress 만 남겨서, 미러에서 예측을 세우려면 저널을
|
|
202
|
+
* 따로 읽어 오더 원값을 되찾아야 했다. 진척은 요청·이행에서 나오는 파생이다 — 파생을 남기고
|
|
203
|
+
* 원본을 버리면 남은 데맨드를 재계획할 수 없다. */
|
|
204
|
+
this.orders.set(d.orderId, {
|
|
205
|
+
id: d.orderId, kind: d.kind, status: d.status,
|
|
206
|
+
progress: d.requested ? d.fulfilled / d.requested : 0,
|
|
207
|
+
requested: d.requested, fulfilled: d.fulfilled,
|
|
208
|
+
...(d.lines?.length ? { lines: d.lines.map(l => ({ ...l })) } : {}),
|
|
209
|
+
held: d.held
|
|
210
|
+
});
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
// 알 수 없는 eventType 은 무시(전방 호환).
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
applyEpcis(ev, envelope) {
|
|
217
|
+
/* 정정 선언이 붙은 이벤트는 **새 사실이 아니다** — 앞선 이벤트를 취소·수정하는 선언이다.
|
|
218
|
+
* 무엇을 어떻게 되돌릴지는 도메인 판단이 필요하므로(원본을 찾아 역적용) 지금은 **상태에 반영하지
|
|
219
|
+
* 않는다.** 새 사실로 받아 재고를 흔드는 것보다 반영하지 않는 것이 정직하다. 정정 목록은 남겨
|
|
220
|
+
* 소비처가 볼 수 있게 한다(조용히 버리지 않는다). */
|
|
221
|
+
if (ev.errorDeclaration) {
|
|
222
|
+
this.corrections.push({
|
|
223
|
+
declaredAt: String(ev.errorDeclaration.declarationTime ?? ''),
|
|
224
|
+
reason: ev.errorDeclaration.reason,
|
|
225
|
+
correctiveEventIDs: ev.errorDeclaration.correctiveEventIDs ?? [],
|
|
226
|
+
eventID: ev.eventID
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (ev.type === 'AggregationEvent') {
|
|
231
|
+
if (ev.action === 'ADD' && ev.childEPCs?.length) {
|
|
232
|
+
this.aggregation.set(ev.parentID, [...ev.childEPCs]);
|
|
233
|
+
/* 조립 관계를 물품에도 심는다 — 예전에는 내부 맵에만 두고 밖으로 내보내지 않아, 3D 가
|
|
234
|
+
* "이 상자가 어느 팔레트에 실렸나" 를 알 수 없었다. */
|
|
235
|
+
for (const child of ev.childEPCs) {
|
|
236
|
+
const cur = this.items.get(child);
|
|
237
|
+
if (cur) {
|
|
238
|
+
this.items.set(child, { ...cur, parent: ev.parentID });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
/* 아직 본 적 없는 자식 — **물품을 만들어 내지 않는다.** 조립 이벤트는 *담김*을 말할 뿐
|
|
242
|
+
* *어디 있는지*를 말하지 않는데, 예전에는 `location: ''` 로 물품을 지어냈다(빈 문자열이
|
|
243
|
+
* "모른다" 를 대신하던 자리). 담김은 잃지 않고 보류해 두었다가, 그 자식이 실제로 관측되는
|
|
244
|
+
* 순간 붙인다. 그때 위치는 관측이 말해 준다. */
|
|
245
|
+
this.pendingParent.set(child, ev.parentID);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (ev.action === 'DELETE') {
|
|
249
|
+
for (const child of this.aggregation.get(ev.parentID) ?? []) {
|
|
250
|
+
const cur = this.items.get(child);
|
|
251
|
+
if (cur)
|
|
252
|
+
this.items.set(child, { ...cur, parent: undefined });
|
|
253
|
+
this.pendingParent.delete(child);
|
|
254
|
+
}
|
|
255
|
+
this.aggregation.delete(ev.parentID);
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (ev.type === 'TransactionEvent')
|
|
260
|
+
return; // 거래 연결 — 오더 상태는 order.status 델타로
|
|
261
|
+
if (ev.type === 'TransformationEvent') {
|
|
262
|
+
// 변환: 입력 소비(제거) → 출력 생산(readPoint 에 등장)
|
|
263
|
+
for (const epc of ev.inputEPCList ?? [])
|
|
264
|
+
this.remove(epc);
|
|
265
|
+
const loc = ev.readPoint?.id ?? '';
|
|
266
|
+
/* 변환의 마스터데이터는 **출력**에 적용된다(§7.3.8) — 입력에 붙이면 소비되는 것에 태생을 심는 셈. */
|
|
267
|
+
this.touchLocation(loc);
|
|
268
|
+
for (const epc of ev.outputEPCList ?? []) {
|
|
269
|
+
this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }));
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// ObjectEvent
|
|
274
|
+
if (ev.action === 'DELETE') {
|
|
275
|
+
for (const epc of ev.epcList)
|
|
276
|
+
this.remove(epc);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const loc = ev.readPoint?.id;
|
|
280
|
+
/* 수량 목록은 **클래스 + 수량 + 단위**를 함께 실어 온다 — 예전에는 클래스만 꺼내고 수량·단위를
|
|
281
|
+
* 버렸다. 클래스가 LGTIN 이면 품번과 로트가 그 안에 있으므로 파서로 뜯는다(문자열을 자르지 않는다). */
|
|
282
|
+
const q = ev.quantityList?.[0];
|
|
283
|
+
this.touchLocation(loc);
|
|
284
|
+
for (const epc of ev.epcList) {
|
|
285
|
+
/* 물품별 순서 판정 — 늦게 온 옛 관측이 최신 위치를 덮지 않게. */
|
|
286
|
+
if (envelope && this.stale(`item:${epc}`, envelope))
|
|
287
|
+
continue;
|
|
288
|
+
this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q));
|
|
289
|
+
}
|
|
290
|
+
/* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
|
|
291
|
+
* 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
|
|
292
|
+
if (!ev.epcList?.length) {
|
|
293
|
+
for (const qe of ev.quantityList ?? []) {
|
|
294
|
+
if (qe?.epcClass)
|
|
295
|
+
this.items.set(qe.epcClass, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
|
|
301
|
+
* 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
|
|
302
|
+
*/
|
|
303
|
+
/**
|
|
304
|
+
* 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
|
|
305
|
+
*
|
|
306
|
+
* 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
|
|
307
|
+
* `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
|
|
308
|
+
*/
|
|
309
|
+
expiryOf(ilmd) {
|
|
310
|
+
const raw = ilmd?.[ILMD_ATTR.expiry];
|
|
311
|
+
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
312
|
+
return raw;
|
|
313
|
+
if (typeof raw === 'string') {
|
|
314
|
+
const t = Date.parse(raw);
|
|
315
|
+
if (Number.isFinite(t))
|
|
316
|
+
return t;
|
|
317
|
+
}
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
mergeItem(epc, patch, q) {
|
|
321
|
+
const cur = this.items.get(epc);
|
|
322
|
+
/* 클래스는 수량 목록에서 오거나, 물품 자신이 클래스 식별자일 수 있다(비직렬 입고). */
|
|
323
|
+
const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : undefined;
|
|
324
|
+
const parsedSelf = parseEpc(epc);
|
|
325
|
+
/* `gtin` 은 **클래스 URI 원문**이다(오더 매칭이 이 값을 쓴다 — 뜻을 바꾸면 조용히 안 맞는다).
|
|
326
|
+
* 파서로 뜯은 품번 키·로트는 **별도 필드**로 얹는다. */
|
|
327
|
+
const classUri = q?.epcClass ?? (parsedSelf.instance ? undefined : epc);
|
|
328
|
+
return {
|
|
329
|
+
epc,
|
|
330
|
+
gtin: classUri ?? cur?.gtin,
|
|
331
|
+
gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
|
|
332
|
+
location: patch.location ?? cur?.location ?? '',
|
|
333
|
+
disposition: patch.disposition ?? cur?.disposition,
|
|
334
|
+
parent: cur?.parent ?? this.pendingParent.get(epc),
|
|
335
|
+
qty: q?.quantity ?? cur?.qty,
|
|
336
|
+
uom: q?.uom ?? cur?.uom,
|
|
337
|
+
/* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
|
|
338
|
+
ilmd: patch.ilmd ?? cur?.ilmd,
|
|
339
|
+
expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
|
|
340
|
+
/* 로트는 LGTIN(식별자)에서 오지만, 직렬 개체는 마스터데이터에 실려 온다. */
|
|
341
|
+
lot: parsedClass?.lot ?? parsedSelf.lot ?? (typeof patch.ilmd?.[ILMD_ATTR.lot] === 'string' ? patch.ilmd[ILMD_ATTR.lot] : undefined) ?? cur?.lot
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
|
|
345
|
+
remove(epc) {
|
|
346
|
+
this.items.delete(epc);
|
|
347
|
+
const children = this.aggregation.get(epc);
|
|
348
|
+
if (children) {
|
|
349
|
+
this.aggregation.delete(epc);
|
|
350
|
+
for (const c of children)
|
|
351
|
+
this.remove(c);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
|
|
355
|
+
snapshot() {
|
|
356
|
+
const occ = new Map();
|
|
357
|
+
for (const it of this.items.values())
|
|
358
|
+
occ.set(it.location, (occ.get(it.location) ?? 0) + 1);
|
|
359
|
+
return {
|
|
360
|
+
revision: this.revision,
|
|
361
|
+
...(this.corrections.length ? { corrections: this.corrections.map(c => ({ ...c })) } : {}),
|
|
362
|
+
nodes: [...this.master.values()].map(n => {
|
|
363
|
+
const occupancy = occ.get(n.id) ?? 0;
|
|
364
|
+
const status = nodeStatusOf({ occupancy, capacity: n.capacity });
|
|
365
|
+
return {
|
|
366
|
+
id: n.id, type: n.type, occupancy,
|
|
367
|
+
...(status ? { status } : {}),
|
|
368
|
+
/* 용량 미상은 **키를 만들지 않는다** — 0 으로 실으면 "자리 없음" 이라는 없는 사실이 생긴다. */
|
|
369
|
+
...(n.capacity === undefined ? {} : { capacity: n.capacity }),
|
|
370
|
+
...(n.parallelism === undefined ? {} : { parallelism: n.parallelism }),
|
|
371
|
+
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
372
|
+
origin: n.origin
|
|
373
|
+
};
|
|
374
|
+
}),
|
|
375
|
+
/* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
|
|
376
|
+
items: [...this.items.values()].map(i => ({ ...i })),
|
|
377
|
+
persons: [...this.persons.values()].map(p => ({ ...p })),
|
|
378
|
+
assets: [...this.assets.values()].map(a => ({ ...a })),
|
|
379
|
+
tasks: [...this.tasks.values()].map(t => ({ ...t })),
|
|
380
|
+
movers: [...this.movers.values()].map(m => ({ ...m })),
|
|
381
|
+
orders: [...this.orders.values()].map(o => ({ ...o }))
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -1,100 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* 마스터 동기 — 선언적 로케이션 upsert/remove.
|
|
4
|
-
*
|
|
5
|
-
* 구조(로케이션·용량·구역 소속)는 이벤트가 말해 주는 것이 아니라 **원 시스템의 마스터**에서 온다.
|
|
6
|
-
* 최초 생성은 Face2 마스터 인제스트(host `ingestMaster`)가 하고, 그 뒤 현장이 바뀐 것(랙 증설·구역
|
|
7
|
-
* 재편·용량 변경)은 이 경로로 **기동 중에도** 반영된다 — 없으면 재기동해야 구조가 갱신된다.
|
|
8
|
-
*/
|
|
9
|
-
export interface MasterUpdate {
|
|
10
|
-
op: 'upsert' | 'remove';
|
|
11
|
-
node: {
|
|
12
|
-
id: string;
|
|
13
|
-
type?: string;
|
|
14
|
-
capacity?: number;
|
|
15
|
-
parentId?: string;
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
export interface ProjectedState {
|
|
19
|
-
revision: number;
|
|
20
|
-
/**
|
|
21
|
-
* 받은 정정 선언들 — 상태에 반영하지 않은 것을 **밝힌다**.
|
|
22
|
-
* 비어 있지 않으면 "원 시스템이 정정을 보냈고 우리는 아직 반영하지 못했다" 는 뜻이다(조용한 무시 금지).
|
|
23
|
-
*/
|
|
24
|
-
corrections?: {
|
|
25
|
-
declaredAt: string;
|
|
26
|
-
reason?: string;
|
|
27
|
-
correctiveEventIDs: string[];
|
|
28
|
-
eventID?: string;
|
|
29
|
-
}[];
|
|
30
|
-
nodes: NodeState[];
|
|
31
|
-
items: ItemState[];
|
|
32
|
-
/** 사람 — 등급·교대·투입. 인원을 선언하지 않은 트윈에서는 빈 배열. */
|
|
33
|
-
persons: PersonState[];
|
|
34
|
-
/** 물리 자산(반복사용) — 자산을 선언하지 않은 트윈에서는 빈 배열. */
|
|
35
|
-
assets: AssetState[];
|
|
36
|
-
tasks: TaskState[];
|
|
37
|
-
movers: MoverState[];
|
|
38
|
-
orders: OrderState[];
|
|
39
|
-
}
|
|
40
|
-
export declare class StateProjector {
|
|
41
|
-
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
42
|
-
private master;
|
|
43
|
-
private items;
|
|
44
|
-
private aggregation;
|
|
45
|
-
private tasks;
|
|
46
|
-
private movers;
|
|
47
|
-
private persons;
|
|
48
|
-
private assets;
|
|
49
|
-
private orders;
|
|
50
|
-
revision: number;
|
|
51
|
-
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
52
|
-
private corrections;
|
|
53
|
-
constructor(board: BoardDef);
|
|
54
|
-
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
55
|
-
applyMaster(u: MasterUpdate): void;
|
|
56
|
-
/**
|
|
57
|
-
* 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
|
|
58
|
-
*
|
|
59
|
-
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
|
|
60
|
-
* 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
|
|
61
|
-
* 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
|
|
62
|
-
* 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
|
|
63
|
-
*
|
|
64
|
-
* 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
|
|
65
|
-
* (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
|
|
66
|
-
*/
|
|
67
|
-
private touchLocation;
|
|
68
|
-
/**
|
|
69
|
-
* 늦게 도착한 옛 이벤트를 걸러낸다 — **도착 순서 ≠ 발생 순서**.
|
|
70
|
-
*
|
|
71
|
-
* 실 연동에서는 순서가 뒤집힌다(재시도·큐·배치). 시각을 비교하지 않으면 **늦게 온 옛 이벤트가 최신
|
|
72
|
-
* 상태를 덮어써** 위치가 과거로 튄다. 표준이 발생(`eventTime`)과 기록(`recordTime`)을 나눠 둔 이유가
|
|
73
|
-
* 이것이므로, 대상별로 마지막으로 반영한 시각을 기억해 그보다 오래된 것은 무시한다.
|
|
74
|
-
*
|
|
75
|
-
* 판정 시각은 **발생 시각**을 쓴다(현장에서 일어난 순서가 사실). `recordTime` 은 같은 발생 시각이
|
|
76
|
-
* 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
|
|
77
|
-
*/
|
|
78
|
-
private stale;
|
|
79
|
-
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
80
|
-
private lastAt;
|
|
81
|
-
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
82
|
-
apply(e: CanonicalEnvelope): void;
|
|
83
|
-
private applyEpcis;
|
|
84
|
-
/**
|
|
85
|
-
* 물품 한 건 병합 — **아는 것을 잃지 않는다.** 새로 온 값이 우선, 없으면 기존 값 유지.
|
|
86
|
-
* 클래스 식별자(LGTIN/idpat)에서 품번·로트를 파생한다 — 소비처가 문자열을 자르지 않게.
|
|
87
|
-
*/
|
|
88
|
-
/**
|
|
89
|
-
* 개체·로트 마스터데이터에서 만료 시각을 뽑는다 — **우리가 아는 이름일 때만.**
|
|
90
|
-
*
|
|
91
|
-
* 표준이 속성 이름을 정의하지 않으므로 모르는 이름은 해석하지 않는다(추측하지 않는다). 원문은
|
|
92
|
-
* `ilmd` 로 그대로 남으니 도메인이 자기 어휘로 읽을 수 있다.
|
|
93
|
-
*/
|
|
94
|
-
private expiryOf;
|
|
95
|
-
private mergeItem;
|
|
96
|
-
/** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
|
|
97
|
-
private remove;
|
|
98
|
-
/** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
|
|
99
|
-
snapshot(): ProjectedState;
|
|
100
|
-
}
|
|
1
|
+
export { ObservedReducer, ObservedReducer as StateProjector } from './observed-reducer.ts';
|
|
2
|
+
export type { MasterUpdate, ProjectedState } from './observed-reducer.ts';
|