@operato/twin-kernel 0.3.0 → 0.4.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/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +512 -23
- package/dist/contract.js +332 -12
- package/dist/divergence.js +5 -2
- package/dist/domain-definition.d.ts +45 -0
- package/dist/epcis.d.ts +12 -0
- package/dist/epcis.js +12 -0
- package/dist/event-journal.d.ts +30 -0
- package/dist/event-journal.js +26 -0
- package/dist/flow-engine.d.ts +270 -7
- package/dist/flow-engine.js +727 -52
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/job-response.d.ts +59 -0
- package/dist/job-response.js +56 -0
- package/dist/kernel.d.ts +5 -0
- package/dist/kernel.js +25 -5
- package/dist/mes-kernel.d.ts +28 -0
- package/dist/mes-kernel.js +58 -4
- package/dist/observed-reducer.d.ts +61 -0
- package/dist/observed-reducer.js +214 -17
- package/dist/yms-kernel.js +26 -5
- package/dist-cjs/index.cjs +1233 -84
- package/package.json +1 -1
package/dist/observed-reducer.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
|
|
20
20
|
* 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
|
|
21
21
|
*/
|
|
22
|
-
import { OP_EVENT, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets } from "./contract.js";
|
|
22
|
+
import { OP_EVENT, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
|
|
23
23
|
import { ILMD_ATTR, parseEpc } from "./epcis.js";
|
|
24
24
|
/**
|
|
25
25
|
* 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
|
|
@@ -31,6 +31,13 @@ import { ILMD_ATTR, parseEpc } from "./epcis.js";
|
|
|
31
31
|
*/
|
|
32
32
|
/** 종류를 모르는 로케이션 — 관측으로 알게 됐지만 마스터가 아직 말해 주지 않은 자리. */
|
|
33
33
|
const UNKNOWN_TYPE = 'unknown';
|
|
34
|
+
/** 선언된 유효 기간만 뽑는다 — 시뮬 쪽 `effectiveOnly` 와 같은 규칙(빈 필드를 만들지 않는다). */
|
|
35
|
+
function effectiveOf(r) {
|
|
36
|
+
return {
|
|
37
|
+
...(r?.effectiveStart ? { effectiveStart: r.effectiveStart } : {}),
|
|
38
|
+
...(r?.effectiveEnd ? { effectiveEnd: r.effectiveEnd } : {})
|
|
39
|
+
};
|
|
40
|
+
}
|
|
34
41
|
export class ObservedReducer {
|
|
35
42
|
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
36
43
|
master = new Map();
|
|
@@ -46,17 +53,101 @@ export class ObservedReducer {
|
|
|
46
53
|
revision = 0;
|
|
47
54
|
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
48
55
|
corrections = [];
|
|
56
|
+
/**
|
|
57
|
+
* 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
|
|
58
|
+
*
|
|
59
|
+
* 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
|
|
60
|
+
* 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
|
|
61
|
+
*/
|
|
62
|
+
shifts = new Map();
|
|
63
|
+
/** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
|
|
64
|
+
utcOffsetMinutes;
|
|
49
65
|
constructor(board) {
|
|
66
|
+
this.utcOffsetMinutes = board.utcOffsetMinutes;
|
|
50
67
|
for (const n of readBoardLocations(board))
|
|
51
68
|
this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
|
|
52
69
|
// 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
|
|
53
70
|
for (const m of readBoardEquipment(board))
|
|
54
|
-
this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), origin: 'master' });
|
|
71
|
+
this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...effectiveOf(m), origin: 'master' });
|
|
55
72
|
// 사람 기준선(마스터) — person.status 델타로 갱신됨.
|
|
56
73
|
for (const p of board.persons ?? [])
|
|
57
|
-
this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}) });
|
|
74
|
+
this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOf(p) });
|
|
75
|
+
/* 교대 선언은 **미러도 들고 있어야 한다** — 설비 델타에는 `offShift` 자리조차 없어서, 그것 없이는
|
|
76
|
+
미러의 설비가 점심 휴게 중에도 "대기" 로 보였다. */
|
|
77
|
+
for (const m of readBoardEquipment(board))
|
|
78
|
+
this.rememberShift(`eq:${m.id}`, m);
|
|
79
|
+
for (const p of board.persons ?? [])
|
|
80
|
+
this.rememberShift(`person:${p.id}`, p);
|
|
58
81
|
for (const a of readBoardAssets(board))
|
|
59
|
-
this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}) });
|
|
82
|
+
this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOf(a) });
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
|
|
86
|
+
*
|
|
87
|
+
* 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
|
|
88
|
+
* **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
|
|
89
|
+
* 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
|
|
90
|
+
*
|
|
91
|
+
* 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
|
|
92
|
+
* 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
|
|
93
|
+
*
|
|
94
|
+
* ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
|
|
95
|
+
* **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
|
|
96
|
+
* **사라진 자원은 버린다** — 없어진 설비의 상태를 계속 들고 있으면 화면이 없는 설비를 그린다.
|
|
97
|
+
* 다만 **몇 개를 버렸는지 돌려준다.** 조용히 사라지면 사용자는 수가 줄어든 것을 눈치채지 못한다.
|
|
98
|
+
*
|
|
99
|
+
* 관측으로 알게 된 자리(`origin: 'observed'`)는 **새 마스터에 없어도 남긴다** — 마스터가 모르는
|
|
100
|
+
* 자리에서 물건이 실제로 보였다는 사실은 구조를 바꾼다고 사라지지 않는다.
|
|
101
|
+
*/
|
|
102
|
+
adoptStructure(board) {
|
|
103
|
+
this.utcOffsetMinutes = board.utcOffsetMinutes;
|
|
104
|
+
const locations = readBoardLocations(board);
|
|
105
|
+
const declaredLocationIds = new Set(locations.map(n => n.id));
|
|
106
|
+
let locationsAdded = 0;
|
|
107
|
+
let locationsDropped = 0;
|
|
108
|
+
for (const [id, cur] of [...this.master]) {
|
|
109
|
+
if (declaredLocationIds.has(id) || cur.origin === 'observed')
|
|
110
|
+
continue;
|
|
111
|
+
this.master.delete(id);
|
|
112
|
+
locationsDropped++;
|
|
113
|
+
}
|
|
114
|
+
for (const n of locations) {
|
|
115
|
+
if (!this.master.has(n.id))
|
|
116
|
+
locationsAdded++;
|
|
117
|
+
this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
|
|
118
|
+
}
|
|
119
|
+
/* 자원은 **선언이 전부다** — 관측으로 생기지 않는다(설비는 이벤트가 만들어 내지 않는다).
|
|
120
|
+
그래서 새 선언에 없으면 없는 것이고, 남아 있는 것은 지금까지의 상태를 그대로 이어 간다. */
|
|
121
|
+
const equipment = readBoardEquipment(board);
|
|
122
|
+
const assets = readBoardAssets(board);
|
|
123
|
+
const persons = board.persons ?? [];
|
|
124
|
+
const dropMissing = (map, declared) => {
|
|
125
|
+
const ids = new Set(declared.map(d => d.id));
|
|
126
|
+
let dropped = 0;
|
|
127
|
+
for (const id of [...map.keys()])
|
|
128
|
+
if (!ids.has(id)) {
|
|
129
|
+
map.delete(id);
|
|
130
|
+
dropped++;
|
|
131
|
+
}
|
|
132
|
+
return dropped;
|
|
133
|
+
};
|
|
134
|
+
const equipmentDropped = dropMissing(this.equipment, equipment);
|
|
135
|
+
const personsDropped = dropMissing(this.persons, persons);
|
|
136
|
+
const assetsDropped = dropMissing(this.assets, assets);
|
|
137
|
+
for (const m of equipment) {
|
|
138
|
+
if (!this.equipment.has(m.id))
|
|
139
|
+
this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...effectiveOf(m), origin: 'master' });
|
|
140
|
+
this.rememberShift(`eq:${m.id}`, m);
|
|
141
|
+
}
|
|
142
|
+
for (const p of persons) {
|
|
143
|
+
if (!this.persons.has(p.id))
|
|
144
|
+
this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOf(p) });
|
|
145
|
+
this.rememberShift(`person:${p.id}`, p);
|
|
146
|
+
}
|
|
147
|
+
for (const a of assets)
|
|
148
|
+
if (!this.assets.has(a.id))
|
|
149
|
+
this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOf(a) });
|
|
150
|
+
return { locationsAdded, locationsDropped, equipmentDropped, personsDropped, assetsDropped };
|
|
60
151
|
}
|
|
61
152
|
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
62
153
|
applyMaster(u) {
|
|
@@ -109,6 +200,8 @@ export class ObservedReducer {
|
|
|
109
200
|
*/
|
|
110
201
|
stale(key, e) {
|
|
111
202
|
const at = Date.parse(String(e.eventTime ?? ''));
|
|
203
|
+
if (Number.isFinite(at) && (this.observedAtMs === undefined || at > this.observedAtMs))
|
|
204
|
+
this.observedAtMs = at;
|
|
112
205
|
if (!Number.isFinite(at))
|
|
113
206
|
return false;
|
|
114
207
|
const recorded = Date.parse(String(e.data?.recordTime ?? ''));
|
|
@@ -126,6 +219,14 @@ export class ObservedReducer {
|
|
|
126
219
|
}
|
|
127
220
|
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
128
221
|
lastAt = new Map();
|
|
222
|
+
/**
|
|
223
|
+
* **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
|
|
224
|
+
*
|
|
225
|
+
* 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
|
|
226
|
+
* 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
|
|
227
|
+
* 판정하지 않는다(모르면 단정하지 않는다).
|
|
228
|
+
*/
|
|
229
|
+
observedAtMs;
|
|
129
230
|
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
130
231
|
apply(e) {
|
|
131
232
|
this.revision++;
|
|
@@ -149,7 +250,12 @@ export class ObservedReducer {
|
|
|
149
250
|
startedAtSimMs: d.startedAtSimMs,
|
|
150
251
|
...(d.personnel?.length ? { personnel: d.personnel.slice() } : {}),
|
|
151
252
|
...(d.assets?.length ? { assets: d.assets.slice() } : {}),
|
|
152
|
-
...(d.resources?.length ? { resources: d.resources.slice() } : {})
|
|
253
|
+
...(d.resources?.length ? { resources: d.resources.slice() } : {}),
|
|
254
|
+
/* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다). */
|
|
255
|
+
...(d.materialActual?.length ? { materialActual: d.materialActual.map(r => ({ ...r })) } : {}),
|
|
256
|
+
...(d.priority !== undefined ? { priority: d.priority } : {}),
|
|
257
|
+
...(d.startTime ? { startTime: d.startTime } : {}),
|
|
258
|
+
...(d.endTime ? { endTime: d.endTime } : {})
|
|
153
259
|
});
|
|
154
260
|
break;
|
|
155
261
|
}
|
|
@@ -165,7 +271,10 @@ export class ObservedReducer {
|
|
|
165
271
|
마스터로 알던 소속을 관측 델타 하나가 지워 버리면 그 설비가 롤업에서 통째로 빠진다.
|
|
166
272
|
소속은 자주 바뀌는 사실이 아니므로 침묵을 "소속 없음" 으로 읽지 않는다. */
|
|
167
273
|
const homeLocation = d.homeLocation ?? known?.homeLocation;
|
|
168
|
-
|
|
274
|
+
/* 유효 기간은 **마스터 사실**이므로 침묵을 "없어졌다" 로 읽지 않고 아는 값을 지킨다.
|
|
275
|
+
판정은 여기서 하지 않는다 — `snapshot()` 이 관측 시각으로 `effectivityAt` 을 부른다. */
|
|
276
|
+
const eqPeriod = d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known);
|
|
277
|
+
this.equipment.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, ...(homeLocation ? { homeLocation } : {}), taskId: d.taskId, motion: d.motion, origin: known?.origin ?? 'observed', ...eqPeriod, ...(d.held ? { held: true } : {}) });
|
|
169
278
|
break;
|
|
170
279
|
}
|
|
171
280
|
case OP_EVENT.person: {
|
|
@@ -184,7 +293,11 @@ export class ObservedReducer {
|
|
|
184
293
|
...((d.location ?? known?.location) ? { location: d.location ?? known?.location } : {}),
|
|
185
294
|
...(known?.properties ? { properties: known.properties } : {}),
|
|
186
295
|
...(known?.testSpecificationIds ? { testSpecificationIds: known.testSpecificationIds } : {}),
|
|
187
|
-
|
|
296
|
+
/* 델타의 `offShift` 는 **선언이 없을 때만** 받는다 — 선언이 있으면 `snapshot()` 이 시각으로
|
|
297
|
+
판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가 남는다(두 출처 = 갈라짐).
|
|
298
|
+
남겨 두는 이유는 캘린더 없는 옛 저널 재생이다. */
|
|
299
|
+
...(d.offShift && !this.shifts.has(`person:${d.personId}`) ? { offShift: true } : {}),
|
|
300
|
+
...(d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known))
|
|
188
301
|
});
|
|
189
302
|
break;
|
|
190
303
|
}
|
|
@@ -199,7 +312,8 @@ export class ObservedReducer {
|
|
|
199
312
|
location: d.location ?? cur?.location,
|
|
200
313
|
status: d.status,
|
|
201
314
|
taskId: d.taskId,
|
|
202
|
-
...(d.carrying ? { carrying: d.carrying } : {})
|
|
315
|
+
...(d.carrying ? { carrying: d.carrying } : {}),
|
|
316
|
+
...(d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(cur))
|
|
203
317
|
});
|
|
204
318
|
this.touchLocation(d.location);
|
|
205
319
|
break;
|
|
@@ -216,6 +330,9 @@ export class ObservedReducer {
|
|
|
216
330
|
progress: d.requested ? d.fulfilled / d.requested : 0,
|
|
217
331
|
requested: d.requested, fulfilled: d.fulfilled,
|
|
218
332
|
...(d.lines?.length ? { lines: d.lines.map(l => ({ ...l })) } : {}),
|
|
333
|
+
...(d.priority !== undefined ? { priority: d.priority } : {}),
|
|
334
|
+
...(d.startTime ? { startTime: d.startTime } : {}),
|
|
335
|
+
...(d.endTime ? { endTime: d.endTime } : {}),
|
|
219
336
|
held: d.held
|
|
220
337
|
});
|
|
221
338
|
break;
|
|
@@ -289,20 +406,33 @@ export class ObservedReducer {
|
|
|
289
406
|
const loc = ev.readPoint?.id;
|
|
290
407
|
/* 수량 목록은 **클래스 + 수량 + 단위**를 함께 실어 온다 — 예전에는 클래스만 꺼내고 수량·단위를
|
|
291
408
|
* 버렸다. 클래스가 LGTIN 이면 품번과 로트가 그 안에 있으므로 파서로 뜯는다(문자열을 자르지 않는다). */
|
|
292
|
-
|
|
409
|
+
/* **전부 받는다.** 예전에는 `[0]` 만 써서 나머지를 버렸다 — 표준은 `Quantity` 를 복수로 두고,
|
|
410
|
+
실 데이터는 같은 로트를 여러 단위로 싣는다(100 EA · 250 KG). 버리면 조용히 잘린다. */
|
|
411
|
+
const all = ev.quantityList ?? [];
|
|
412
|
+
const q = all[0];
|
|
293
413
|
this.touchLocation(loc);
|
|
294
414
|
for (const epc of ev.epcList) {
|
|
295
415
|
/* 물품별 순서 판정 — 늦게 온 옛 관측이 최신 위치를 덮지 않게. */
|
|
296
416
|
if (envelope && this.stale(`item:${epc}`, envelope))
|
|
297
417
|
continue;
|
|
298
|
-
this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q));
|
|
418
|
+
this.items.set(epc, this.mergeItem(epc, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, q, all));
|
|
299
419
|
}
|
|
300
420
|
/* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
|
|
301
421
|
* 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
|
|
302
422
|
if (!ev.epcList?.length) {
|
|
303
423
|
for (const qe of ev.quantityList ?? []) {
|
|
304
|
-
if (qe?.epcClass)
|
|
305
|
-
|
|
424
|
+
if (!qe?.epcClass)
|
|
425
|
+
continue;
|
|
426
|
+
/* 이 클래스에 대해 선언된 것만 모은다 — 다른 클래스의 수량을 섞으면 거짓이 된다. */
|
|
427
|
+
const mine = all.filter(x => x.epcClass === qe.epcClass);
|
|
428
|
+
/*
|
|
429
|
+
* **자리마다 한 줄** — 표준 `MaterialSubLot`(각자 `StorageLocation`·`Quantity`).
|
|
430
|
+
*
|
|
431
|
+
* 예전에는 클래스 식별자 하나를 키로 써서, 같은 로트가 두 자리에 있으면 **뒤에 온 관측이 앞을
|
|
432
|
+
* 덮었다**(rack-1 의 100개가 사라지고 합계가 60이 됐다). 로트가 나뉘어 놓이는 것은 일상이다.
|
|
433
|
+
*/
|
|
434
|
+
const subLotId = `${qe.epcClass}@${loc}`;
|
|
435
|
+
this.items.set(subLotId, this.mergeItem(qe.epcClass, { location: loc, disposition: ev.disposition, ilmd: ev.ilmd }, qe, mine, subLotId));
|
|
306
436
|
}
|
|
307
437
|
}
|
|
308
438
|
}
|
|
@@ -327,8 +457,12 @@ export class ObservedReducer {
|
|
|
327
457
|
}
|
|
328
458
|
return undefined;
|
|
329
459
|
}
|
|
330
|
-
mergeItem(epc, patch, q
|
|
331
|
-
|
|
460
|
+
mergeItem(epc, patch, q,
|
|
461
|
+
/** 이 물품에 대해 선언된 수량 전부(표준 복수). 없으면 주 수량만 남는다. */
|
|
462
|
+
all,
|
|
463
|
+
/** 로트의 부분 식별자(표준 `MaterialSubLot.ID`) — 비직렬 로트가 자리마다 갈릴 때. */
|
|
464
|
+
subLotId) {
|
|
465
|
+
const cur = this.items.get(subLotId ?? epc);
|
|
332
466
|
/* 클래스는 수량 목록에서 오거나, 물품 자신이 클래스 식별자일 수 있다(비직렬 입고). */
|
|
333
467
|
const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : undefined;
|
|
334
468
|
const parsedSelf = parseEpc(epc);
|
|
@@ -337,6 +471,7 @@ export class ObservedReducer {
|
|
|
337
471
|
const classUri = q?.epcClass ?? (parsedSelf.instance ? undefined : epc);
|
|
338
472
|
return {
|
|
339
473
|
epc,
|
|
474
|
+
...(subLotId ? { subLotId } : {}),
|
|
340
475
|
gtin: classUri ?? cur?.gtin,
|
|
341
476
|
gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
|
|
342
477
|
location: patch.location ?? cur?.location ?? '',
|
|
@@ -344,6 +479,20 @@ export class ObservedReducer {
|
|
|
344
479
|
parent: cur?.parent ?? this.pendingParent.get(epc),
|
|
345
480
|
qty: q?.quantity ?? cur?.qty,
|
|
346
481
|
uom: q?.uom ?? cur?.uom,
|
|
482
|
+
/*
|
|
483
|
+
* 선언된 수량 전부 — 값이 없는 항목은 담지 않는다(모름을 0 으로 만들지 않는다).
|
|
484
|
+
*
|
|
485
|
+
* **한 항목뿐이면 담지 않는다.** 그 하나는 `qty`/`uom` 이 이미 무손실로 들고 있고, 1개짜리
|
|
486
|
+
* 배열을 더 두면 같은 사실이 두 곳에 생긴다(그리고 시뮬은 그 배열을 만들지 않아 두 구동이
|
|
487
|
+
* 갈라진다 — 적합성 하네스가 실제로 잡았다). `quantities` 의 뜻은 **추가 단위**다.
|
|
488
|
+
* 읽을 때는 단위를 가리지 않는 `quantityIn` 을 쓴다(단일이든 복수든 같은 답).
|
|
489
|
+
*/
|
|
490
|
+
quantities: (() => {
|
|
491
|
+
const declared = (all ?? []).filter(x => typeof x.quantity === 'number');
|
|
492
|
+
if (declared.length < 2)
|
|
493
|
+
return cur?.quantities;
|
|
494
|
+
return declared.map(x => ({ value: x.quantity, ...(x.uom ? { uom: x.uom } : {}) }));
|
|
495
|
+
})(),
|
|
347
496
|
/* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
|
|
348
497
|
ilmd: patch.ilmd ?? cur?.ilmd,
|
|
349
498
|
expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
|
|
@@ -362,6 +511,52 @@ export class ObservedReducer {
|
|
|
362
511
|
}
|
|
363
512
|
}
|
|
364
513
|
/** 현재 투영 State — 자리 점유는 아이템 위치 집계로 유도(pure projection). */
|
|
514
|
+
/**
|
|
515
|
+
* 마지막으로 들은 발생 시각(ms) — **관측 구동의 "지금".**
|
|
516
|
+
*
|
|
517
|
+
* 시각으로만 바뀌는 사실(납기 초과·유효 기간·교대)을 판정하려면 기준이 필요하고, 관측 모드에는
|
|
518
|
+
* 굴러가는 시계가 없다. 정직한 기준은 "내가 마지막으로 들은 시점" 이다. 아무것도 못 들었으면
|
|
519
|
+
* `undefined` — 없는 기준으로 단정하지 않는다.
|
|
520
|
+
*/
|
|
521
|
+
get lastObservedMs() {
|
|
522
|
+
return this.observedAtMs;
|
|
523
|
+
}
|
|
524
|
+
/** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
|
|
525
|
+
rememberShift(key, r) {
|
|
526
|
+
if (!r.workCalendar?.length && !r.window)
|
|
527
|
+
return;
|
|
528
|
+
this.shifts.set(key, {
|
|
529
|
+
...(r.window ? { window: r.window } : {}),
|
|
530
|
+
...(r.workCalendar?.length ? { workCalendar: r.workCalendar } : {})
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* 지금 근무 시간 밖인가 — 시뮬과 **같은 함수**(`offCalendarAt`)를 미러의 시각으로 부른다.
|
|
535
|
+
*
|
|
536
|
+
* 판정을 델타로 받지 않는 이유는 유효 기간과 같다: 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로,
|
|
537
|
+
* 유휴 상태로 경계를 지난 자원은 실어 보낸 옛 판정에 머문다. 아무것도 못 들었으면 판정하지 않는다.
|
|
538
|
+
*/
|
|
539
|
+
offShiftPart(key) {
|
|
540
|
+
const decl = this.shifts.get(key);
|
|
541
|
+
if (!decl || this.observedAtMs === undefined)
|
|
542
|
+
return {};
|
|
543
|
+
if (offCalendarAt(decl, this.observedAtMs, this.utcOffsetMinutes)) {
|
|
544
|
+
const why = offCalendarReasonAt(decl, this.observedAtMs, this.utcOffsetMinutes);
|
|
545
|
+
return { offShift: true, ...(why ? { offShiftReason: why } : {}) };
|
|
546
|
+
}
|
|
547
|
+
/* 근무 중이면 **어느 교대인지**도 낸다 — 시뮬과 같은 함수. 교대별 성과의 축이 여기서 나온다. */
|
|
548
|
+
const shift = activeShiftAt(decl.workCalendar, this.observedAtMs, this.utcOffsetMinutes);
|
|
549
|
+
return shift ? { shift } : {};
|
|
550
|
+
}
|
|
551
|
+
/** 유효 기간 밖이면 그 이유를 붙인다 — 미러의 시각 기준은 `observedAtMs`(마지막으로 들은 시점). */
|
|
552
|
+
effectivityPart(r) {
|
|
553
|
+
if (!r.effectiveStart && !r.effectiveEnd)
|
|
554
|
+
return {};
|
|
555
|
+
if (this.observedAtMs === undefined)
|
|
556
|
+
return {};
|
|
557
|
+
const e = effectivityAt(r, new Date(this.observedAtMs).toISOString());
|
|
558
|
+
return e ? { effectivity: e } : {};
|
|
559
|
+
}
|
|
365
560
|
snapshot() {
|
|
366
561
|
const occ = new Map();
|
|
367
562
|
for (const it of this.items.values())
|
|
@@ -384,10 +579,12 @@ export class ObservedReducer {
|
|
|
384
579
|
}),
|
|
385
580
|
/* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
|
|
386
581
|
items: [...this.items.values()].map(i => ({ ...i })),
|
|
387
|
-
|
|
388
|
-
|
|
582
|
+
/* 유효 기간 판정은 **저장하지 않고 여기서 낸다** — 시뮬과 **같은 함수**(`effectivityAt`)를 부른다.
|
|
583
|
+
만료는 이벤트 없이 시각만으로 일어나므로, 델타로 받아 두면 유휴 자원이 영원히 유효하게 남는다. */
|
|
584
|
+
persons: [...this.persons.values()].map(p => ({ ...p, ...this.effectivityPart(p), ...this.offShiftPart(`person:${p.id}`) })),
|
|
585
|
+
assets: [...this.assets.values()].map(a => ({ ...a, ...this.effectivityPart(a) })),
|
|
389
586
|
tasks: [...this.tasks.values()].map(t => ({ ...t })),
|
|
390
|
-
equipment: [...this.equipment.values()].map(m => ({ ...m })),
|
|
587
|
+
equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`) })),
|
|
391
588
|
orders: [...this.orders.values()].map(o => ({ ...o }))
|
|
392
589
|
};
|
|
393
590
|
}
|
package/dist/yms-kernel.js
CHANGED
|
@@ -22,6 +22,16 @@ import { YARD_BIZSTEP, BTT_DELIVERY, graiUri } from "./yms-profile.js";
|
|
|
22
22
|
const TRAVEL_MS = 20_000; // 야드 이동
|
|
23
23
|
const DWELL_MS = 30_000; // 도크 체류(상·하차) — depart 이동에 folded
|
|
24
24
|
const WINDOW_DELAY_MS = 40_000; // 도착 후 어포인트먼트 창까지(early arrival → 대기)
|
|
25
|
+
/**
|
|
26
|
+
* 어포인트먼트 **창 길이** — 창이 열린 뒤 이만큼 안에 도크에 들어가야 한다.
|
|
27
|
+
*
|
|
28
|
+
* 표준 대조(2026-08-02): 우리 `windowStartMs` 는 `OpSegmentRequirement.EarliestStartTime` 과 **같은
|
|
29
|
+
* 개념**이고 단위만 다르다(시뮬 ms). **없던 것은 창의 끝** — `LatestEndTime` 에 해당한다. 그래서
|
|
30
|
+
* 어포인트먼트도 납기를 갖게 되고, 야드에서도 "늦었나" 를 물을 수 있다.
|
|
31
|
+
*
|
|
32
|
+
* 창 길이는 표준이 정하지 않는다(현장 계약 사항) — 데모 기본값이고, 마스터가 말해 주면 그것을 쓴다.
|
|
33
|
+
*/
|
|
34
|
+
const WINDOW_LENGTH_MS = 20 * 60_000;
|
|
25
35
|
const CARGO_PER_TRAILER = 2; // 트레일러 적재/하역 화물(SSCC) 수
|
|
26
36
|
const CP = '0614141';
|
|
27
37
|
export class YmsKernel extends FlowEngine {
|
|
@@ -36,11 +46,11 @@ export class YmsKernel extends FlowEngine {
|
|
|
36
46
|
this.mode = mode;
|
|
37
47
|
}
|
|
38
48
|
/** 인바운드(하차) 자극 — 화물 적재된 트레일러 도착. */
|
|
39
|
-
onArrival(_spec) { this.spawnAppointment('appointment'); }
|
|
49
|
+
onArrival(_spec) { this.spawnAppointment('appointment', _spec); }
|
|
40
50
|
/** 아웃바운드(상차) 자극 — 빈 트레일러 도착(도크에서 staging 화물 적재 예정). */
|
|
41
|
-
onOrder(_spec) { this.spawnAppointment('appointment-out'); }
|
|
51
|
+
onOrder(_spec) { this.spawnAppointment('appointment-out', _spec); }
|
|
42
52
|
/** 게이트-인 — 트레일러 도착 → 어포인트먼트(도크도어 배정 + 창). drop 은 야드 주차 태스크, live 는 게이트 대기. */
|
|
43
|
-
spawnAppointment(kind) {
|
|
53
|
+
spawnAppointment(kind, spec) {
|
|
44
54
|
const gate = this.locationByType('gate');
|
|
45
55
|
const doors = [...this.locations.values()].filter(n => n.type === 'dock-door').sort((a, b) => a.id.localeCompare(b.id));
|
|
46
56
|
if (!gate || doors.length === 0)
|
|
@@ -59,7 +69,14 @@ export class YmsKernel extends FlowEngine {
|
|
|
59
69
|
const door = doors[this.doorRR++ % doors.length];
|
|
60
70
|
const id = `order-${++this.orderSeq}`;
|
|
61
71
|
const appt = gdtiUri(CP, '404', ++this.soSeq);
|
|
62
|
-
const order = { id, kind, status: 'created', requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [epc], picked: [], dockDoor: door.id,
|
|
72
|
+
const order = { id, kind, status: 'created', requested: 1, fulfilled: 0, bizTransaction: appt, allocated: [epc], picked: [], dockDoor: door.id,
|
|
73
|
+
/* 내부 스케줄 게이트(시뮬 ms) — 표준 `EarliestStartTime` 과 같은 개념의 우리 단위. */
|
|
74
|
+
windowStartMs: this.clockMs + WINDOW_DELAY_MS,
|
|
75
|
+
/* 표준 필드로도 함께 낸다 — 소비처(화면·집계·미러)는 표준 축으로 읽는다.
|
|
76
|
+
`startTime` = 창이 열리는 때 · `endTime` = 창이 닫히는 때(= 납기). */
|
|
77
|
+
startTime: new Date(Date.parse(this.now()) + WINDOW_DELAY_MS).toISOString(),
|
|
78
|
+
endTime: new Date(Date.parse(this.now()) + WINDOW_DELAY_MS + WINDOW_LENGTH_MS).toISOString(),
|
|
79
|
+
...(spec?.priority !== undefined ? { priority: spec.priority } : {}) };
|
|
63
80
|
this.orders.set(id, order);
|
|
64
81
|
this.apptMode.set(id, this.mode);
|
|
65
82
|
this.emitOrder(order);
|
|
@@ -117,7 +134,11 @@ export class YmsKernel extends FlowEngine {
|
|
|
117
134
|
}
|
|
118
135
|
/** 태스크 완료 — 목적지 타입으로 분기: yard-slot=주차, dock-door=상/하차+depart, gate=출차. */
|
|
119
136
|
onTaskComplete(t) {
|
|
120
|
-
const trailer = this.
|
|
137
|
+
const trailer = this.itemByRef(t.itemEpc);
|
|
138
|
+
/* 여기 도달했다면 코어가 이미 물품을 확인했다(주체가 사라진 작업은 완료 전에 접힌다).
|
|
139
|
+
그래도 단정(`!`)은 쓰지 않는다 — 계약이 바뀌면 조용히 틀리는 대신 분명히 멈춘다. */
|
|
140
|
+
if (!trailer)
|
|
141
|
+
throw new Error(`task ${t.id}: item "${t.itemEpc}" vanished between the core check and the domain hook`);
|
|
121
142
|
const from = this.locations.get(t.fromNode);
|
|
122
143
|
const to = this.locations.get(t.toNode);
|
|
123
144
|
from.occupancy--;
|