@operato/twin-kernel 0.7.32 → 0.7.33

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/index.d.ts CHANGED
@@ -30,6 +30,8 @@ export { YmsKernel } from './yms-kernel.ts';
30
30
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from './mes-kernel.ts';
31
31
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from './ems-kernel.ts';
32
32
  export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from './energy-ingest.ts';
33
+ export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from './operational-ingest.ts';
34
+ export type { OperationalKind, OperationalRecord, OperationalIngestOptions } from './operational-ingest.ts';
33
35
  export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from './energy-attribution.ts';
34
36
  export type { AttributionBasis, AttributionResult, ElectricityCost, EnergyConsumer, EnergyPool, EnergyShare, IntensityInput, IntensityResult, IntensityDenominator, TariffDeclaration, WeightKind, WindowedEnergy } from './energy-attribution.ts';
35
37
  export type { EnergyRecord, EnergyEquipmentRecord, EnergyIngestOptions, EnergyIngestResult } from './energy-ingest.ts';
package/dist/index.js CHANGED
@@ -30,6 +30,8 @@ export { YmsKernel } from "./yms-kernel.js";
30
30
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from "./mes-kernel.js";
31
31
  export { EmsKernel, DEMAND_WINDOW_MS, demandWindowStart } from "./ems-kernel.js";
32
32
  export { ingestEnergyRecords, isEnergyRecord, ingestEnergyEquipmentRecords, isEnergyEquipmentRecord } from "./energy-ingest.js";
33
+ /* 운영 사실의 문 — 리듀서가 접는 여섯이 들어오는 자리(미러가 시뮬보다 가난하지 않게). */
34
+ export { ingestOperationalRecords, isOperationalRecord, operationalKindOf } from "./operational-ingest.js";
33
35
  export { attributeEnergy, electricityCost, energyIntensity, energyOfWindows } from "./energy-attribution.js";
34
36
  /* 에너지 상태 타입은 **계약**에 있다(상태의 모양은 계약이다) — contract 의 `export *` 가 이미 낸다. */
35
37
  export * from "./vocabulary.js";
