@operato/twin-kernel 0.7.63 → 0.7.65
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 +25 -0
- package/dist/face2-adapter.d.ts +34 -1
- package/dist/face2-adapter.js +47 -0
- package/dist/flow-engine.d.ts +9 -0
- package/dist/flow-engine.js +51 -1
- package/dist/observed-reducer.d.ts +46 -0
- package/dist/observed-reducer.js +64 -1
- package/dist/operational-ingest.js +12 -2
- package/dist-cjs/index.cjs +105 -3
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -5,6 +5,31 @@ export interface CanonicalEnvelope<T = unknown> {
|
|
|
5
5
|
eventTime: ISOTime;
|
|
6
6
|
tenantId: string;
|
|
7
7
|
correlationId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* **사람이 그때 적어 둔 말** — 연결된 시스템에서 온 자유 문장.
|
|
10
|
+
*
|
|
11
|
+
* ── 왜 이 자리가 필요한가 (2026-08-25) ────────────────────────────────────
|
|
12
|
+
* 재고 조정만은 **원인이 트윈의 모델 밖에 있다.** 다른 이동은 원인을 트윈이 스스로 압니다 —
|
|
13
|
+
* 꺼내 간 것은 작업 지시가 시켰고, 적치는 입고가 시켰습니다. 그런데 조정은 **사람이 판단한 것**이고,
|
|
14
|
+
* 그 이유는 그 사람이 적은 문장에만 있습니다(「실재고로 조정(192kg→198kg)」).
|
|
15
|
+
*
|
|
16
|
+
* 그 문장이 없으면 트윈은 「6kg 이 늘었다」까지만 알고 **왜인지 영원히 답하지 못합니다.**
|
|
17
|
+
*
|
|
18
|
+
* ── 왜 봉투에 두고 상태에 두지 않나 ──────────────────────────────────────
|
|
19
|
+
* 그 문장은 **지난 사건에 대한 사실**이고 지금 수량에 대한 사실이 아닙니다. 상태에 두면 조정 열 번에
|
|
20
|
+
* 문장 열 개가 쌓여 시간에 비례해 자랍니다. 지난 기록이 답하는 물음입니다.
|
|
21
|
+
*
|
|
22
|
+
* 그리고 EPCIS 사건 안에 넣지 않습니다 — 표준에 그 칸이 없고, 이름을 지어 넣으면 그 사건이
|
|
23
|
+
* 표준을 벗어납니다. 봉투는 우리 것이므로 여기가 맞고, 지난 기록에는 봉투가 그대로 남습니다.
|
|
24
|
+
*
|
|
25
|
+
* ── 커널은 읽지 않습니다 ─────────────────────────────────────────────────
|
|
26
|
+
* 자유 문장이므로 어떤 판단에도 쓰지 않습니다. 나르기만 합니다. 비어 있으면 이 칸을 만들지
|
|
27
|
+
* 않습니다 — 빈 문장은 「적지 않았다」와 다르게 보입니다.
|
|
28
|
+
*
|
|
29
|
+
* 표준 근거: ISA-95 가 거의 모든 타입에 두는 `Description`(DescriptionType). 사람의 글을 담는
|
|
30
|
+
* 표준 자리이고, EPCIS 핵심에는 없어 확장으로 갑니다.
|
|
31
|
+
*/
|
|
32
|
+
description?: string;
|
|
8
33
|
data: T;
|
|
9
34
|
}
|
|
10
35
|
/**
|
package/dist/face2-adapter.d.ts
CHANGED
|
@@ -2,6 +2,34 @@ import type { CanonicalEnvelope } from './contract.ts';
|
|
|
2
2
|
import type { EpcisEvent } from './epcis.ts';
|
|
3
3
|
/** 매핑 값: 리터럴, 또는 소스 필드 참조("$.field.path"). */
|
|
4
4
|
export type MapValue = string;
|
|
5
|
+
/**
|
|
6
|
+
* **정정 선언 매핑** — 「이 사건은 없던 일이 되었다」를 표준 어휘로 옮기는 자리.
|
|
7
|
+
*
|
|
8
|
+
* ── 왜 뒤늦게 생겼나 (2026-08-25) ───────────────────────────────────────────
|
|
9
|
+
* `quantityList` · `ilmd` 와 **똑같은 모양의 결함**이었다. 받는 쪽은 처음부터 다 있었다 —
|
|
10
|
+
* 타입(`ErrorDeclaration`) · 검증(선언 시각·정정 대상 검사) · 리듀서(정정 목록에 적고 상태는
|
|
11
|
+
* 흔들지 않는다). 옮길 자리만 없었다.
|
|
12
|
+
*
|
|
13
|
+
* 실 시스템은 잘못 보낸 것을 정정한다. 그 정정을 실을 자리가 없으면 두 길뿐인데 둘 다 나쁘다:
|
|
14
|
+
* 취소를 **새 사실**로 보내 재고를 두 번 흔들거나, 아예 보내지 않아 지난 기록이 일어나지 않은 일을
|
|
15
|
+
* 일어난 것으로 계속 말하게 한다. 표준이 세 번째 길을 정해 두었다 — 앞선 사건을 지우지 않고
|
|
16
|
+
* 「정정되었다」를 함께 남긴다(EPCIS 2.0 §7.5).
|
|
17
|
+
*
|
|
18
|
+
* **세 값은 함께여야 뜻이 있다.** 그래서 평평한 세 필드가 아니라 묶음 하나로 둔다 — 반쯤 채운
|
|
19
|
+
* 정정 선언은 정정이 아니다.
|
|
20
|
+
*
|
|
21
|
+
* 이유(`reason`)는 CBV 어휘다(`did_not_occur` · `incorrect_data`). 연결된 시스템의 낱말
|
|
22
|
+
* (`UNDO_UNLOADING` 같은 것)을 그 둘 중 하나로 옮기는 것은 커넥터의 일이다 — 여기서 그 번역을
|
|
23
|
+
* 대신하면 시스템마다 다른 규칙이 이 파일에 쌓인다.
|
|
24
|
+
*/
|
|
25
|
+
export interface ErrorDeclarationMapping {
|
|
26
|
+
/** 정정을 **선언한** 시각. 사건이 일어난 시각이 아니다 — 없으면 지어내지 않고 이유와 함께 알린다. */
|
|
27
|
+
declarationTime: MapValue;
|
|
28
|
+
/** CBV 이유 어휘. 연결된 시스템이 이유를 말하지 않으면 비워 둔다(둘 중 하나로 짐작하지 않는다). */
|
|
29
|
+
reason?: MapValue;
|
|
30
|
+
/** 정정이 가리키는 원래 사건들의 `eventID`("$.correctiveEventIDs") — 문자열 배열. */
|
|
31
|
+
correctiveEventIDs?: MapValue;
|
|
32
|
+
}
|
|
5
33
|
/** ObjectEvent 매핑 스펙(선언적). */
|
|
6
34
|
export interface ObjectEventMapping {
|
|
7
35
|
type: 'ObjectEvent';
|
|
@@ -47,6 +75,8 @@ export interface ObjectEventMapping {
|
|
|
47
75
|
ilmd?: MapValue;
|
|
48
76
|
readPoint?: MapValue;
|
|
49
77
|
bizLocation?: MapValue;
|
|
78
|
+
/** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
|
|
79
|
+
errorDeclaration?: ErrorDeclarationMapping;
|
|
50
80
|
}
|
|
51
81
|
/**
|
|
52
82
|
* AggregationEvent 매핑 스펙 — **담김의 사실**(무엇이 무엇에 실렸나).
|
|
@@ -74,6 +104,8 @@ export interface AggregationEventMapping {
|
|
|
74
104
|
childQuantityList?: MapValue;
|
|
75
105
|
readPoint?: MapValue;
|
|
76
106
|
bizLocation?: MapValue;
|
|
107
|
+
/** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
|
|
108
|
+
errorDeclaration?: ErrorDeclarationMapping;
|
|
77
109
|
}
|
|
78
110
|
/**
|
|
79
111
|
* **변환의 사실** — 무엇이 들어가 무엇이 나왔나(EPCIS `TransformationEvent`).
|
|
@@ -104,6 +136,8 @@ export interface TransformationEventMapping {
|
|
|
104
136
|
transformationID?: MapValue;
|
|
105
137
|
readPoint?: MapValue;
|
|
106
138
|
bizLocation?: MapValue;
|
|
139
|
+
/** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
|
|
140
|
+
errorDeclaration?: ErrorDeclarationMapping;
|
|
107
141
|
}
|
|
108
142
|
export type EventMapping = ObjectEventMapping | AggregationEventMapping | TransformationEventMapping;
|
|
109
143
|
/**
|
|
@@ -143,7 +177,6 @@ export interface IngestResult {
|
|
|
143
177
|
}[];
|
|
144
178
|
}
|
|
145
179
|
type Rec = Record<string, unknown>;
|
|
146
|
-
/** 단일 레코드 → 정규 EPCIS 이벤트 + 매핑에서 드러난 문제(검증은 `ingest` 가 이어서 한다). */
|
|
147
180
|
export declare function mapRecordChecked(record: Rec, mapping: EventMapping, eventTime: string): {
|
|
148
181
|
event: EpcisEvent;
|
|
149
182
|
errors: string[];
|
package/dist/face2-adapter.js
CHANGED
|
@@ -120,12 +120,49 @@ function resolveObject(v, record, field, errors) {
|
|
|
120
120
|
return Object.keys(o).length ? o : undefined;
|
|
121
121
|
}
|
|
122
122
|
/** 단일 레코드 → 정규 EPCIS 이벤트 + 매핑에서 드러난 문제(검증은 `ingest` 가 이어서 한다). */
|
|
123
|
+
/**
|
|
124
|
+
* 정정 선언 묶음 → 표준 `ErrorDeclaration`.
|
|
125
|
+
*
|
|
126
|
+
* 선언 시각이 없으면 **이유와 함께 알리고 붙이지 않는다.** 사건 시각으로 메우고 싶어지는 자리인데,
|
|
127
|
+
* 그러면 「언제 정정했나」가 「언제 일어났나」와 같아져 둘을 다시 갈라낼 방법이 없다.
|
|
128
|
+
*/
|
|
129
|
+
function resolveErrorDeclaration(spec, record, errors) {
|
|
130
|
+
if (!spec)
|
|
131
|
+
return undefined;
|
|
132
|
+
const declarationTime = resolve(spec.declarationTime, record, 'errorDeclaration.declarationTime', errors);
|
|
133
|
+
const reason = resolve(spec.reason, record, 'errorDeclaration.reason', errors);
|
|
134
|
+
const correctiveEventIDs = resolveList(spec.correctiveEventIDs, record, 'errorDeclaration.correctiveEventIDs', errors);
|
|
135
|
+
/*
|
|
136
|
+
* ★ **자리가 있다는 것과 정정이라는 것은 다르다.**
|
|
137
|
+
*
|
|
138
|
+
* 매핑은 한 번 적고 모든 레코드가 그것을 지난다. 정정은 드문 사건이므로 **거의 모든 레코드에는 이
|
|
139
|
+
* 세 값이 없다.** 그때 오류를 내면 평범한 관측 전부가 매핑 오류를 달게 되고, 라이브가 통째로
|
|
140
|
+
* 막힌다 — 자리를 낸 것이 곧 통로를 막는 일이 된다.
|
|
141
|
+
*
|
|
142
|
+
* 그래서 아무 말도 없으면 정정이 아닌 것이다. **반쯤 말한 것**만 오류다: 이유나 정정 대상은 있는데
|
|
143
|
+
* 선언 시각이 없으면 정정하려던 것이 분명한데 언제인지 모르는 것이고, 그 시각은 지어낼 수 없다.
|
|
144
|
+
*/
|
|
145
|
+
if (!declarationTime) {
|
|
146
|
+
if (reason || correctiveEventIDs.length) {
|
|
147
|
+
errors.push('errorDeclaration 이 반쯤 왔다 — 이유·정정 대상은 있는데 declarationTime 이 없다(정정 시각은 지어낼 수 없다)');
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
declarationTime,
|
|
153
|
+
...(reason ? { reason } : {}),
|
|
154
|
+
...(correctiveEventIDs.length ? { correctiveEventIDs } : {})
|
|
155
|
+
};
|
|
156
|
+
}
|
|
123
157
|
export function mapRecordChecked(record, mapping, eventTime) {
|
|
124
158
|
const errors = [];
|
|
125
159
|
const bizStep = resolve(mapping.bizStep, record, 'bizStep', errors) ?? '';
|
|
126
160
|
const disposition = resolve(mapping.disposition, record, 'disposition', errors);
|
|
127
161
|
const readPoint = resolve(mapping.readPoint, record, 'readPoint', errors);
|
|
128
162
|
const bizLocation = resolve(mapping.bizLocation, record, 'bizLocation', errors);
|
|
163
|
+
/* 정정 선언은 표준 **헤더**의 것이므로 세 종류 모두에 담긴다(§7.5). 한 종류에만 내면 같은 결함이
|
|
164
|
+
나머지 둘에 남는다 — 그것을 이미 두 번 겪었다(`quantityList` · `ilmd`). */
|
|
165
|
+
const errorDeclaration = resolveErrorDeclaration(mapping.errorDeclaration, record, errors);
|
|
129
166
|
if (mapping.type === 'TransformationEvent') {
|
|
130
167
|
const inputEPCList = resolveList(mapping.inputEPCList, record, 'inputEPCList', errors);
|
|
131
168
|
const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, 'inputQuantityList', errors);
|
|
@@ -156,6 +193,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
|
|
|
156
193
|
...(outputEPCList.length ? { outputEPCList } : {}),
|
|
157
194
|
...(outputQuantityList.length ? { outputQuantityList } : {}),
|
|
158
195
|
...(transformationID ? { transformationID } : {}),
|
|
196
|
+
...(errorDeclaration ? { errorDeclaration } : {}),
|
|
159
197
|
readPoint, bizLocation
|
|
160
198
|
}),
|
|
161
199
|
errors
|
|
@@ -172,6 +210,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
|
|
|
172
210
|
/* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「자식이 없다」고 말하는 것이 된다. */
|
|
173
211
|
...(childEPCs.length ? { childEPCs } : {}),
|
|
174
212
|
...(childQuantityList.length ? { childQuantityList } : {}),
|
|
213
|
+
...(errorDeclaration ? { errorDeclaration } : {}),
|
|
175
214
|
readPoint, bizLocation
|
|
176
215
|
}),
|
|
177
216
|
errors
|
|
@@ -192,6 +231,7 @@ export function mapRecordChecked(record, mapping, eventTime) {
|
|
|
192
231
|
epcList: epc ? [epc] : [],
|
|
193
232
|
...(quantityList.length ? { quantityList } : {}),
|
|
194
233
|
...(ilmd ? { ilmd } : {}),
|
|
234
|
+
...(errorDeclaration ? { errorDeclaration } : {}),
|
|
195
235
|
readPoint, bizLocation
|
|
196
236
|
}),
|
|
197
237
|
errors
|
|
@@ -225,11 +265,18 @@ export function ingest(records, rules, opts) {
|
|
|
225
265
|
rejected.push({ record, errors });
|
|
226
266
|
continue;
|
|
227
267
|
}
|
|
268
|
+
/*
|
|
269
|
+
* 사람이 적어 둔 말은 **봉투에** 싣는다(§`CanonicalEnvelope.description`) — EPCIS 사건 안에 넣으면
|
|
270
|
+
* 그 사건이 표준을 벗어난다. 지난 기록에는 봉투가 그대로 남으므로 잃지 않는다.
|
|
271
|
+
* 비어 있으면 이 칸을 만들지 않는다 — 빈 문장은 「적지 않았다」와 다르게 보인다.
|
|
272
|
+
*/
|
|
273
|
+
const note = typeof record['description'] === 'string' ? String(record['description']).trim() : '';
|
|
228
274
|
accepted.push({
|
|
229
275
|
eventId: `${opts.tenantId}-ingest-${++seq}`,
|
|
230
276
|
eventType: `epcis.${ev.type}`,
|
|
231
277
|
eventTime: ev.eventTime,
|
|
232
278
|
tenantId: opts.tenantId,
|
|
279
|
+
...(note ? { description: note } : {}),
|
|
233
280
|
data: ev
|
|
234
281
|
});
|
|
235
282
|
}
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -365,6 +365,15 @@ export declare function deriveAttentions(view: {
|
|
|
365
365
|
goodCount?: number;
|
|
366
366
|
scrapCount?: number;
|
|
367
367
|
offShift?: boolean;
|
|
368
|
+
/**
|
|
369
|
+
* 이 설비 소식이 끊긴 시간, 그리고 그때까지 가장 오래 끊겼던 시간(밀리초).
|
|
370
|
+
* 실제 현장을 따라가는 트윈만 이 값을 넣는다.
|
|
371
|
+
*
|
|
372
|
+
* 값이 없으면 이 항목은 판단하지 않는다. 스스로 상태를 만들어 가는 트윈에는 소식이 끊긴다는
|
|
373
|
+
* 것이 없으므로 값을 넣지 않는다(`tasks` · `now` 도 같은 방식이다).
|
|
374
|
+
*/
|
|
375
|
+
silentMs?: number;
|
|
376
|
+
longestSilenceBeforeMs?: number;
|
|
368
377
|
}[];
|
|
369
378
|
locations: {
|
|
370
379
|
id: string;
|
package/dist/flow-engine.js
CHANGED
|
@@ -106,6 +106,43 @@ thresholds) {
|
|
|
106
106
|
suggestedAction: { code: 'act.repair', command: CMD.resourceRepair, args: { resourceId: m.id } }
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
|
+
/*
|
|
110
|
+
* 이 설비 소식이 지난 간격보다 오래 끊겼다는 신호. 설비가 멈췄다는 뜻이 아니고, 지금 상태를 우리가
|
|
111
|
+
* 모르고 있다는 뜻이다.
|
|
112
|
+
*
|
|
113
|
+
* ── 왜 필요한가 ───────────────────────────────────────────────────────────
|
|
114
|
+
* 연결된 시스템에서 값이 오다가 끊기는 일은 흔하다(통신 두절, 야간 정지, 커넥터 중단). 그때
|
|
115
|
+
* 트윈은 마지막으로 받은 값을 그대로 들고 있고, 화면은 그 값을 지금 상태로 그린다. 어제 오후에
|
|
116
|
+
* 받은 「가동 중」이 오늘 아침에도 「가동 중」으로 보인다. 오류가 나지 않으므로 아무도 확인하지
|
|
117
|
+
* 않는다. 값이 비어 있으면 사람이 의심하지만, 오래된 값이 채워져 있으면 의심하지 않는다.
|
|
118
|
+
*
|
|
119
|
+
* ── 왜 상태 필드가 아니라 신호인가 ────────────────────────────────────────
|
|
120
|
+
* 「가동 중」은 설비의 상태이고, 「그 값을 언제 받았나」는 우리 쪽 사정이다. 두 가지를 한 필드에
|
|
121
|
+
* 담으면 화면과 집계가 구별할 수 없다. 물품 쪽에서 같은 이유로 `seenAtMs` 를 상태에서 뺐다.
|
|
122
|
+
*
|
|
123
|
+
* ── 판단 기준을 우리가 정하지 않는다 ──────────────────────────────────────
|
|
124
|
+
* 「몇 분 넘게 값이 없으면 이상하다」는 현장마다 다르다. 1초마다 값을 보내는 계측기와 하루 한 번
|
|
125
|
+
* 올라오는 점검 기록이 같은 표에 있다. 기준을 하나로 정하면 앞쪽에는 신호가 너무 많이 서고
|
|
126
|
+
* 뒤쪽에는 서지 않는다. 그래서 그 설비가 지금까지 보여 준 가장 긴 간격을 기준으로 쓴다.
|
|
127
|
+
*
|
|
128
|
+
* 지난 간격을 아직 모르는 설비는 신호를 내지 않는다. 조치도 붙이지 않는다 — 고쳐야 할 것이
|
|
129
|
+
* 설비인지 연결인지 우리가 알 수 없다.
|
|
130
|
+
*/
|
|
131
|
+
if (m.silentMs !== undefined && m.longestSilenceBeforeMs !== undefined && m.silentMs > m.longestSilenceBeforeMs) {
|
|
132
|
+
out.push({
|
|
133
|
+
id: `silent:${m.id}`,
|
|
134
|
+
kind: 'equipment-silent',
|
|
135
|
+
severity: 'medium',
|
|
136
|
+
anchor: { moverId: m.id, locationId: m.location },
|
|
137
|
+
params: {
|
|
138
|
+
moverId: m.id,
|
|
139
|
+
...(m.location ? { locationId: m.location } : {}),
|
|
140
|
+
silentMs: m.silentMs,
|
|
141
|
+
longestBeforeMs: m.longestSilenceBeforeMs,
|
|
142
|
+
...(m.status ? { lastStatus: m.status } : {})
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
109
146
|
}
|
|
110
147
|
for (const n of view.locations) {
|
|
111
148
|
/*
|
|
@@ -1773,8 +1810,21 @@ export class FlowEngine {
|
|
|
1773
1810
|
criteriaOf.set(n.id, cs);
|
|
1774
1811
|
}
|
|
1775
1812
|
}
|
|
1813
|
+
/*
|
|
1814
|
+
* 소식이 끊긴 시간은 상태에 없고 받은 기록에만 있으므로 리듀서에게 묻는다. 스스로 상태를 만들어
|
|
1815
|
+
* 가는 트윈에는 그 리듀서가 없고, 그때는 값을 넣지 않으므로 이 신호도 서지 않는다.
|
|
1816
|
+
*
|
|
1817
|
+
* 앞날을 계산하는 사본(`fork`)에서도 묻지 않는다. 사본은 `structuredClone` 으로 만들어서 리듀서가
|
|
1818
|
+
* 메서드 없는 객체가 되고(§`ItemStore.clone`), 그대로 부르면 오류가 난다. 실제로 테스트 네 건이
|
|
1819
|
+
* 그렇게 실패했다. 다만 결과는 맞다 — 앞날 계산은 자기 시계로 진행하므로 소식이 끊긴다는 것이
|
|
1820
|
+
* 없다. 소식이 끊긴 사실은 현장을 따라가는 트윈이 알리고, 앞날 계산은 그 뒤의 일이다.
|
|
1821
|
+
*/
|
|
1822
|
+
const silence = typeof this.observer?.equipmentSilence === 'function' ? this.observer.equipmentSilence() : undefined;
|
|
1776
1823
|
const out = deriveAttentions({
|
|
1777
|
-
equipment: [...this.equipment.values()]
|
|
1824
|
+
equipment: [...this.equipment.values()].map(m => {
|
|
1825
|
+
const s = silence?.get(m.id);
|
|
1826
|
+
return s ? { ...m, silentMs: s.silentMs, longestSilenceBeforeMs: s.longestBeforeMs } : m;
|
|
1827
|
+
}),
|
|
1778
1828
|
locations: [...this.locations.values()].map(n => {
|
|
1779
1829
|
const criteria = criteriaOf.get(n.id);
|
|
1780
1830
|
return criteria?.length ? { ...n, criteria } : n;
|
|
@@ -156,12 +156,35 @@ export interface ReducerCheckpoint {
|
|
|
156
156
|
id: string;
|
|
157
157
|
values: LocationObservation[];
|
|
158
158
|
}[];
|
|
159
|
+
/**
|
|
160
|
+
* 설비마다 두 값 — 마지막으로 값을 받은 시각, 그리고 그때까지 값이 가장 오래 끊겼던 길이.
|
|
161
|
+
* §`equipmentSilence` 가 이 값으로 판단한다.
|
|
162
|
+
*
|
|
163
|
+
* 저장해 두어야 한다. 저장하지 않으면 재기동한 뒤에 값이 얼마나 끊겼는지 계산할 수 없다.
|
|
164
|
+
* 그러면 화면은 어제 받은 상태를 오늘의 상태로 그린다.
|
|
165
|
+
*/
|
|
166
|
+
heard?: {
|
|
167
|
+
id: string;
|
|
168
|
+
atMs: number;
|
|
169
|
+
longestGapMs?: number;
|
|
170
|
+
}[];
|
|
159
171
|
}
|
|
160
172
|
export declare class ObservedReducer {
|
|
161
173
|
/** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
|
|
162
174
|
private master;
|
|
163
175
|
/** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
|
|
164
176
|
private observations;
|
|
177
|
+
/**
|
|
178
|
+
* 설비별 **마지막으로 들은 시각** — 그 설비의 상태가 아니라 **우리 지식의 상태**다.
|
|
179
|
+
*
|
|
180
|
+
* ── 왜 상태에 넣지 않나 ─────────────────────────────────────────────────────
|
|
181
|
+
* 「가동 중」은 설비에 대한 사실이고 「그 말을 언제 들었나」는 우리에 대한 사실이다. 상태에 섞으면
|
|
182
|
+
* 두 종류가 한 통에 담기고, 자체 구동(시뮬)에는 없는 값이라 두 구동이 어긋난다 — 물품의
|
|
183
|
+
* `seenAtMs` 가 같은 이유로 상태에서 빠졌다(§`snapshot`).
|
|
184
|
+
*
|
|
185
|
+
* 그래서 여기 안에 두고, 밖으로는 **주의 신호**로만 낸다(§`equipmentSilence`).
|
|
186
|
+
*/
|
|
187
|
+
private heard;
|
|
165
188
|
private items;
|
|
166
189
|
private aggregation;
|
|
167
190
|
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
@@ -250,6 +273,29 @@ export declare class ObservedReducer {
|
|
|
250
273
|
* 겹칠 때의 보조 기준이다. 시각이 없으면 판정하지 않는다(있는 것만 가지고 판단한다).
|
|
251
274
|
*/
|
|
252
275
|
private stale;
|
|
276
|
+
/**
|
|
277
|
+
* 이 설비의 값을 받은 시각을 적고, 지난번 값과의 간격 중 가장 긴 것을 함께 기록한다.
|
|
278
|
+
*
|
|
279
|
+
* 간격을 기록하는 이유는 판단 기준을 우리가 정하지 않기 위해서다. 「몇 분 넘게 소식이 없으면
|
|
280
|
+
* 이상하다」는 현장마다 다르다 — 1초마다 값을 보내는 계측기와 하루 한 번 올라오는 점검 기록이
|
|
281
|
+
* 같은 표에 있다. 기준을 하나로 정하면 앞쪽에는 신호가 너무 많이 서고 뒤쪽에는 서지 않는다.
|
|
282
|
+
* 그래서 그 설비가 지금까지 실제로 보여 준 간격을 기준으로 쓴다.
|
|
283
|
+
*/
|
|
284
|
+
private markHeard;
|
|
285
|
+
/**
|
|
286
|
+
* 값이 지난 간격보다 오래 끊긴 설비의 목록. 밖으로 내보내는 값은 이것 하나다.
|
|
287
|
+
*
|
|
288
|
+
* 끊긴 시간은 이 트윈이 마지막으로 어떤 값이든 받은 시각을 기준으로 잰다. 연결이 통째로 멈추면 그
|
|
289
|
+
* 기준도 멈추므로 아무 신호도 서지 않는다. 그때 알려야 할 사실은 설비마다의 상태가 아니라 「연결이
|
|
290
|
+
* 끊겼다」 하나이고, 그것은 유입 장부가 알린다.
|
|
291
|
+
*
|
|
292
|
+
* 지난 간격을 아직 모르는 설비는 목록에 넣지 않는다. 값을 한 번만 받았다면 이 설비가 원래 얼마나
|
|
293
|
+
* 뜸하게 보내는지 알 수 없다.
|
|
294
|
+
*/
|
|
295
|
+
equipmentSilence(): Map<string, {
|
|
296
|
+
silentMs: number;
|
|
297
|
+
longestBeforeMs: number;
|
|
298
|
+
}>;
|
|
253
299
|
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
254
300
|
private lastAt;
|
|
255
301
|
/**
|
package/dist/observed-reducer.js
CHANGED
|
@@ -43,6 +43,17 @@ export class ObservedReducer {
|
|
|
43
43
|
master = new Map();
|
|
44
44
|
/** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
|
|
45
45
|
observations = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* 설비별 **마지막으로 들은 시각** — 그 설비의 상태가 아니라 **우리 지식의 상태**다.
|
|
48
|
+
*
|
|
49
|
+
* ── 왜 상태에 넣지 않나 ─────────────────────────────────────────────────────
|
|
50
|
+
* 「가동 중」은 설비에 대한 사실이고 「그 말을 언제 들었나」는 우리에 대한 사실이다. 상태에 섞으면
|
|
51
|
+
* 두 종류가 한 통에 담기고, 자체 구동(시뮬)에는 없는 값이라 두 구동이 어긋난다 — 물품의
|
|
52
|
+
* `seenAtMs` 가 같은 이유로 상태에서 빠졌다(§`snapshot`).
|
|
53
|
+
*
|
|
54
|
+
* 그래서 여기 안에 두고, 밖으로는 **주의 신호**로만 낸다(§`equipmentSilence`).
|
|
55
|
+
*/
|
|
56
|
+
heard = new Map();
|
|
46
57
|
items = new Map();
|
|
47
58
|
aggregation = new Map();
|
|
48
59
|
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
@@ -245,6 +256,54 @@ export class ObservedReducer {
|
|
|
245
256
|
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : seen.recorded });
|
|
246
257
|
return false;
|
|
247
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* 이 설비의 값을 받은 시각을 적고, 지난번 값과의 간격 중 가장 긴 것을 함께 기록한다.
|
|
261
|
+
*
|
|
262
|
+
* 간격을 기록하는 이유는 판단 기준을 우리가 정하지 않기 위해서다. 「몇 분 넘게 소식이 없으면
|
|
263
|
+
* 이상하다」는 현장마다 다르다 — 1초마다 값을 보내는 계측기와 하루 한 번 올라오는 점검 기록이
|
|
264
|
+
* 같은 표에 있다. 기준을 하나로 정하면 앞쪽에는 신호가 너무 많이 서고 뒤쪽에는 서지 않는다.
|
|
265
|
+
* 그래서 그 설비가 지금까지 실제로 보여 준 간격을 기준으로 쓴다.
|
|
266
|
+
*/
|
|
267
|
+
markHeard(id, e) {
|
|
268
|
+
const at = Date.parse(String(e.eventTime ?? ''));
|
|
269
|
+
if (!Number.isFinite(at))
|
|
270
|
+
return;
|
|
271
|
+
const prev = this.heard.get(id);
|
|
272
|
+
if (!prev) {
|
|
273
|
+
this.heard.set(id, { atMs: at });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
/* 이미 지난 시각이 담긴 사건은 간격 계산에서 뺀다. 도착이 늦은 사건이고, 값이 끊긴 것이 아니다. */
|
|
277
|
+
if (at <= prev.atMs)
|
|
278
|
+
return;
|
|
279
|
+
const gap = at - prev.atMs;
|
|
280
|
+
this.heard.set(id, { atMs: at, longestGapMs: Math.max(prev.longestGapMs ?? 0, gap) });
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 값이 지난 간격보다 오래 끊긴 설비의 목록. 밖으로 내보내는 값은 이것 하나다.
|
|
284
|
+
*
|
|
285
|
+
* 끊긴 시간은 이 트윈이 마지막으로 어떤 값이든 받은 시각을 기준으로 잰다. 연결이 통째로 멈추면 그
|
|
286
|
+
* 기준도 멈추므로 아무 신호도 서지 않는다. 그때 알려야 할 사실은 설비마다의 상태가 아니라 「연결이
|
|
287
|
+
* 끊겼다」 하나이고, 그것은 유입 장부가 알린다.
|
|
288
|
+
*
|
|
289
|
+
* 지난 간격을 아직 모르는 설비는 목록에 넣지 않는다. 값을 한 번만 받았다면 이 설비가 원래 얼마나
|
|
290
|
+
* 뜸하게 보내는지 알 수 없다.
|
|
291
|
+
*/
|
|
292
|
+
equipmentSilence() {
|
|
293
|
+
const out = new Map();
|
|
294
|
+
const now = this.observedAtMs;
|
|
295
|
+
if (now === undefined)
|
|
296
|
+
return out;
|
|
297
|
+
for (const [id, h] of this.heard) {
|
|
298
|
+
if (h.longestGapMs === undefined)
|
|
299
|
+
continue;
|
|
300
|
+
const silentMs = now - h.atMs;
|
|
301
|
+
if (silentMs <= 0)
|
|
302
|
+
continue;
|
|
303
|
+
out.set(id, { silentMs, longestBeforeMs: h.longestGapMs });
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
248
307
|
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
249
308
|
lastAt = new Map();
|
|
250
309
|
/**
|
|
@@ -333,6 +392,7 @@ export class ObservedReducer {
|
|
|
333
392
|
/* 유효 기간은 **마스터 사실**이므로 침묵을 "없어졌다" 로 읽지 않고 아는 값을 지킨다.
|
|
334
393
|
판정은 여기서 하지 않는다 — `snapshot()` 이 관측 시각으로 `effectivityAt` 을 부른다. */
|
|
335
394
|
const eqPeriod = d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known);
|
|
395
|
+
this.markHeard(d.moverId, e);
|
|
336
396
|
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 } : {}) });
|
|
337
397
|
break;
|
|
338
398
|
}
|
|
@@ -963,7 +1023,9 @@ export class ObservedReducer {
|
|
|
963
1023
|
orders: [...this.orders.values()].map(o => ({ ...o })),
|
|
964
1024
|
acked: [...this.acked],
|
|
965
1025
|
corrections: this.corrections.map(c => ({ ...c })),
|
|
966
|
-
unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v }))
|
|
1026
|
+
unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
|
|
1027
|
+
/* 마지막으로 들은 시각 — 이어받지 않으면 재기동 뒤에 침묵의 길이를 말할 수 없다. */
|
|
1028
|
+
heard: [...this.heard.entries()].map(([id, v]) => ({ id, ...v }))
|
|
967
1029
|
};
|
|
968
1030
|
}
|
|
969
1031
|
/**
|
|
@@ -975,6 +1037,7 @@ export class ObservedReducer {
|
|
|
975
1037
|
this.observations = new Map((cp?.observations ?? []).map(o => [o.id, new Map((o.values ?? []).map(v => [v.propertyId, { ...v }]))]));
|
|
976
1038
|
this.revision = cp?.revision ?? 0;
|
|
977
1039
|
this.master = new Map((cp?.master ?? []).map(n => [n.id, { ...n }]));
|
|
1040
|
+
this.heard = new Map((cp?.heard ?? []).map(h => [h.id, { atMs: h.atMs, ...(h.longestGapMs !== undefined ? { longestGapMs: h.longestGapMs } : {}) }]));
|
|
978
1041
|
this.items = new Map((cp?.items ?? []).map(i => [i.epc, { ...i }]));
|
|
979
1042
|
this.aggregation = new Map((cp?.aggregation ?? []).map(a => [a.parent, [...a.children]]));
|
|
980
1043
|
this.pendingParent = new Map((cp?.pendingParent ?? []).map(x => [x.child, x.parent]));
|
|
@@ -267,8 +267,15 @@ export function ingestOperationalRecords(records, opts) {
|
|
|
267
267
|
const spec = SPECS[kind];
|
|
268
268
|
const r = record;
|
|
269
269
|
const errors = [];
|
|
270
|
-
/*
|
|
271
|
-
|
|
270
|
+
/*
|
|
271
|
+
* 모르는 이름은 거부한다 — 한 글자 틀린 필드가 오류 없이 사라지는 것을 막는다.
|
|
272
|
+
*
|
|
273
|
+
* 둘은 뺀다. **사실의 칸이 아니라 봉투의 칸**이다: `at` 은 그 사실이 일어난 시각이고,
|
|
274
|
+
* `description` 은 사람이 그때 적어 둔 말이다(§`CanonicalEnvelope.description`). 사실 칸으로
|
|
275
|
+
* 검사하면 명세마다 같은 두 줄을 적어야 하고, 한 곳을 빠뜨리면 그 통로만 거부한다.
|
|
276
|
+
*/
|
|
277
|
+
const ENVELOPE_FIELDS = ['at', 'description'];
|
|
278
|
+
const unknown = Object.keys(r).filter(k => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === undefined);
|
|
272
279
|
if (unknown.length)
|
|
273
280
|
errors.push(`${kind}: 계약에 없는 필드 — ${unknown.join(', ')}`);
|
|
274
281
|
for (const name of spec.required) {
|
|
@@ -357,11 +364,14 @@ export function ingestOperationalRecords(records, opts) {
|
|
|
357
364
|
rejected.push({ record, errors });
|
|
358
365
|
continue;
|
|
359
366
|
}
|
|
367
|
+
/* 사람이 적어 둔 말 — 봉투에 싣는다(§`CanonicalEnvelope.description`). 물류 쪽과 같은 자리다. */
|
|
368
|
+
const note = typeof r.description === 'string' ? String(r.description).trim() : '';
|
|
360
369
|
accepted.push({
|
|
361
370
|
eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
|
|
362
371
|
eventType: spec.eventType,
|
|
363
372
|
eventTime: new Date(atMs).toISOString(),
|
|
364
373
|
tenantId: opts.tenantId,
|
|
374
|
+
...(note ? { description: note } : {}),
|
|
365
375
|
data
|
|
366
376
|
});
|
|
367
377
|
}
|
package/dist-cjs/index.cjs
CHANGED
|
@@ -1389,6 +1389,17 @@ var ObservedReducer = class {
|
|
|
1389
1389
|
master = /* @__PURE__ */ new Map();
|
|
1390
1390
|
/** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
|
|
1391
1391
|
observations = /* @__PURE__ */ new Map();
|
|
1392
|
+
/**
|
|
1393
|
+
* 설비별 **마지막으로 들은 시각** — 그 설비의 상태가 아니라 **우리 지식의 상태**다.
|
|
1394
|
+
*
|
|
1395
|
+
* ── 왜 상태에 넣지 않나 ─────────────────────────────────────────────────────
|
|
1396
|
+
* 「가동 중」은 설비에 대한 사실이고 「그 말을 언제 들었나」는 우리에 대한 사실이다. 상태에 섞으면
|
|
1397
|
+
* 두 종류가 한 통에 담기고, 자체 구동(시뮬)에는 없는 값이라 두 구동이 어긋난다 — 물품의
|
|
1398
|
+
* `seenAtMs` 가 같은 이유로 상태에서 빠졌다(§`snapshot`).
|
|
1399
|
+
*
|
|
1400
|
+
* 그래서 여기 안에 두고, 밖으로는 **주의 신호**로만 낸다(§`equipmentSilence`).
|
|
1401
|
+
*/
|
|
1402
|
+
heard = /* @__PURE__ */ new Map();
|
|
1392
1403
|
items = /* @__PURE__ */ new Map();
|
|
1393
1404
|
aggregation = /* @__PURE__ */ new Map();
|
|
1394
1405
|
/** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
|
|
@@ -1566,6 +1577,48 @@ var ObservedReducer = class {
|
|
|
1566
1577
|
this.lastAt.set(key, { at, recorded: Number.isFinite(recorded) ? recorded : seen.recorded });
|
|
1567
1578
|
return false;
|
|
1568
1579
|
}
|
|
1580
|
+
/**
|
|
1581
|
+
* 이 설비의 값을 받은 시각을 적고, 지난번 값과의 간격 중 가장 긴 것을 함께 기록한다.
|
|
1582
|
+
*
|
|
1583
|
+
* 간격을 기록하는 이유는 판단 기준을 우리가 정하지 않기 위해서다. 「몇 분 넘게 소식이 없으면
|
|
1584
|
+
* 이상하다」는 현장마다 다르다 — 1초마다 값을 보내는 계측기와 하루 한 번 올라오는 점검 기록이
|
|
1585
|
+
* 같은 표에 있다. 기준을 하나로 정하면 앞쪽에는 신호가 너무 많이 서고 뒤쪽에는 서지 않는다.
|
|
1586
|
+
* 그래서 그 설비가 지금까지 실제로 보여 준 간격을 기준으로 쓴다.
|
|
1587
|
+
*/
|
|
1588
|
+
markHeard(id, e) {
|
|
1589
|
+
const at = Date.parse(String(e.eventTime ?? ""));
|
|
1590
|
+
if (!Number.isFinite(at)) return;
|
|
1591
|
+
const prev = this.heard.get(id);
|
|
1592
|
+
if (!prev) {
|
|
1593
|
+
this.heard.set(id, { atMs: at });
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (at <= prev.atMs) return;
|
|
1597
|
+
const gap = at - prev.atMs;
|
|
1598
|
+
this.heard.set(id, { atMs: at, longestGapMs: Math.max(prev.longestGapMs ?? 0, gap) });
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* 값이 지난 간격보다 오래 끊긴 설비의 목록. 밖으로 내보내는 값은 이것 하나다.
|
|
1602
|
+
*
|
|
1603
|
+
* 끊긴 시간은 이 트윈이 마지막으로 어떤 값이든 받은 시각을 기준으로 잰다. 연결이 통째로 멈추면 그
|
|
1604
|
+
* 기준도 멈추므로 아무 신호도 서지 않는다. 그때 알려야 할 사실은 설비마다의 상태가 아니라 「연결이
|
|
1605
|
+
* 끊겼다」 하나이고, 그것은 유입 장부가 알린다.
|
|
1606
|
+
*
|
|
1607
|
+
* 지난 간격을 아직 모르는 설비는 목록에 넣지 않는다. 값을 한 번만 받았다면 이 설비가 원래 얼마나
|
|
1608
|
+
* 뜸하게 보내는지 알 수 없다.
|
|
1609
|
+
*/
|
|
1610
|
+
equipmentSilence() {
|
|
1611
|
+
const out = /* @__PURE__ */ new Map();
|
|
1612
|
+
const now = this.observedAtMs;
|
|
1613
|
+
if (now === void 0) return out;
|
|
1614
|
+
for (const [id, h] of this.heard) {
|
|
1615
|
+
if (h.longestGapMs === void 0) continue;
|
|
1616
|
+
const silentMs = now - h.atMs;
|
|
1617
|
+
if (silentMs <= 0) continue;
|
|
1618
|
+
out.set(id, { silentMs, longestBeforeMs: h.longestGapMs });
|
|
1619
|
+
}
|
|
1620
|
+
return out;
|
|
1621
|
+
}
|
|
1569
1622
|
/** 대상별 마지막 반영 시각 — 순서 판정용(대상=EPC·작업·설비·오더 id). */
|
|
1570
1623
|
lastAt = /* @__PURE__ */ new Map();
|
|
1571
1624
|
/**
|
|
@@ -1624,6 +1677,7 @@ var ObservedReducer = class {
|
|
|
1624
1677
|
const known = this.equipment.get(d.moverId);
|
|
1625
1678
|
const homeLocation = d.homeLocation ?? known?.homeLocation;
|
|
1626
1679
|
const eqPeriod = d.effectiveStart || d.effectiveEnd ? effectiveOf(d) : effectiveOf(known);
|
|
1680
|
+
this.markHeard(d.moverId, e);
|
|
1627
1681
|
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 } : {} });
|
|
1628
1682
|
break;
|
|
1629
1683
|
}
|
|
@@ -2063,7 +2117,9 @@ var ObservedReducer = class {
|
|
|
2063
2117
|
orders: [...this.orders.values()].map((o) => ({ ...o })),
|
|
2064
2118
|
acked: [...this.acked],
|
|
2065
2119
|
corrections: this.corrections.map((c) => ({ ...c })),
|
|
2066
|
-
unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v }))
|
|
2120
|
+
unhandled: [...this.unhandled.entries()].map(([eventType, v]) => ({ eventType, ...v })),
|
|
2121
|
+
/* 마지막으로 들은 시각 — 이어받지 않으면 재기동 뒤에 침묵의 길이를 말할 수 없다. */
|
|
2122
|
+
heard: [...this.heard.entries()].map(([id, v]) => ({ id, ...v }))
|
|
2067
2123
|
};
|
|
2068
2124
|
}
|
|
2069
2125
|
/**
|
|
@@ -2077,6 +2133,7 @@ var ObservedReducer = class {
|
|
|
2077
2133
|
);
|
|
2078
2134
|
this.revision = cp?.revision ?? 0;
|
|
2079
2135
|
this.master = new Map((cp?.master ?? []).map((n) => [n.id, { ...n }]));
|
|
2136
|
+
this.heard = new Map((cp?.heard ?? []).map((h) => [h.id, { atMs: h.atMs, ...h.longestGapMs !== void 0 ? { longestGapMs: h.longestGapMs } : {} }]));
|
|
2080
2137
|
this.items = new Map((cp?.items ?? []).map((i) => [i.epc, { ...i }]));
|
|
2081
2138
|
this.aggregation = new Map((cp?.aggregation ?? []).map((a) => [a.parent, [...a.children]]));
|
|
2082
2139
|
this.pendingParent = new Map((cp?.pendingParent ?? []).map((x) => [x.child, x.parent]));
|
|
@@ -3276,12 +3333,30 @@ function resolveObject(v, record, field, errors) {
|
|
|
3276
3333
|
const o = { ...r };
|
|
3277
3334
|
return Object.keys(o).length ? o : void 0;
|
|
3278
3335
|
}
|
|
3336
|
+
function resolveErrorDeclaration(spec, record, errors) {
|
|
3337
|
+
if (!spec) return void 0;
|
|
3338
|
+
const declarationTime = resolve(spec.declarationTime, record, "errorDeclaration.declarationTime", errors);
|
|
3339
|
+
const reason = resolve(spec.reason, record, "errorDeclaration.reason", errors);
|
|
3340
|
+
const correctiveEventIDs = resolveList(spec.correctiveEventIDs, record, "errorDeclaration.correctiveEventIDs", errors);
|
|
3341
|
+
if (!declarationTime) {
|
|
3342
|
+
if (reason || correctiveEventIDs.length) {
|
|
3343
|
+
errors.push("errorDeclaration \uC774 \uBC18\uCBE4 \uC654\uB2E4 \u2014 \uC774\uC720\xB7\uC815\uC815 \uB300\uC0C1\uC740 \uC788\uB294\uB370 declarationTime \uC774 \uC5C6\uB2E4(\uC815\uC815 \uC2DC\uAC01\uC740 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB2E4)");
|
|
3344
|
+
}
|
|
3345
|
+
return void 0;
|
|
3346
|
+
}
|
|
3347
|
+
return {
|
|
3348
|
+
declarationTime,
|
|
3349
|
+
...reason ? { reason } : {},
|
|
3350
|
+
...correctiveEventIDs.length ? { correctiveEventIDs } : {}
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3279
3353
|
function mapRecordChecked(record, mapping, eventTime) {
|
|
3280
3354
|
const errors = [];
|
|
3281
3355
|
const bizStep = resolve(mapping.bizStep, record, "bizStep", errors) ?? "";
|
|
3282
3356
|
const disposition = resolve(mapping.disposition, record, "disposition", errors);
|
|
3283
3357
|
const readPoint = resolve(mapping.readPoint, record, "readPoint", errors);
|
|
3284
3358
|
const bizLocation = resolve(mapping.bizLocation, record, "bizLocation", errors);
|
|
3359
|
+
const errorDeclaration = resolveErrorDeclaration(mapping.errorDeclaration, record, errors);
|
|
3285
3360
|
if (mapping.type === "TransformationEvent") {
|
|
3286
3361
|
const inputEPCList = resolveList(mapping.inputEPCList, record, "inputEPCList", errors);
|
|
3287
3362
|
const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, "inputQuantityList", errors);
|
|
@@ -3306,6 +3381,7 @@ function mapRecordChecked(record, mapping, eventTime) {
|
|
|
3306
3381
|
...outputEPCList.length ? { outputEPCList } : {},
|
|
3307
3382
|
...outputQuantityList.length ? { outputQuantityList } : {},
|
|
3308
3383
|
...transformationID ? { transformationID } : {},
|
|
3384
|
+
...errorDeclaration ? { errorDeclaration } : {},
|
|
3309
3385
|
readPoint,
|
|
3310
3386
|
bizLocation
|
|
3311
3387
|
}),
|
|
@@ -3327,6 +3403,7 @@ function mapRecordChecked(record, mapping, eventTime) {
|
|
|
3327
3403
|
/* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「자식이 없다」고 말하는 것이 된다. */
|
|
3328
3404
|
...childEPCs.length ? { childEPCs } : {},
|
|
3329
3405
|
...childQuantityList.length ? { childQuantityList } : {},
|
|
3406
|
+
...errorDeclaration ? { errorDeclaration } : {},
|
|
3330
3407
|
readPoint,
|
|
3331
3408
|
bizLocation
|
|
3332
3409
|
}),
|
|
@@ -3349,6 +3426,7 @@ function mapRecordChecked(record, mapping, eventTime) {
|
|
|
3349
3426
|
epcList: epc ? [epc] : [],
|
|
3350
3427
|
...quantityList.length ? { quantityList } : {},
|
|
3351
3428
|
...ilmd ? { ilmd } : {},
|
|
3429
|
+
...errorDeclaration ? { errorDeclaration } : {},
|
|
3352
3430
|
readPoint,
|
|
3353
3431
|
bizLocation
|
|
3354
3432
|
}),
|
|
@@ -3377,11 +3455,13 @@ function ingest(records, rules, opts) {
|
|
|
3377
3455
|
rejected.push({ record, errors });
|
|
3378
3456
|
continue;
|
|
3379
3457
|
}
|
|
3458
|
+
const note = typeof record["description"] === "string" ? String(record["description"]).trim() : "";
|
|
3380
3459
|
accepted.push({
|
|
3381
3460
|
eventId: `${opts.tenantId}-ingest-${++seq}`,
|
|
3382
3461
|
eventType: `epcis.${ev.type}`,
|
|
3383
3462
|
eventTime: ev.eventTime,
|
|
3384
3463
|
tenantId: opts.tenantId,
|
|
3464
|
+
...note ? { description: note } : {},
|
|
3385
3465
|
data: ev
|
|
3386
3466
|
});
|
|
3387
3467
|
}
|
|
@@ -3650,6 +3730,21 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
|
|
|
3650
3730
|
suggestedAction: { code: "act.repair", command: CMD.resourceRepair, args: { resourceId: m.id } }
|
|
3651
3731
|
});
|
|
3652
3732
|
}
|
|
3733
|
+
if (m.silentMs !== void 0 && m.longestSilenceBeforeMs !== void 0 && m.silentMs > m.longestSilenceBeforeMs) {
|
|
3734
|
+
out.push({
|
|
3735
|
+
id: `silent:${m.id}`,
|
|
3736
|
+
kind: "equipment-silent",
|
|
3737
|
+
severity: "medium",
|
|
3738
|
+
anchor: { moverId: m.id, locationId: m.location },
|
|
3739
|
+
params: {
|
|
3740
|
+
moverId: m.id,
|
|
3741
|
+
...m.location ? { locationId: m.location } : {},
|
|
3742
|
+
silentMs: m.silentMs,
|
|
3743
|
+
longestBeforeMs: m.longestSilenceBeforeMs,
|
|
3744
|
+
...m.status ? { lastStatus: m.status } : {}
|
|
3745
|
+
}
|
|
3746
|
+
});
|
|
3747
|
+
}
|
|
3653
3748
|
}
|
|
3654
3749
|
for (const n of view.locations) {
|
|
3655
3750
|
for (const c of n.criteria ?? []) {
|
|
@@ -5010,9 +5105,13 @@ var FlowEngine = class {
|
|
|
5010
5105
|
if (cs.length) criteriaOf.set(n.id, cs);
|
|
5011
5106
|
}
|
|
5012
5107
|
}
|
|
5108
|
+
const silence = typeof this.observer?.equipmentSilence === "function" ? this.observer.equipmentSilence() : void 0;
|
|
5013
5109
|
const out = deriveAttentions(
|
|
5014
5110
|
{
|
|
5015
|
-
equipment: [...this.equipment.values()]
|
|
5111
|
+
equipment: [...this.equipment.values()].map((m) => {
|
|
5112
|
+
const s = silence?.get(m.id);
|
|
5113
|
+
return s ? { ...m, silentMs: s.silentMs, longestSilenceBeforeMs: s.longestBeforeMs } : m;
|
|
5114
|
+
}),
|
|
5016
5115
|
locations: [...this.locations.values()].map((n) => {
|
|
5017
5116
|
const criteria = criteriaOf.get(n.id);
|
|
5018
5117
|
return criteria?.length ? { ...n, criteria } : n;
|
|
@@ -9172,7 +9271,8 @@ function ingestOperationalRecords(records, opts) {
|
|
|
9172
9271
|
const spec = SPECS[kind];
|
|
9173
9272
|
const r = record;
|
|
9174
9273
|
const errors = [];
|
|
9175
|
-
const
|
|
9274
|
+
const ENVELOPE_FIELDS = ["at", "description"];
|
|
9275
|
+
const unknown = Object.keys(r).filter((k) => !ENVELOPE_FIELDS.includes(k) && spec.fields[k] === void 0);
|
|
9176
9276
|
if (unknown.length) errors.push(`${kind}: \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uD544\uB4DC \u2014 ${unknown.join(", ")}`);
|
|
9177
9277
|
for (const name of spec.required) {
|
|
9178
9278
|
const v = r[name];
|
|
@@ -9256,11 +9356,13 @@ function ingestOperationalRecords(records, opts) {
|
|
|
9256
9356
|
rejected.push({ record, errors });
|
|
9257
9357
|
continue;
|
|
9258
9358
|
}
|
|
9359
|
+
const note = typeof r.description === "string" ? String(r.description).trim() : "";
|
|
9259
9360
|
accepted.push({
|
|
9260
9361
|
eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
|
|
9261
9362
|
eventType: spec.eventType,
|
|
9262
9363
|
eventTime: new Date(atMs).toISOString(),
|
|
9263
9364
|
tenantId: opts.tenantId,
|
|
9365
|
+
...note ? { description: note } : {},
|
|
9264
9366
|
data
|
|
9265
9367
|
});
|
|
9266
9368
|
}
|
package/package.json
CHANGED