@operato/twin-kernel 0.2.3 → 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/README.md +46 -5
- package/dist/capability.js +2 -2
- package/dist/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +836 -45
- package/dist/contract.js +519 -7
- package/dist/divergence.d.ts +1 -1
- package/dist/divergence.js +8 -5
- package/dist/domain-catalog.d.ts +4 -4
- package/dist/domain-catalog.js +4 -4
- package/dist/domain-definition.d.ts +50 -5
- package/dist/domain-definition.js +6 -6
- package/dist/epcis.d.ts +12 -0
- package/dist/epcis.js +12 -0
- package/dist/event-journal.d.ts +31 -1
- package/dist/event-journal.js +27 -1
- package/dist/flow-engine.d.ts +323 -36
- package/dist/flow-engine.js +868 -164
- package/dist/forecast.d.ts +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -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 +31 -11
- package/dist/mes-kernel.d.ts +28 -0
- package/dist/mes-kernel.js +83 -29
- package/dist/mes-profile.d.ts +2 -2
- package/dist/mes-profile.js +11 -11
- package/dist/observed-reducer.d.ts +68 -7
- package/dist/observed-reducer.js +248 -41
- package/dist/task-fold.d.ts +2 -2
- package/dist/task-fold.js +4 -4
- package/dist/vocabulary.d.ts +19 -0
- package/dist/vocabulary.js +66 -0
- package/dist/wms-profile.d.ts +2 -2
- package/dist/wms-profile.js +6 -6
- package/dist/yms-kernel.js +36 -15
- package/dist/yms-profile.d.ts +2 -2
- package/dist/yms-profile.js +6 -6
- package/dist-cjs/index.cjs +1618 -270
- package/package.json +1 -1
package/dist/mes-profile.js
CHANGED
|
@@ -10,15 +10,15 @@ export const BTT_PRODORDER = 'urn:epcglobal:cbv:btt:prodorder';
|
|
|
10
10
|
export function sgtinUri(companyPrefix, itemRef, serial) {
|
|
11
11
|
return `urn:epc:id:sgtin:${companyPrefix}.${itemRef}.${serial}`;
|
|
12
12
|
}
|
|
13
|
-
/** MES
|
|
13
|
+
/** MES 자리 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(locationByType/.type). 도메인 SSOT.
|
|
14
14
|
* 트레일러 제조 라인: 자재→프레임 절단→용접→도장→조립→완성차(kernel ROUTE 와 일치). */
|
|
15
|
-
export const
|
|
15
|
+
export const MES_LOCATION_TYPES = ['raw-store', 'cut-station', 'weld-station', 'paint-booth', 'assembly-line', 'fg-store'];
|
|
16
16
|
/*
|
|
17
|
-
* 트윈 타입 서술(ADR-0018 확장) —
|
|
18
|
-
* MES=ISA-95 앵커: 스테이션=WorkCenter, 저장소=bizLocation.
|
|
17
|
+
* 트윈 타입 서술(ADR-0018 확장) — 자리 키는 MES_LOCATION_TYPES 단일 출처에서 파생 + 설비(자원) 타입 추가.
|
|
18
|
+
* MES=ISA-95 앵커: 스테이션=WorkCenter, 저장소=bizLocation. 설비(가공설비)=Equipment/자산(GIAI). key 는 flow resourceType 과 일치.
|
|
19
19
|
*/
|
|
20
|
-
// label 은 언어 중립 i18n 키(twin.type.<key>) — cls(표준 클래스)만
|
|
21
|
-
const
|
|
20
|
+
// label 은 언어 중립 i18n 키(twin.type.<key>) — cls(표준 클래스)만 자리별 메타로 유지. 사람 언어는 표현계층(L2).
|
|
21
|
+
const MES_LOCATION_CLS = {
|
|
22
22
|
'raw-store': { epcis: 'bizLocation' },
|
|
23
23
|
'cut-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
|
|
24
24
|
'weld-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
|
|
@@ -27,9 +27,9 @@ const MES_NODE_CLS = {
|
|
|
27
27
|
'fg-store': { epcis: 'bizLocation' }
|
|
28
28
|
};
|
|
29
29
|
export const MES_TYPES = [
|
|
30
|
-
...
|
|
31
|
-
{ key: 'cutter', role: '
|
|
32
|
-
{ key: 'welder', role: '
|
|
33
|
-
{ key: 'painter', role: '
|
|
34
|
-
{ key: 'assembler', role: '
|
|
30
|
+
...MES_LOCATION_TYPES.map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
|
|
31
|
+
{ key: 'cutter', role: 'equipment', label: 'twin.type.cutter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
|
|
32
|
+
{ key: 'welder', role: 'equipment', label: 'twin.type.welder', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
|
|
33
|
+
{ key: 'painter', role: 'equipment', label: 'twin.type.painter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
|
|
34
|
+
{ key: 'assembler', role: 'equipment', label: 'twin.type.assembler', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
|
|
35
35
|
];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AssetState, BoardDef, CanonicalEnvelope,
|
|
1
|
+
import type { AssetState, BoardDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState } from './contract.ts';
|
|
2
2
|
/**
|
|
3
3
|
* 마스터 동기 — 선언적 로케이션 upsert/remove.
|
|
4
4
|
*
|
|
@@ -8,7 +8,7 @@ import type { AssetState, BoardDef, CanonicalEnvelope, NodeState, ItemState, Mov
|
|
|
8
8
|
*/
|
|
9
9
|
export interface MasterUpdate {
|
|
10
10
|
op: 'upsert' | 'remove';
|
|
11
|
-
|
|
11
|
+
location: {
|
|
12
12
|
id: string;
|
|
13
13
|
type?: string;
|
|
14
14
|
capacity?: number;
|
|
@@ -28,14 +28,14 @@ export interface ProjectedState {
|
|
|
28
28
|
correctiveEventIDs: string[];
|
|
29
29
|
eventID?: string;
|
|
30
30
|
}[];
|
|
31
|
-
|
|
31
|
+
locations: LocationState[];
|
|
32
32
|
items: ItemState[];
|
|
33
33
|
/** 사람 — 등급·교대·투입. 인원을 선언하지 않은 트윈에서는 빈 배열. */
|
|
34
34
|
persons: PersonState[];
|
|
35
35
|
/** 물리 자산(반복사용) — 자산을 선언하지 않은 트윈에서는 빈 배열. */
|
|
36
36
|
assets: AssetState[];
|
|
37
37
|
tasks: TaskState[];
|
|
38
|
-
|
|
38
|
+
equipment: EquipmentState[];
|
|
39
39
|
orders: OrderState[];
|
|
40
40
|
}
|
|
41
41
|
export declare class ObservedReducer {
|
|
@@ -46,20 +46,54 @@ export declare class ObservedReducer {
|
|
|
46
46
|
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
47
47
|
private pendingParent;
|
|
48
48
|
private tasks;
|
|
49
|
-
private
|
|
49
|
+
private equipment;
|
|
50
50
|
private persons;
|
|
51
51
|
private assets;
|
|
52
52
|
private orders;
|
|
53
53
|
revision: number;
|
|
54
54
|
/** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
|
|
55
55
|
private corrections;
|
|
56
|
+
/**
|
|
57
|
+
* 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
|
|
58
|
+
*
|
|
59
|
+
* 상태(`EquipmentState`)에 넣지 않는다: 시뮬 스냅샷도 이것을 내보내지 않으므로 넣으면 두 구동이
|
|
60
|
+
* 갈라진다(적합성 하네스가 `mirrorOnly` 로 잡는다). 이것은 **판정의 입력**이고, 나가는 것은 판정뿐이다.
|
|
61
|
+
*/
|
|
62
|
+
private shifts;
|
|
63
|
+
/** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
|
|
64
|
+
private utcOffsetMinutes?;
|
|
56
65
|
constructor(board: BoardDef);
|
|
66
|
+
/**
|
|
67
|
+
* **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
|
|
68
|
+
*
|
|
69
|
+
* 공장은 바뀐다. 도장 부스를 넷 더 놓고, 라인을 하나 접는다. 그런데 지금까지는 구조가 바뀌면
|
|
70
|
+
* **그 트윈의 저널을 통째로 지우는 것**이 유일한 길이었다 — 안 지우면 옛 이벤트를 새 공장에 대고
|
|
71
|
+
* 접게 되어 이력이 거짓말을 한다. 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
|
|
72
|
+
*
|
|
73
|
+
* 셋째 길이 이것이다: 이벤트가 **자기 구조를 달고** 다니고, 재생은 구조가 바뀌는 지점에서 여기를
|
|
74
|
+
* 불러 갈아탄 뒤 이어 접는다. 그러면 "그때 그 공장의 사실" 로 계속 읽힌다.
|
|
75
|
+
*
|
|
76
|
+
* ── 무엇을 지키고 무엇을 버리는가 ───────────────────────────────────────
|
|
77
|
+
* **관측은 지킨다** — 물품·오더·작업·집합은 구조와 무관한 사실이다(팔레트는 부스를 늘려도 그대로다).
|
|
78
|
+
* **사라진 자원은 버린다** — 없어진 설비의 상태를 계속 들고 있으면 화면이 없는 설비를 그린다.
|
|
79
|
+
* 다만 **몇 개를 버렸는지 돌려준다.** 조용히 사라지면 사용자는 수가 줄어든 것을 눈치채지 못한다.
|
|
80
|
+
*
|
|
81
|
+
* 관측으로 알게 된 자리(`origin: 'observed'`)는 **새 마스터에 없어도 남긴다** — 마스터가 모르는
|
|
82
|
+
* 자리에서 물건이 실제로 보였다는 사실은 구조를 바꾼다고 사라지지 않는다.
|
|
83
|
+
*/
|
|
84
|
+
adoptStructure(board: BoardDef): {
|
|
85
|
+
locationsAdded: number;
|
|
86
|
+
locationsDropped: number;
|
|
87
|
+
equipmentDropped: number;
|
|
88
|
+
personsDropped: number;
|
|
89
|
+
assetsDropped: number;
|
|
90
|
+
};
|
|
57
91
|
/** 마스터 동기 — 로케이션 추가/변경/제거. */
|
|
58
92
|
applyMaster(u: MasterUpdate): void;
|
|
59
93
|
/**
|
|
60
94
|
* 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
|
|
61
95
|
*
|
|
62
|
-
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `
|
|
96
|
+
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `locations` 에는
|
|
63
97
|
* 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
|
|
64
98
|
* 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
|
|
65
99
|
* 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
|
|
@@ -81,6 +115,14 @@ export declare class ObservedReducer {
|
|
|
81
115
|
private stale;
|
|
82
116
|
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
83
117
|
private lastAt;
|
|
118
|
+
/**
|
|
119
|
+
* **미러의 "지금"** — 지금까지 들은 것 중 가장 늦은 발생 시각.
|
|
120
|
+
*
|
|
121
|
+
* 미러는 스스로 시간을 굴리지 않지만, 시각으로만 일어나는 사실(유효 기간 만료)을 판정하려면 기준이
|
|
122
|
+
* 필요하다. 미러의 정직한 기준은 **"내가 마지막으로 들은 시점"** 이다. 아무것도 못 들었으면
|
|
123
|
+
* 판정하지 않는다(모르면 단정하지 않는다).
|
|
124
|
+
*/
|
|
125
|
+
private observedAtMs?;
|
|
84
126
|
/** 이벤트 1건 반영 — eventType 으로 EPCIS vs 운영 델타 분기. */
|
|
85
127
|
apply(e: CanonicalEnvelope): void;
|
|
86
128
|
private applyEpcis;
|
|
@@ -98,6 +140,25 @@ export declare class ObservedReducer {
|
|
|
98
140
|
private mergeItem;
|
|
99
141
|
/** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
|
|
100
142
|
private remove;
|
|
101
|
-
/** 현재 투영 State —
|
|
143
|
+
/** 현재 투영 State — 자리 점유는 아이템 위치 집계로 유도(pure projection). */
|
|
144
|
+
/**
|
|
145
|
+
* 마지막으로 들은 발생 시각(ms) — **관측 구동의 "지금".**
|
|
146
|
+
*
|
|
147
|
+
* 시각으로만 바뀌는 사실(납기 초과·유효 기간·교대)을 판정하려면 기준이 필요하고, 관측 모드에는
|
|
148
|
+
* 굴러가는 시계가 없다. 정직한 기준은 "내가 마지막으로 들은 시점" 이다. 아무것도 못 들었으면
|
|
149
|
+
* `undefined` — 없는 기준으로 단정하지 않는다.
|
|
150
|
+
*/
|
|
151
|
+
get lastObservedMs(): number | undefined;
|
|
152
|
+
/** 선언된 교대만 기억한다 — 아무 선언이 없으면 키를 만들지 않는다(24시간 가용이 기존 거동). */
|
|
153
|
+
private rememberShift;
|
|
154
|
+
/**
|
|
155
|
+
* 지금 근무 시간 밖인가 — 시뮬과 **같은 함수**(`offCalendarAt`)를 미러의 시각으로 부른다.
|
|
156
|
+
*
|
|
157
|
+
* 판정을 델타로 받지 않는 이유는 유효 기간과 같다: 교대 경계는 **이벤트 없이 시각만으로** 넘어가므로,
|
|
158
|
+
* 유휴 상태로 경계를 지난 자원은 실어 보낸 옛 판정에 머문다. 아무것도 못 들었으면 판정하지 않는다.
|
|
159
|
+
*/
|
|
160
|
+
private offShiftPart;
|
|
161
|
+
/** 유효 기간 밖이면 그 이유를 붙인다 — 미러의 시각 기준은 `observedAtMs`(마지막으로 들은 시점). */
|
|
162
|
+
private effectivityPart;
|
|
102
163
|
snapshot(): ProjectedState;
|
|
103
164
|
}
|
package/dist/observed-reducer.js
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
*
|
|
17
17
|
* 두 갈래 이벤트를 함께 접는다:
|
|
18
18
|
* - EPCIS(epcis.*) → 재고/위치/조립 (What/Where)
|
|
19
|
-
* - 운영 델타(task/equipment/order.status) → tasks·
|
|
19
|
+
* - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
|
|
20
20
|
* 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
|
|
21
21
|
*/
|
|
22
|
-
import { OP_EVENT,
|
|
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();
|
|
@@ -39,44 +46,128 @@ export class ObservedReducer {
|
|
|
39
46
|
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
40
47
|
pendingParent = new Map(); // 자식 EPC → 부모(물류단위)
|
|
41
48
|
tasks = new Map();
|
|
42
|
-
|
|
49
|
+
equipment = new Map();
|
|
43
50
|
persons = new Map();
|
|
44
51
|
assets = new Map();
|
|
45
52
|
orders = new Map();
|
|
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) {
|
|
50
|
-
|
|
66
|
+
this.utcOffsetMinutes = board.utcOffsetMinutes;
|
|
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
|
-
//
|
|
53
|
-
for (const m of board
|
|
54
|
-
this.
|
|
69
|
+
// 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
|
|
70
|
+
for (const m of readBoardEquipment(board))
|
|
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,
|
|
58
|
-
|
|
59
|
-
|
|
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);
|
|
81
|
+
for (const a of readBoardAssets(board))
|
|
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) {
|
|
63
154
|
if (u.op === 'remove') {
|
|
64
|
-
this.master.delete(u.
|
|
155
|
+
this.master.delete(u.location.id);
|
|
65
156
|
return;
|
|
66
157
|
}
|
|
67
|
-
const cur = this.master.get(u.
|
|
158
|
+
const cur = this.master.get(u.location.id);
|
|
68
159
|
/* **모르는 용량을 0 으로 뭉개지 않는다.** 0 은 "자리가 없다" 는 사실 주장이고, 미지정은 "모른다" 다.
|
|
69
|
-
* 계약(`
|
|
70
|
-
* 성립하고 배정 정책이 그
|
|
160
|
+
* 계약(`LocationState.capacity`)이 선택 필드로 둔 이유가 이것이며, 0 으로 채우면 포화 판정이 거짓으로
|
|
161
|
+
* 성립하고 배정 정책이 그 자리를 영구히 배제한다. 그리고 upsert 가 **구역 소속(parentId)을 지우지
|
|
71
162
|
* 않는다** — 마스터가 말하지 않은 것은 기존 값을 지키는 것이 upsert 의 뜻이다. */
|
|
72
|
-
const capacity = u.
|
|
73
|
-
const parallelism = u.
|
|
74
|
-
this.master.set(u.
|
|
75
|
-
id: u.
|
|
76
|
-
type: u.
|
|
163
|
+
const capacity = u.location.capacity ?? cur?.capacity;
|
|
164
|
+
const parallelism = u.location.parallelism ?? cur?.parallelism;
|
|
165
|
+
this.master.set(u.location.id, {
|
|
166
|
+
id: u.location.id,
|
|
167
|
+
type: u.location.type ?? cur?.type ?? UNKNOWN_TYPE,
|
|
77
168
|
...(capacity === undefined ? {} : { capacity }),
|
|
78
169
|
...(parallelism === undefined ? {} : { parallelism }),
|
|
79
|
-
...(u.
|
|
170
|
+
...(u.location.parentId ?? cur?.parentId ? { parentId: u.location.parentId ?? cur?.parentId } : {}),
|
|
80
171
|
/* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
|
|
81
172
|
origin: 'master'
|
|
82
173
|
});
|
|
@@ -84,7 +175,7 @@ export class ObservedReducer {
|
|
|
84
175
|
/**
|
|
85
176
|
* 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
|
|
86
177
|
*
|
|
87
|
-
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `
|
|
178
|
+
* 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `locations` 에는
|
|
88
179
|
* 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
|
|
89
180
|
* 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
|
|
90
181
|
* 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
|
|
@@ -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,19 +250,31 @@ 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
|
}
|
|
156
262
|
case OP_EVENT.equipment: {
|
|
157
263
|
const d = e.data;
|
|
158
|
-
if (this.stale(`
|
|
264
|
+
if (this.stale(`eq:${d.moverId}`, e))
|
|
159
265
|
return;
|
|
160
266
|
/* 마스터에 없던 자원도 관측으로 자란다(예전부터 그랬다) — 이제 그 사실을 출처로 표시하고,
|
|
161
267
|
* 자원이 있다고 말하는 자리도 구조로 승격한다(로케이션만 자라지 않던 비대칭 해소). */
|
|
162
268
|
this.touchLocation(d.location);
|
|
163
|
-
const known = this.
|
|
164
|
-
|
|
269
|
+
const known = this.equipment.get(d.moverId);
|
|
270
|
+
/* 소속(homeLocation)은 델타가 말해 주면 그 값, 안 말해 주면 **이미 아는 값을 지킨다** —
|
|
271
|
+
마스터로 알던 소속을 관측 델타 하나가 지워 버리면 그 설비가 롤업에서 통째로 빠진다.
|
|
272
|
+
소속은 자주 바뀌는 사실이 아니므로 침묵을 "소속 없음" 으로 읽지 않는다. */
|
|
273
|
+
const homeLocation = d.homeLocation ?? known?.homeLocation;
|
|
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 } : {}) });
|
|
165
278
|
break;
|
|
166
279
|
}
|
|
167
280
|
case OP_EVENT.person: {
|
|
@@ -169,12 +282,22 @@ export class ObservedReducer {
|
|
|
169
282
|
if (this.stale(`person:${d.personId}`, e))
|
|
170
283
|
return;
|
|
171
284
|
/* 사람도 관측으로 자란다(자원과 같은 정책) — 마스터에 없던 사람이 이벤트에 나오면 승격한다. */
|
|
285
|
+
const known = this.persons.get(d.personId);
|
|
286
|
+
/* 마스터로 알던 사실(등급·위치·속성)은 델타가 말하지 않으면 **지키지 않고 지우면** 안 된다 —
|
|
287
|
+
설비 소속과 같은 규율이다(침묵을 "없어졌다" 로 읽지 않는다). */
|
|
172
288
|
this.persons.set(d.personId, {
|
|
173
289
|
id: d.personId,
|
|
174
|
-
|
|
290
|
+
personnelClassIds: d.personnelClassIds ?? known?.personnelClassIds,
|
|
175
291
|
status: d.status,
|
|
176
292
|
taskId: d.taskId,
|
|
177
|
-
...(d.
|
|
293
|
+
...((d.location ?? known?.location) ? { location: d.location ?? known?.location } : {}),
|
|
294
|
+
...(known?.properties ? { properties: known.properties } : {}),
|
|
295
|
+
...(known?.testSpecificationIds ? { testSpecificationIds: known.testSpecificationIds } : {}),
|
|
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))
|
|
178
301
|
});
|
|
179
302
|
break;
|
|
180
303
|
}
|
|
@@ -185,11 +308,12 @@ export class ObservedReducer {
|
|
|
185
308
|
const cur = this.assets.get(d.assetId);
|
|
186
309
|
this.assets.set(d.assetId, {
|
|
187
310
|
id: d.assetId,
|
|
188
|
-
|
|
311
|
+
assetClassIds: d.assetClassIds ?? cur?.assetClassIds,
|
|
189
312
|
location: d.location ?? cur?.location,
|
|
190
313
|
status: d.status,
|
|
191
314
|
taskId: d.taskId,
|
|
192
|
-
...(d.carrying ? { carrying: d.carrying } : {})
|
|
315
|
+
...(d.carrying ? { carrying: d.carrying } : {}),
|
|
316
|
+
...(d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(cur))
|
|
193
317
|
});
|
|
194
318
|
this.touchLocation(d.location);
|
|
195
319
|
break;
|
|
@@ -206,6 +330,9 @@ export class ObservedReducer {
|
|
|
206
330
|
progress: d.requested ? d.fulfilled / d.requested : 0,
|
|
207
331
|
requested: d.requested, fulfilled: d.fulfilled,
|
|
208
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 } : {}),
|
|
209
336
|
held: d.held
|
|
210
337
|
});
|
|
211
338
|
break;
|
|
@@ -279,20 +406,33 @@ export class ObservedReducer {
|
|
|
279
406
|
const loc = ev.readPoint?.id;
|
|
280
407
|
/* 수량 목록은 **클래스 + 수량 + 단위**를 함께 실어 온다 — 예전에는 클래스만 꺼내고 수량·단위를
|
|
281
408
|
* 버렸다. 클래스가 LGTIN 이면 품번과 로트가 그 안에 있으므로 파서로 뜯는다(문자열을 자르지 않는다). */
|
|
282
|
-
|
|
409
|
+
/* **전부 받는다.** 예전에는 `[0]` 만 써서 나머지를 버렸다 — 표준은 `Quantity` 를 복수로 두고,
|
|
410
|
+
실 데이터는 같은 로트를 여러 단위로 싣는다(100 EA · 250 KG). 버리면 조용히 잘린다. */
|
|
411
|
+
const all = ev.quantityList ?? [];
|
|
412
|
+
const q = all[0];
|
|
283
413
|
this.touchLocation(loc);
|
|
284
414
|
for (const epc of ev.epcList) {
|
|
285
415
|
/* 물품별 순서 판정 — 늦게 온 옛 관측이 최신 위치를 덮지 않게. */
|
|
286
416
|
if (envelope && this.stale(`item:${epc}`, envelope))
|
|
287
417
|
continue;
|
|
288
|
-
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));
|
|
289
419
|
}
|
|
290
420
|
/* 개체 없이 수량만 오는 입고(비직렬 자재) — 표준이 허용하고 검증기도 유효로 판정한다.
|
|
291
421
|
* 이 경우 클래스 식별자 자체가 물품의 키다(로트 관리 자재는 LGTIN 이라 로트별로 갈린다). */
|
|
292
422
|
if (!ev.epcList?.length) {
|
|
293
423
|
for (const qe of ev.quantityList ?? []) {
|
|
294
|
-
if (qe?.epcClass)
|
|
295
|
-
|
|
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));
|
|
296
436
|
}
|
|
297
437
|
}
|
|
298
438
|
}
|
|
@@ -317,8 +457,12 @@ export class ObservedReducer {
|
|
|
317
457
|
}
|
|
318
458
|
return undefined;
|
|
319
459
|
}
|
|
320
|
-
mergeItem(epc, patch, q
|
|
321
|
-
|
|
460
|
+
mergeItem(epc, patch, q,
|
|
461
|
+
/** 이 물품에 대해 선언된 수량 전부(표준 복수). 없으면 주 수량만 남는다. */
|
|
462
|
+
all,
|
|
463
|
+
/** 로트의 부분 식별자(표준 `MaterialSubLot.ID`) — 비직렬 로트가 자리마다 갈릴 때. */
|
|
464
|
+
subLotId) {
|
|
465
|
+
const cur = this.items.get(subLotId ?? epc);
|
|
322
466
|
/* 클래스는 수량 목록에서 오거나, 물품 자신이 클래스 식별자일 수 있다(비직렬 입고). */
|
|
323
467
|
const parsedClass = q?.epcClass ? parseEpc(q.epcClass) : undefined;
|
|
324
468
|
const parsedSelf = parseEpc(epc);
|
|
@@ -327,6 +471,7 @@ export class ObservedReducer {
|
|
|
327
471
|
const classUri = q?.epcClass ?? (parsedSelf.instance ? undefined : epc);
|
|
328
472
|
return {
|
|
329
473
|
epc,
|
|
474
|
+
...(subLotId ? { subLotId } : {}),
|
|
330
475
|
gtin: classUri ?? cur?.gtin,
|
|
331
476
|
gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
|
|
332
477
|
location: patch.location ?? cur?.location ?? '',
|
|
@@ -334,6 +479,20 @@ export class ObservedReducer {
|
|
|
334
479
|
parent: cur?.parent ?? this.pendingParent.get(epc),
|
|
335
480
|
qty: q?.quantity ?? cur?.qty,
|
|
336
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
|
+
})(),
|
|
337
496
|
/* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
|
|
338
497
|
ilmd: patch.ilmd ?? cur?.ilmd,
|
|
339
498
|
expiry: this.expiryOf(patch.ilmd) ?? cur?.expiry,
|
|
@@ -351,7 +510,53 @@ export class ObservedReducer {
|
|
|
351
510
|
this.remove(c);
|
|
352
511
|
}
|
|
353
512
|
}
|
|
354
|
-
/** 현재 투영 State —
|
|
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
|
+
}
|
|
355
560
|
snapshot() {
|
|
356
561
|
const occ = new Map();
|
|
357
562
|
for (const it of this.items.values())
|
|
@@ -359,9 +564,9 @@ export class ObservedReducer {
|
|
|
359
564
|
return {
|
|
360
565
|
revision: this.revision,
|
|
361
566
|
...(this.corrections.length ? { corrections: this.corrections.map(c => ({ ...c })) } : {}),
|
|
362
|
-
|
|
567
|
+
locations: [...this.master.values()].map(n => {
|
|
363
568
|
const occupancy = occ.get(n.id) ?? 0;
|
|
364
|
-
const status =
|
|
569
|
+
const status = locationStatusOf({ occupancy, capacity: n.capacity });
|
|
365
570
|
return {
|
|
366
571
|
id: n.id, type: n.type, occupancy,
|
|
367
572
|
...(status ? { status } : {}),
|
|
@@ -374,10 +579,12 @@ export class ObservedReducer {
|
|
|
374
579
|
}),
|
|
375
580
|
/* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
|
|
376
581
|
items: [...this.items.values()].map(i => ({ ...i })),
|
|
377
|
-
|
|
378
|
-
|
|
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) })),
|
|
379
586
|
tasks: [...this.tasks.values()].map(t => ({ ...t })),
|
|
380
|
-
|
|
587
|
+
equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`) })),
|
|
381
588
|
orders: [...this.orders.values()].map(o => ({ ...o }))
|
|
382
589
|
};
|
|
383
590
|
}
|