@@ -0,0 +1,44 @@
1
+ import type { IngestResult } from './face2-adapter.ts';
2
+ /** 이 문이 받는 여섯 가지 — 리듀서가 접는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
3
+ export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality';
4
+ /**
5
+ * 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
6
+ *
7
+ * `at` 은 봉투의 `eventTime` 이 된다(리듀서가 늦게 온 옛 사실을 걸러내는 기준). 페이로드에는 싣지
8
+ * 않는다 — 델타에 없는 필드이고, 같은 사실이 두 시각을 갖지 않게.
9
+ */
10
+ export interface OperationalRecordEnvelopeFields {
11
+ /** 발생 시각(ISO) — 없으면 `defaultEventTime`, 그것도 없으면 거부한다. */
12
+ at?: string;
13
+ /** 기록 시각(ISO) — 같은 발생 시각이 겹칠 때의 보조 순서. 리듀서가 페이로드에서 읽는다. */
14
+ recordTime?: string;
15
+ }
16
+ export type OperationalRecord = Record<string, unknown> & OperationalRecordEnvelopeFields;
17
+ export interface OperationalIngestOptions {
18
+ tenantId: string;
19
+ /** 레코드에 시각이 없을 때 쓸 값 — 주지 않으면 그 레코드를 거부한다. */
20
+ defaultEventTime?: string;
21
+ }
22
+ /**
23
+ * 이 레코드가 어느 운영 사실인가 — **라우팅 판정을 한 곳에 둔다**(소비처가 각자 짐작하지 않게).
24
+ *
25
+ * EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 가 있으면 그쪽 어휘이고, `equipmentId` 는 설비
26
+ * **에너지** 상태의 이름이다(운영 설비는 `moverId`). 품질은 설비와 정체 필드를 공유하므로 `good` 으로
27
+ * 가른다 — 둘 다 아니면 어느 쪽인지 모르는 것이고, 모르면 받지 않는다.
28
+ *
29
+ * ── 순서가 뜻을 갖는다: **주체와 참조는 다르다** ────────────────────────────
30
+ * 정체 필드는 하나만 오지 않는다. 설비·사람·자산 델타는 「지금 붙어 있는 작업」(`taskId`)을 함께 싣고,
31
+ * 작업 델타는 「소속 오더」(`orderId`)를 함께 싣는다. 그래서 아무 정체 필드나 먼저 보면 **참조를 주체로
32
+ * 읽는다** — 실제로 그랬다: `moverId` + `taskId` 인 설비 사실을 작업으로 읽어 「계약에 없는 필드」로
33
+ * 거부했다. 자원(설비·사람·자산)을 먼저 보고, 작업을 오더보다 먼저 본다.
34
+ */
35
+ export declare function operationalKindOf(record: unknown): OperationalKind | undefined;
36
+ /** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
37
+ export declare function isOperationalRecord(record: unknown): boolean;
38
+ /**
39
+ * 운영 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 **이유와 함께** 남는다.
40
+ *
41
+ * 봉투는 다른 어휘와 같은 것을 쓴다(`CanonicalEnvelope`) — 그래서 저널·리플레이·시간여행·성과 폴드를
42
+ * 그대로 얻는다. 어휘만 자기 것이다.
43
+ */
44
+ export declare function ingestOperationalRecords(records: OperationalRecord | OperationalRecord[] | undefined | null, opts: OperationalIngestOptions): IngestResult;
@@ -0,0 +1,283 @@
1
+ /*
2
+ * 운영 사실 인제스트 — **작업·설비·사람·자산·오더·품질이 들어오는 문.** (ADR-0029 어휘 넓히기)
3
+ *
4
+ * ── 무엇이 없었나 (2026-08-19) ──────────────────────────────────────────────
5
+ * 관측 리듀서는 이 여섯을 **이미 접는다**(`observed-reducer.ts` 의 `OP_EVENT.*` 분기). 그런데 라이브
6
+ * 인제스트 문은 어휘를 셋만 알았다: EPCIS 품목 사실, 에너지 계량, 설비 에너지 상태. 그래서 원본이
7
+ * 「이 작업이 끝났다」·「이 설비가 고장이다」를 말할 **길이 없었다** — 넣으면 `epc` 가 없어 EPCIS
8
+ * 검증에서 거부됐다.
9
+ *
10
+ * 그 결과가 이 프로젝트가 가장 싫어하는 모양이었다: **시뮬만 아는 상태.** 시뮬 커널은 작업과 설비를
11
+ * 알고 미러는 영원히 몰랐다. 그러면 같은 화면이 두 구동에서 다른 것을 말하고, 미러 위에 세운 예측은
12
+ * 「진행 중인 일이 하나도 없는 현장」에서 출발한다.
13
+ *
14
+ * ── 어휘는 델타의 이름이다 ──────────────────────────────────────────────────
15
+ * 필드 이름을 새로 짓지 않는다. `TaskStatusDelta`·`EquipmentStatusDelta`… 가 이미 계약이고, 리듀서가
16
+ * 그 이름으로 읽는다. 여기서 다른 이름을 받아 옮기면 **같은 사실에 두 어휘**가 생긴다(에너지가 그
17
+ * 규율을 먼저 세웠다: "필드 이름이 계약이다").
18
+ *
19
+ * ── 무엇을 거부하나 ─────────────────────────────────────────────────────────
20
+ * 지어낼 수 없는 것이 빠지면 거부한다 — 정체(누구의 상태인가)와 상태다. 그리고 **접을 수 없는 낱말**도
21
+ * 거부한다: 설비 상태를 `'RUNNING'` 으로 받으면 아무 오류 없이 가동률이 0% 가 되고(누적기는 `busy`·
22
+ * `down` 만 센다), 사람이 `'available'` 이면 배정에서 조용히 사라진다. 그 실패는 화면에서 「일이 없는
23
+ * 공장」으로 보이고 원인을 되짚을 수 없다. 그래서 커널이 접을 수 있는 낱말만 받고, **받는 낱말을 이유에
24
+ * 적어** 커넥터가 매핑을 고칠 수 있게 한다(매핑=밖, 검증=커널).
25
+ *
26
+ * 오더의 상태·종류는 **열려 있다** — 도메인이 소유한다(`picking`·`packed`·`shipped`…). 커널이 그 낱말로
27
+ * 무엇을 접지 않으므로 닫을 근거가 없다.
28
+ *
29
+ * ── 파생은 받아도 커널이 다시 계산한다 ──────────────────────────────────────
30
+ * 작업의 진척(`progress`)은 계약에 있어 받지만, 상태에 앉는 값은 커널이 **소요·남은 시간에서 다시
31
+ * 계산한 것**이다(`progressOf`). 그러니 원본이 진척을 보이게 하려면 `durationMs`·`remainingMs` 를 보내야
32
+ * 한다 — 파생을 사실로 삼지 않는 규율이고, 이 문을 붙이는 사람이 알아야 하는 사실이라 여기 적는다.
33
+ *
34
+ * 모르는 필드는 **조용히 버리지 않고 거부한다.** `taskID` 처럼 한 글자 틀린 이름은 통과시키면 영원히
35
+ * 보이지 않는 손실이 된다(이 문에는 아직 옛 발신자가 없어 호환 부담도 없다).
36
+ */
37
+ import { OP_EVENT } from "./contract.js";
38
+ /**
39
+ * 닫아 둔 낱말과 그 이유.
40
+ * · 작업 상태 — 성과 폴드가 `completed`·`in-progress` 로 갈린다(`kpi-fold`).
41
+ * · 설비 상태 — OEE 누적기가 `busy`·`down` 만 센다. 그 밖의 낱말은 가동률 0% 로 조용히 앉는다.
42
+ * · 사람·자산 상태 — 배정이 `idle` 을 찾는다. 다른 낱말이면 있는 자원이 없는 것이 된다.
43
+ */
44
+ const TASK_STATUS = ['created', 'assigned', 'in-progress', 'completed'];
45
+ const EQUIPMENT_STATUS = ['idle', 'busy', 'down'];
46
+ const PERSON_STATUS = ['idle', 'busy'];
47
+ const ASSET_STATUS = ['idle', 'in-use'];
48
+ const SPECS = {
49
+ task: {
50
+ eventType: OP_EVENT.task,
51
+ identity: 'taskId',
52
+ /* 종류가 없으면 성과를 종류별로 접을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
53
+ required: ['taskId', 'kind', 'status'],
54
+ fields: {
55
+ taskId: 'string', kind: 'string', status: 'string', fromNode: 'string', toNode: 'string',
56
+ itemRefs: 'string[]', resourceRef: 'string', resources: 'string[]', personnel: 'string[]', assets: 'string[]',
57
+ orderId: 'string', intent: 'string', progress: 'number', remainingMs: 'number', durationMs: 'number',
58
+ startedAtSimMs: 'number', outcome: 'string', priority: 'number', startTime: 'string', endTime: 'string',
59
+ materialActual: 'object[]', recordTime: 'string'
60
+ },
61
+ enums: {
62
+ status: TASK_STATUS,
63
+ intent: ['transport', 'process', 'dwell'],
64
+ /* 품질 판정은 **있었던 작업만** — 없음은 「양품」이 아니라 「판정하지 않았다」다. */
65
+ outcome: ['good', 'scrap']
66
+ }
67
+ },
68
+ equipment: {
69
+ eventType: OP_EVENT.equipment,
70
+ identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
71
+ required: ['moverId', 'kind', 'status'], // vocabulary-guard: allow 위와 같은 이유
72
+ fields: {
73
+ moverId: 'string', kind: 'string', status: 'string', location: 'string', homeLocation: 'string', // vocabulary-guard: allow
74
+ taskId: 'string', held: 'boolean', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string',
75
+ /* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
76
+ 재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
77
+ motion: 'object'
78
+ },
79
+ enums: { status: EQUIPMENT_STATUS }
80
+ },
81
+ person: {
82
+ eventType: OP_EVENT.person,
83
+ identity: 'personId',
84
+ required: ['personId', 'status'],
85
+ fields: {
86
+ personId: 'string', status: 'string', personnelClassIds: 'string[]', taskId: 'string', location: 'string',
87
+ offShift: 'boolean', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string'
88
+ },
89
+ enums: { status: PERSON_STATUS }
90
+ },
91
+ asset: {
92
+ eventType: OP_EVENT.asset,
93
+ identity: 'assetId',
94
+ required: ['assetId', 'status'],
95
+ fields: {
96
+ assetId: 'string', status: 'string', assetClassIds: 'string[]', location: 'string', taskId: 'string',
97
+ carrying: 'string', effectiveStart: 'string', effectiveEnd: 'string', recordTime: 'string'
98
+ },
99
+ enums: { status: ASSET_STATUS }
100
+ },
101
+ order: {
102
+ eventType: OP_EVENT.order,
103
+ identity: 'orderId',
104
+ /*
105
+ * 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
106
+ * 그것은 「모른다」가 아니라 「아무것도 안 됐다」로 읽힌다 — 결측을 0 으로 메우지 않는다.
107
+ */
108
+ required: ['orderId', 'kind', 'status', 'requested', 'fulfilled'],
109
+ fields: {
110
+ orderId: 'string', kind: 'string', status: 'string', requested: 'number', fulfilled: 'number',
111
+ gtin: 'string', held: 'boolean', lines: 'object[]', priority: 'number', startTime: 'string', endTime: 'string',
112
+ allocated: 'string[]', bizTransaction: 'string', dockDoor: 'string', windowStartMs: 'number', recordTime: 'string'
113
+ }
114
+ /* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
115
+ },
116
+ quality: {
117
+ eventType: OP_EVENT.quality,
118
+ identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드
119
+ /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
120
+ required: ['moverId', 'good', 'goodCount', 'scrapCount'], // vocabulary-guard: allow
121
+ fields: { moverId: 'string', good: 'boolean', goodCount: 'number', scrapCount: 'number', recordTime: 'string' } // vocabulary-guard: allow
122
+ }
123
+ };
124
+ /**
125
+ * 이 레코드가 어느 운영 사실인가 — **라우팅 판정을 한 곳에 둔다**(소비처가 각자 짐작하지 않게).
126
+ *
127
+ * EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 가 있으면 그쪽 어휘이고, `equipmentId` 는 설비
128
+ * **에너지** 상태의 이름이다(운영 설비는 `moverId`). 품질은 설비와 정체 필드를 공유하므로 `good` 으로
129
+ * 가른다 — 둘 다 아니면 어느 쪽인지 모르는 것이고, 모르면 받지 않는다.
130
+ *
131
+ * ── 순서가 뜻을 갖는다: **주체와 참조는 다르다** ────────────────────────────
132
+ * 정체 필드는 하나만 오지 않는다. 설비·사람·자산 델타는 「지금 붙어 있는 작업」(`taskId`)을 함께 싣고,
133
+ * 작업 델타는 「소속 오더」(`orderId`)를 함께 싣는다. 그래서 아무 정체 필드나 먼저 보면 **참조를 주체로
134
+ * 읽는다** — 실제로 그랬다: `moverId` + `taskId` 인 설비 사실을 작업으로 읽어 「계약에 없는 필드」로
135
+ * 거부했다. 자원(설비·사람·자산)을 먼저 보고, 작업을 오더보다 먼저 본다.
136
+ */
137
+ export function operationalKindOf(record) {
138
+ if (!record || typeof record !== 'object')
139
+ return undefined;
140
+ const r = record;
141
+ if (r.epc !== undefined || r.meterId !== undefined || r.equipmentId !== undefined)
142
+ return undefined;
143
+ const has = (k) => typeof r[k] === 'string' && r[k].trim().length > 0;
144
+ /* vocabulary-guard: allow 저널 와이어 필드로 가른다 */
145
+ if (has('moverId'))
146
+ return r.good !== undefined ? 'quality' : 'equipment';
147
+ if (has('personId'))
148
+ return 'person';
149
+ if (has('assetId'))
150
+ return 'asset';
151
+ if (has('taskId'))
152
+ return 'task'; // 작업이 든 `orderId` 는 소속(참조)이다
153
+ if (has('orderId'))
154
+ return 'order';
155
+ return undefined;
156
+ }
157
+ /** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
158
+ export function isOperationalRecord(record) {
159
+ return operationalKindOf(record) !== undefined;
160
+ }
161
+ /**
162
+ * 운영 레코드들을 봉투로 — 유효한 것만 통과하고 나머지는 **이유와 함께** 남는다.
163
+ *
164
+ * 봉투는 다른 어휘와 같은 것을 쓴다(`CanonicalEnvelope`) — 그래서 저널·리플레이·시간여행·성과 폴드를
165
+ * 그대로 얻는다. 어휘만 자기 것이다.
166
+ */
167
+ export function ingestOperationalRecords(records, opts) {
168
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
169
+ const accepted = [];
170
+ const rejected = [];
171
+ let seq = 0;
172
+ for (const record of arr) {
173
+ const kind = operationalKindOf(record);
174
+ if (!kind) {
175
+ rejected.push({
176
+ record,
177
+ errors: ['어느 운영 사실인지 모른다 — 정체 필드가 필요하다(taskId · moverId(+good=품질) · personId · assetId · orderId)'] // vocabulary-guard: allow 거부 이유가 계약 필드 이름을 말한다
178
+ });
179
+ continue;
180
+ }
181
+ const spec = SPECS[kind];
182
+ const r = record;
183
+ const errors = [];
184
+ /* 모르는 이름은 거부한다 — 한 글자 틀린 필드가 조용히 사라지는 것을 막는다. */
185
+ const unknown = Object.keys(r).filter(k => k !== 'at' && spec.fields[k] === undefined);
186
+ if (unknown.length)
187
+ errors.push(`${kind}: 계약에 없는 필드 — ${unknown.join(', ')}`);
188
+ for (const name of spec.required) {
189
+ const v = r[name];
190
+ if (v === undefined || v === null || v === '')
191
+ errors.push(`${kind}: ${name} 없음 — 지어낼 수 없는 값이다`);
192
+ }
193
+ const data = {};
194
+ for (const [name, type] of Object.entries(spec.fields)) {
195
+ const v = r[name];
196
+ if (v === undefined || v === null || v === '')
197
+ continue;
198
+ switch (type) {
199
+ case 'string': {
200
+ if (typeof v !== 'string') {
201
+ errors.push(`${kind}.${name} 이 문자열이 아니다: ${JSON.stringify(v)}`);
202
+ break;
203
+ }
204
+ const allowed = spec.enums?.[name];
205
+ if (allowed && !allowed.includes(v)) {
206
+ errors.push(`${kind}.${name} 이 커널이 접는 낱말이 아니다: ${JSON.stringify(v)} — 받는 값은 ${allowed.join(' · ')}`);
207
+ break;
208
+ }
209
+ data[name] = v;
210
+ break;
211
+ }
212
+ case 'number': {
213
+ const n = Number(v);
214
+ if (typeof v === 'boolean' || !Number.isFinite(n)) {
215
+ errors.push(`${kind}.${name} 가 수가 아니다: ${JSON.stringify(v)}`);
216
+ break;
217
+ }
218
+ /* 진척은 비율이다 — 백분율(95)을 그대로 받으면 화면이 9,500% 를 말한다. */
219
+ if (name === 'progress' && (n < 0 || n > 1)) {
220
+ errors.push(`${kind}.progress 는 0~1 비율이다: ${n}`);
221
+ break;
222
+ }
223
+ if ((name === 'requested' || name === 'fulfilled' || name === 'goodCount' || name === 'scrapCount') && n < 0) {
224
+ errors.push(`${kind}.${name} 가 음수다: ${n}`);
225
+ break;
226
+ }
227
+ data[name] = n;
228
+ break;
229
+ }
230
+ case 'boolean': {
231
+ if (typeof v !== 'boolean') {
232
+ errors.push(`${kind}.${name} 가 참/거짓이 아니다: ${JSON.stringify(v)}`);
233
+ break;
234
+ }
235
+ data[name] = v;
236
+ break;
237
+ }
238
+ case 'string[]': {
239
+ if (!Array.isArray(v) || v.some(x => typeof x !== 'string')) {
240
+ errors.push(`${kind}.${name} 가 문자열 배열이 아니다: ${JSON.stringify(v)}`);
241
+ break;
242
+ }
243
+ data[name] = v.slice();
244
+ break;
245
+ }
246
+ case 'object': {
247
+ if (Array.isArray(v) || typeof v !== 'object') {
248
+ errors.push(`${kind}.${name} 가 객체가 아니다: ${JSON.stringify(v)}`);
249
+ break;
250
+ }
251
+ data[name] = { ...v };
252
+ break;
253
+ }
254
+ case 'object[]': {
255
+ if (!Array.isArray(v) || v.some(x => !x || typeof x !== 'object')) {
256
+ errors.push(`${kind}.${name} 가 객체 배열이 아니다: ${JSON.stringify(v)}`);
257
+ break;
258
+ }
259
+ data[name] = v.map(x => ({ ...x }));
260
+ break;
261
+ }
262
+ }
263
+ }
264
+ const at = String(r.at ?? '').trim() || opts.defaultEventTime;
265
+ const atMs = at ? Date.parse(at) : Number.NaN;
266
+ if (!Number.isFinite(atMs)) {
267
+ /* 시각이 없으면 순서를 판정할 수 없다 — 늦게 온 옛 사실이 최신 상태를 덮어써 위치가 과거로 튄다. */
268
+ errors.push(`${kind}: at 없음/형식 오류 — 시각 없이는 늦게 온 옛 사실을 걸러낼 수 없다`);
269
+ }
270
+ if (errors.length) {
271
+ rejected.push({ record, errors });
272
+ continue;
273
+ }
274
+ accepted.push({
275
+ eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
276
+ eventType: spec.eventType,
277
+ eventTime: new Date(atMs).toISOString(),
278
+ tenantId: opts.tenantId,
279
+ data
280
+ });
281
+ }
282
+ return { accepted, rejected };
283
+ }
@@ -1,5 +1,14 @@
1
- /** 금지 어휘 — 표준 어휘로 대체된 옛 낱말. 파생 식별자까지 잡도록 부분 일치로 본다. */
2
- export declare const RETIRED_VOCABULARY: readonly ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
1
+ /**
2
+ * 금지 어휘 표준 어휘로 대체된 낱말. 파생 식별자까지 잡도록 부분 일치로 본다.
3
+ *
4
+ * `BoardDef`·`loadBoard` 는 ADR-0033 으로 `TwinModelDef`·`loadTwinModel` 이 됐다. 커널 소스에는
5
+ * **0곳**이라 개명이 끝났는데, 문서에는 31곳이 남아 있었다(2026-08-19 실측) — 코드에 없는 이름으로
6
+ * 설계를 설명하고 있었다. 코드에 0곳이므로 여기 올려 되돌아오는 것을 막는다.
7
+ *
8
+ * (문서 쪽은 `test/doc-vocabulary-guard.test.ts` 가 **자기 목록**으로 본다 — 이 목록의 `node`·`mover` 는
9
+ * ADR 기록이 없어 문서의 개념 명사에까지 들이대지 않는다.)
10
+ */
11
+ export declare const RETIRED_VOCABULARY: readonly ["mover", "Mover", "MOVER", "node", "Node", "NODE", "BoardDef", "loadBoard"];
3
12
  /**
4
13
  * 예외 — 남아 있는 것이 **정당한** 토큰. 각 항목에 이유가 붙는다.
5
14
  *
@@ -10,8 +10,17 @@
10
10
  *
11
11
  * 예외는 **이유와 함께** 여기 적는다. 주석에만 적으면 다음 사람이 "원래 그런가 보다" 하고 넘긴다.
12
12
  */
13
- /** 금지 어휘 — 표준 어휘로 대체된 옛 낱말. 파생 식별자까지 잡도록 부분 일치로 본다. */
14
- export const RETIRED_VOCABULARY = ['mover', 'Mover', 'MOVER', 'node', 'Node', 'NODE'];
13
+ /**
14
+ * 금지 어휘 표준 어휘로 대체된 낱말. 파생 식별자까지 잡도록 부분 일치로 본다.
15
+ *
16
+ * `BoardDef`·`loadBoard` 는 ADR-0033 으로 `TwinModelDef`·`loadTwinModel` 이 됐다. 커널 소스에는
17
+ * **0곳**이라 개명이 끝났는데, 문서에는 31곳이 남아 있었다(2026-08-19 실측) — 코드에 없는 이름으로
18
+ * 설계를 설명하고 있었다. 코드에 0곳이므로 여기 올려 되돌아오는 것을 막는다.
19
+ *
20
+ * (문서 쪽은 `test/doc-vocabulary-guard.test.ts` 가 **자기 목록**으로 본다 — 이 목록의 `node`·`mover` 는
21
+ * ADR 기록이 없어 문서의 개념 명사에까지 들이대지 않는다.)
22
+ */
23
+ export const RETIRED_VOCABULARY = ['mover', 'Mover', 'MOVER', 'node', 'Node', 'NODE', 'BoardDef', 'loadBoard'];
15
24
  /**
16
25
  * 예외 — 남아 있는 것이 **정당한** 토큰. 각 항목에 이유가 붙는다.
17
26
  *
@@ -117,10 +117,12 @@ __export(index_exports, {
117
117
  ingest: () => ingest,
118
118
  ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
119
119
  ingestEnergyRecords: () => ingestEnergyRecords,
120
+ ingestOperationalRecords: () => ingestOperationalRecords,
120
121
  isElectricalLocationType: () => isElectricalLocationType,
121
122
  isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
122
123
  isEnergyRecord: () => isEnergyRecord,
123
124
  isEquipmentLevel: () => isEquipmentLevel,
125
+ isOperationalRecord: () => isOperationalRecord,
124
126
  isoDurationHours: () => isoDurationHours,
125
127
  itemKeyOf: () => itemKeyOf,
126
128
  levelOfLocationType: () => levelOfLocationType,
@@ -134,6 +136,7 @@ __export(index_exports, {
134
136
  objectEvent: () => objectEvent,
135
137
  offCalendarAt: () => offCalendarAt,
136
138
  offCalendarReasonAt: () => offCalendarReasonAt,
139
+ operationalKindOf: () => operationalKindOf,
137
140
  operationsCapabilityOf: () => operationsCapabilityOf,
138
141
  parseEpc: () => parseEpc,
139
142
  parseIsoDuration: () => parseIsoDuration,
@@ -7166,6 +7169,273 @@ function ingestEnergyEquipmentRecords(records, opts) {
7166
7169
  return { accepted, rejected };
7167
7170
  }
7168
7171
 
7172
+ // src/operational-ingest.ts
7173
+ var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
7174
+ var EQUIPMENT_STATUS = ["idle", "busy", "down"];
7175
+ var PERSON_STATUS = ["idle", "busy"];
7176
+ var ASSET_STATUS = ["idle", "in-use"];
7177
+ var SPECS = {
7178
+ task: {
7179
+ eventType: OP_EVENT.task,
7180
+ identity: "taskId",
7181
+ /* 종류가 없으면 성과를 종류별로 접을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
7182
+ required: ["taskId", "kind", "status"],
7183
+ fields: {
7184
+ taskId: "string",
7185
+ kind: "string",
7186
+ status: "string",
7187
+ fromNode: "string",
7188
+ toNode: "string",
7189
+ itemRefs: "string[]",
7190
+ resourceRef: "string",
7191
+ resources: "string[]",
7192
+ personnel: "string[]",
7193
+ assets: "string[]",
7194
+ orderId: "string",
7195
+ intent: "string",
7196
+ progress: "number",
7197
+ remainingMs: "number",
7198
+ durationMs: "number",
7199
+ startedAtSimMs: "number",
7200
+ outcome: "string",
7201
+ priority: "number",
7202
+ startTime: "string",
7203
+ endTime: "string",
7204
+ materialActual: "object[]",
7205
+ recordTime: "string"
7206
+ },
7207
+ enums: {
7208
+ status: TASK_STATUS,
7209
+ intent: ["transport", "process", "dwell"],
7210
+ /* 품질 판정은 **있었던 작업만** — 없음은 「양품」이 아니라 「판정하지 않았다」다. */
7211
+ outcome: ["good", "scrap"]
7212
+ }
7213
+ },
7214
+ equipment: {
7215
+ eventType: OP_EVENT.equipment,
7216
+ identity: "moverId",
7217
+ // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
7218
+ required: ["moverId", "kind", "status"],
7219
+ // vocabulary-guard: allow 위와 같은 이유
7220
+ fields: {
7221
+ moverId: "string",
7222
+ kind: "string",
7223
+ status: "string",
7224
+ location: "string",
7225
+ homeLocation: "string",
7226
+ // vocabulary-guard: allow
7227
+ taskId: "string",
7228
+ held: "boolean",
7229
+ effectiveStart: "string",
7230
+ effectiveEnd: "string",
7231
+ recordTime: "string",
7232
+ /* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
7233
+ 재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
7234
+ motion: "object"
7235
+ },
7236
+ enums: { status: EQUIPMENT_STATUS }
7237
+ },
7238
+ person: {
7239
+ eventType: OP_EVENT.person,
7240
+ identity: "personId",
7241
+ required: ["personId", "status"],
7242
+ fields: {
7243
+ personId: "string",
7244
+ status: "string",
7245
+ personnelClassIds: "string[]",
7246
+ taskId: "string",
7247
+ location: "string",
7248
+ offShift: "boolean",
7249
+ effectiveStart: "string",
7250
+ effectiveEnd: "string",
7251
+ recordTime: "string"
7252
+ },
7253
+ enums: { status: PERSON_STATUS }
7254
+ },
7255
+ asset: {
7256
+ eventType: OP_EVENT.asset,
7257
+ identity: "assetId",
7258
+ required: ["assetId", "status"],
7259
+ fields: {
7260
+ assetId: "string",
7261
+ status: "string",
7262
+ assetClassIds: "string[]",
7263
+ location: "string",
7264
+ taskId: "string",
7265
+ carrying: "string",
7266
+ effectiveStart: "string",
7267
+ effectiveEnd: "string",
7268
+ recordTime: "string"
7269
+ },
7270
+ enums: { status: ASSET_STATUS }
7271
+ },
7272
+ order: {
7273
+ eventType: OP_EVENT.order,
7274
+ identity: "orderId",
7275
+ /*
7276
+ * 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
7277
+ * 그것은 「모른다」가 아니라 「아무것도 안 됐다」로 읽힌다 — 결측을 0 으로 메우지 않는다.
7278
+ */
7279
+ required: ["orderId", "kind", "status", "requested", "fulfilled"],
7280
+ fields: {
7281
+ orderId: "string",
7282
+ kind: "string",
7283
+ status: "string",
7284
+ requested: "number",
7285
+ fulfilled: "number",
7286
+ gtin: "string",
7287
+ held: "boolean",
7288
+ lines: "object[]",
7289
+ priority: "number",
7290
+ startTime: "string",
7291
+ endTime: "string",
7292
+ allocated: "string[]",
7293
+ bizTransaction: "string",
7294
+ dockDoor: "string",
7295
+ windowStartMs: "number",
7296
+ recordTime: "string"
7297
+ }
7298
+ /* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
7299
+ },
7300
+ quality: {
7301
+ eventType: OP_EVENT.quality,
7302
+ identity: "moverId",
7303
+ // vocabulary-guard: allow 저널 와이어 필드
7304
+ /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
7305
+ required: ["moverId", "good", "goodCount", "scrapCount"],
7306
+ // vocabulary-guard: allow
7307
+ fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", recordTime: "string" }
7308
+ // vocabulary-guard: allow
7309
+ }
7310
+ };
7311
+ function operationalKindOf(record) {
7312
+ if (!record || typeof record !== "object") return void 0;
7313
+ const r = record;
7314
+ if (r.epc !== void 0 || r.meterId !== void 0 || r.equipmentId !== void 0) return void 0;
7315
+ const has = (k) => typeof r[k] === "string" && r[k].trim().length > 0;
7316
+ if (has("moverId")) return r.good !== void 0 ? "quality" : "equipment";
7317
+ if (has("personId")) return "person";
7318
+ if (has("assetId")) return "asset";
7319
+ if (has("taskId")) return "task";
7320
+ if (has("orderId")) return "order";
7321
+ return void 0;
7322
+ }
7323
+ function isOperationalRecord(record) {
7324
+ return operationalKindOf(record) !== void 0;
7325
+ }
7326
+ function ingestOperationalRecords(records, opts) {
7327
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
7328
+ const accepted = [];
7329
+ const rejected = [];
7330
+ let seq = 0;
7331
+ for (const record of arr) {
7332
+ const kind = operationalKindOf(record);
7333
+ if (!kind) {
7334
+ rejected.push({
7335
+ record,
7336
+ errors: ["\uC5B4\uB290 \uC6B4\uC601 \uC0AC\uC2E4\uC778\uC9C0 \uBAA8\uB978\uB2E4 \u2014 \uC815\uCCB4 \uD544\uB4DC\uAC00 \uD544\uC694\uD558\uB2E4(taskId \xB7 moverId(+good=\uD488\uC9C8) \xB7 personId \xB7 assetId \xB7 orderId)"]
7337
+ // vocabulary-guard: allow 거부 이유가 계약 필드 이름을 말한다
7338
+ });
7339
+ continue;
7340
+ }
7341
+ const spec = SPECS[kind];
7342
+ const r = record;
7343
+ const errors = [];
7344
+ const unknown = Object.keys(r).filter((k) => k !== "at" && spec.fields[k] === void 0);
7345
+ if (unknown.length) errors.push(`${kind}: \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uD544\uB4DC \u2014 ${unknown.join(", ")}`);
7346
+ for (const name of spec.required) {
7347
+ const v = r[name];
7348
+ if (v === void 0 || v === null || v === "") errors.push(`${kind}: ${name} \uC5C6\uC74C \u2014 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB294 \uAC12\uC774\uB2E4`);
7349
+ }
7350
+ const data = {};
7351
+ for (const [name, type] of Object.entries(spec.fields)) {
7352
+ const v = r[name];
7353
+ if (v === void 0 || v === null || v === "") continue;
7354
+ switch (type) {
7355
+ case "string": {
7356
+ if (typeof v !== "string") {
7357
+ errors.push(`${kind}.${name} \uC774 \uBB38\uC790\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7358
+ break;
7359
+ }
7360
+ const allowed = spec.enums?.[name];
7361
+ if (allowed && !allowed.includes(v)) {
7362
+ errors.push(`${kind}.${name} \uC774 \uCEE4\uB110\uC774 \uC811\uB294 \uB0B1\uB9D0\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)} \u2014 \uBC1B\uB294 \uAC12\uC740 ${allowed.join(" \xB7 ")}`);
7363
+ break;
7364
+ }
7365
+ data[name] = v;
7366
+ break;
7367
+ }
7368
+ case "number": {
7369
+ const n = Number(v);
7370
+ if (typeof v === "boolean" || !Number.isFinite(n)) {
7371
+ errors.push(`${kind}.${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7372
+ break;
7373
+ }
7374
+ if (name === "progress" && (n < 0 || n > 1)) {
7375
+ errors.push(`${kind}.progress \uB294 0~1 \uBE44\uC728\uC774\uB2E4: ${n}`);
7376
+ break;
7377
+ }
7378
+ if ((name === "requested" || name === "fulfilled" || name === "goodCount" || name === "scrapCount") && n < 0) {
7379
+ errors.push(`${kind}.${name} \uAC00 \uC74C\uC218\uB2E4: ${n}`);
7380
+ break;
7381
+ }
7382
+ data[name] = n;
7383
+ break;
7384
+ }
7385
+ case "boolean": {
7386
+ if (typeof v !== "boolean") {
7387
+ errors.push(`${kind}.${name} \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7388
+ break;
7389
+ }
7390
+ data[name] = v;
7391
+ break;
7392
+ }
7393
+ case "string[]": {
7394
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
7395
+ errors.push(`${kind}.${name} \uAC00 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7396
+ break;
7397
+ }
7398
+ data[name] = v.slice();
7399
+ break;
7400
+ }
7401
+ case "object": {
7402
+ if (Array.isArray(v) || typeof v !== "object") {
7403
+ errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7404
+ break;
7405
+ }
7406
+ data[name] = { ...v };
7407
+ break;
7408
+ }
7409
+ case "object[]": {
7410
+ if (!Array.isArray(v) || v.some((x) => !x || typeof x !== "object")) {
7411
+ errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7412
+ break;
7413
+ }
7414
+ data[name] = v.map((x) => ({ ...x }));
7415
+ break;
7416
+ }
7417
+ }
7418
+ }
7419
+ const at = String(r.at ?? "").trim() || opts.defaultEventTime;
7420
+ const atMs = at ? Date.parse(at) : Number.NaN;
7421
+ if (!Number.isFinite(atMs)) {
7422
+ errors.push(`${kind}: at \uC5C6\uC74C/\uD615\uC2DD \uC624\uB958 \u2014 \uC2DC\uAC01 \uC5C6\uC774\uB294 \uB2A6\uAC8C \uC628 \uC61B \uC0AC\uC2E4\uC744 \uAC78\uB7EC\uB0BC \uC218 \uC5C6\uB2E4`);
7423
+ }
7424
+ if (errors.length) {
7425
+ rejected.push({ record, errors });
7426
+ continue;
7427
+ }
7428
+ accepted.push({
7429
+ eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
7430
+ eventType: spec.eventType,
7431
+ eventTime: new Date(atMs).toISOString(),
7432
+ tenantId: opts.tenantId,
7433
+ data
7434
+ });
7435
+ }
7436
+ return { accepted, rejected };
7437
+ }
7438
+
7169
7439
  // src/energy-attribution.ts
7170
7440
  var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
7171
7441
  function attributeEnergy(opts) {
@@ -7350,7 +7620,7 @@ function electricityCost(input) {
7350
7620
  }
7351
7621
 
7352
7622
  // src/vocabulary.ts
7353
- var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
7623
+ var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE", "BoardDef", "loadBoard"];
7354
7624
  var VOCABULARY_EXCEPTIONS = [
7355
7625
  /* ── 저널 와이어 필드 — append-only 역사이므로 이름을 바꾸지 않는다 ───────
7356
7626
  * 같은 사실이 시점에 따라 다른 키로 들어가면 낡은 이름보다 나쁘다. 개명은 이벤트 스키마
@@ -7491,10 +7761,12 @@ function retiredVocabularyIn(line) {
7491
7761
  ingest,
7492
7762
  ingestEnergyEquipmentRecords,
7493
7763
  ingestEnergyRecords,
7764
+ ingestOperationalRecords,
7494
7765
  isElectricalLocationType,
7495
7766
  isEnergyEquipmentRecord,
7496
7767
  isEnergyRecord,
7497
7768
  isEquipmentLevel,
7769
+ isOperationalRecord,
7498
7770
  isoDurationHours,
7499
7771
  itemKeyOf,
7500
7772
  levelOfLocationType,
@@ -7508,6 +7780,7 @@ function retiredVocabularyIn(line) {
7508
7780
  objectEvent,
7509
7781
  offCalendarAt,
7510
7782
  offCalendarReasonAt,
7783
+ operationalKindOf,
7511
7784
  operationsCapabilityOf,
7512
7785
  parseEpc,
7513
7786
  parseIsoDuration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.32",
3
+ "version": "0.7.33",
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": {