@operato/twin-kernel 0.7.64 → 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.
@@ -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[];
@@ -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
@@ -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;
@@ -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
  /**
@@ -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]));
@@ -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
  }),
@@ -3652,6 +3730,21 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3652
3730
  suggestedAction: { code: "act.repair", command: CMD.resourceRepair, args: { resourceId: m.id } }
3653
3731
  });
3654
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
+ }
3655
3748
  }
3656
3749
  for (const n of view.locations) {
3657
3750
  for (const c of n.criteria ?? []) {
@@ -5012,9 +5105,13 @@ var FlowEngine = class {
5012
5105
  if (cs.length) criteriaOf.set(n.id, cs);
5013
5106
  }
5014
5107
  }
5108
+ const silence = typeof this.observer?.equipmentSilence === "function" ? this.observer.equipmentSilence() : void 0;
5015
5109
  const out = deriveAttentions(
5016
5110
  {
5017
- 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
+ }),
5018
5115
  locations: [...this.locations.values()].map((n) => {
5019
5116
  const criteria = criteriaOf.get(n.id);
5020
5117
  return criteria?.length ? { ...n, criteria } : n;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.64",
3
+ "version": "0.7.65",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {