@operato/twin-kernel 0.7.32 → 0.7.37

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,7 +2,7 @@ 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
- /** ObjectEvent 매핑 스펙(선언적). Agg/Txn 매핑은 확장 지점. */
5
+ /** ObjectEvent 매핑 스펙(선언적). */
6
6
  export interface ObjectEventMapping {
7
7
  type: 'ObjectEvent';
8
8
  action: MapValue;
@@ -12,7 +12,41 @@ export interface ObjectEventMapping {
12
12
  readPoint?: MapValue;
13
13
  bizLocation?: MapValue;
14
14
  }
15
- export type EventMapping = ObjectEventMapping;
15
+ /**
16
+ * AggregationEvent 매핑 스펙 — **담김의 사실**(무엇이 무엇에 실렸나).
17
+ *
18
+ * ── 왜 뒤늦게 생겼나 (2026-08-19) ───────────────────────────────────────────
19
+ * 이 파일은 오래 *"Agg/Txn 매핑은 확장 지점"* 이라고만 적어 두었고, 그래서 원본이 **적재를 말할 길이
20
+ * 없었다.** 관측 리듀서는 `AggregationEvent` 를 받으면 자식 물품에 `parent` 를 붙이도록 이미 되어 있었는데
21
+ * (3D 가 「이 상자가 어느 팔레트에 실렸나」를 묻는 자리) 그 사실이 문 앞에서 사라졌다 — 시뮬 원본을
22
+ * 붙여 돌려 보고서야 그것이 로그로 드러났다(*"is not carried to the twin"*).
23
+ *
24
+ * 자식은 **두 가지로 말할 수 있다**(표준이 그렇고 우리 커널도 둘 다 낸다):
25
+ * · `childEPCs` — 개체 하나하나(상자 3개의 식별자).
26
+ * · `childQuantityList` — 클래스와 수량(「이 품번 40개」). 낱개 식별자가 없는 입고·포장이 이 모양이다.
27
+ * 하나만 받으면 나머지 사실이 문 앞에서 사라진다 — 실제로 그렇게 사라지고 있었다(로그가 짚었다).
28
+ */
29
+ export interface AggregationEventMapping {
30
+ type: 'AggregationEvent';
31
+ action: MapValue;
32
+ bizStep: MapValue;
33
+ disposition?: MapValue;
34
+ parentID: MapValue;
35
+ /** 자식 목록 참조("$.childEPCs") — 문자열 배열이어야 한다. */
36
+ childEPCs?: MapValue;
37
+ /** 수량으로 담긴 자식 참조("$.childQuantityList") — `{ epcClass, quantity, uom? }` 배열. */
38
+ childQuantityList?: MapValue;
39
+ readPoint?: MapValue;
40
+ bizLocation?: MapValue;
41
+ }
42
+ export type EventMapping = ObjectEventMapping | AggregationEventMapping;
43
+ /**
44
+ * 이 품목 레코드가 **담김의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
45
+ *
46
+ * 담김은 부모와 자식으로 말한다(`parentID`·`childEPCs`). 개체 하나의 관측(`epc`)과 섞이지 않게, 부모가
47
+ * 있으면 담김으로 본다 — 둘 다 있으면 커넥터가 무엇을 말하려는지 알 수 없으므로 담김으로 받지 않는다.
48
+ */
49
+ export declare function isAggregationRecord(record: unknown): boolean;
16
50
  /** 룰: 소스 레코드의 판별자(sourceType)로 매핑을 고른다. */
17
51
  export interface AdapterRule {
18
52
  sourceType: string;
@@ -32,7 +66,12 @@ export interface IngestResult {
32
66
  }[];
33
67
  }
34
68
  type Rec = Record<string, unknown>;
35
- /** 단일 레코드 → 정규 EPCIS 이벤트(매핑만; 검증은 ingest 에서). */
69
+ /** 단일 레코드 → 정규 EPCIS 이벤트 + 매핑에서 드러난 문제(검증은 `ingest` 가 이어서 한다). */
70
+ export declare function mapRecordChecked(record: Rec, mapping: EventMapping, eventTime: string): {
71
+ event: EpcisEvent;
72
+ errors: string[];
73
+ };
74
+ /** 단일 레코드 → 정규 EPCIS 이벤트(매핑만). 매핑 문제까지 보려면 `mapRecordChecked`. */
36
75
  export declare function mapRecord(record: Rec, mapping: EventMapping, eventTime: string): EpcisEvent;
37
76
  /**
38
77
  * 레거시 레코드 배열 → 정규 EPCIS 봉투. 매핑 후 검증, 실패분은 rejected(오염 차단).
@@ -8,32 +8,110 @@
8
8
  * 매핑 엔진은 walking-skeleton 범위에서 무의존 declarative spec 사용.
9
9
  * (jsonata/템플릿 엔진 최종 선택은 열린 결정 — face2-adapters.md. 커널 zero-dep 유지 위해 여기선 미도입.)
10
10
  */
11
- import { objectEvent, validateEpcisEvent } from "./epcis.js";
11
+ import { aggregationEvent, objectEvent, validateEpcisEvent } from "./epcis.js";
12
+ /**
13
+ * 이 품목 레코드가 **담김의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
14
+ *
15
+ * 담김은 부모와 자식으로 말한다(`parentID`·`childEPCs`). 개체 하나의 관측(`epc`)과 섞이지 않게, 부모가
16
+ * 있으면 담김으로 본다 — 둘 다 있으면 커넥터가 무엇을 말하려는지 알 수 없으므로 담김으로 받지 않는다.
17
+ */
18
+ export function isAggregationRecord(record) {
19
+ if (!record || typeof record !== 'object')
20
+ return false;
21
+ const r = record;
22
+ return typeof r.parentID === 'string' && r.parentID.trim().length > 0 && r.epc === undefined;
23
+ }
12
24
  function get(obj, path) {
13
25
  return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
14
26
  }
15
- /** 매핑 값 해석: "$.path" 는 소스 참조, 그 외는 리터럴. */
16
- function resolve(v, record) {
27
+ /**
28
+ * 매핑 값 해석: "$.path" 는 소스 참조, 그 외는 리터럴.
29
+ *
30
+ * ── 객체를 문자열로 굳히지 않는다 (2026-08-19 실측으로 고침) ────────────────
31
+ * 예전에는 무엇이 오든 `String(값)` 이었다. 그래서 커널이 낸 EPCIS 의 `readPoint`(= `{ id }`)를 이 문에
32
+ * 그대로 실은 커넥터가 있었고, **오류 하나 없이** `readPoint: { id: "[object Object]" }` 가 저널에 쌓였다
33
+ * (실 앱에서 관측). 자리를 잃은 관측은 「어디 있나」에 답하지 못하는데 화면은 값이 있다고 믿는다.
34
+ *
35
+ * 그러니 객체·배열은 **매핑 오류**다. 조용히 버리지도(그러면 자리가 없어진 이유를 아무도 모른다),
36
+ * 문자열로 굳히지도 않고, 그 레코드를 이유와 함께 거부한다.
37
+ */
38
+ function resolve(v, record, field, errors) {
17
39
  if (v === undefined)
18
40
  return undefined;
19
- if (v.startsWith('$.')) {
20
- const r = get(record, v.slice(2));
21
- return r === undefined || r === null ? undefined : String(r);
41
+ if (!v.startsWith('$.'))
42
+ return v;
43
+ const r = get(record, v.slice(2));
44
+ if (r === undefined || r === null)
45
+ return undefined;
46
+ if (typeof r === 'object') {
47
+ errors.push(`${field} 가 값이 아니라 ${Array.isArray(r) ? '배열' : '객체'}로 왔다(${v}) — 커넥터가 표준 구조를 그대로 실었다: ${JSON.stringify(r).slice(0, 80)}`);
48
+ return undefined;
49
+ }
50
+ return String(r);
51
+ }
52
+ /** 목록 참조 해석 — 문자열 배열만 받는다(하나만 온 것을 배열로 지어내지 않는다). */
53
+ function resolveList(v, record, field, errors) {
54
+ if (v === undefined)
55
+ return [];
56
+ const r = get(record, v.startsWith('$.') ? v.slice(2) : v);
57
+ if (r === undefined || r === null)
58
+ return [];
59
+ if (!Array.isArray(r) || r.some(x => typeof x !== 'string')) {
60
+ errors.push(`${field} 가 문자열 배열이 아니다(${v}): ${JSON.stringify(r).slice(0, 80)}`);
61
+ return [];
62
+ }
63
+ return r.slice();
64
+ }
65
+ /**
66
+ * 수량 목록 참조 해석 — `{ epcClass, quantity, uom? }` 배열.
67
+ *
68
+ * 값의 옳고 그름(클래스 식별자인가·수량이 양수인가)은 **검증이 본다**(`validateEpcisEvent`) — 여기서
69
+ * 다시 판정하면 규칙이 두 벌이 된다. 이 자리는 「배열인가·객체인가」만 지킨다.
70
+ */
71
+ function resolveQuantityList(v, record, field, errors) {
72
+ if (v === undefined)
73
+ return [];
74
+ const r = get(record, v.startsWith('$.') ? v.slice(2) : v);
75
+ if (r === undefined || r === null)
76
+ return [];
77
+ if (!Array.isArray(r) || r.some(x => !x || typeof x !== 'object' || Array.isArray(x))) {
78
+ errors.push(`${field} 가 객체 배열이 아니다(${v}): ${JSON.stringify(r).slice(0, 80)}`);
79
+ return [];
80
+ }
81
+ return r.map(x => ({ ...x }));
82
+ }
83
+ /** 단일 레코드 → 정규 EPCIS 이벤트 + 매핑에서 드러난 문제(검증은 `ingest` 가 이어서 한다). */
84
+ export function mapRecordChecked(record, mapping, eventTime) {
85
+ const errors = [];
86
+ const action = (resolve(mapping.action, record, 'action', errors) ?? '');
87
+ const bizStep = resolve(mapping.bizStep, record, 'bizStep', errors) ?? '';
88
+ const disposition = resolve(mapping.disposition, record, 'disposition', errors);
89
+ const readPoint = resolve(mapping.readPoint, record, 'readPoint', errors);
90
+ const bizLocation = resolve(mapping.bizLocation, record, 'bizLocation', errors);
91
+ if (mapping.type === 'AggregationEvent') {
92
+ const parentID = resolve(mapping.parentID, record, 'parentID', errors) ?? '';
93
+ const childEPCs = resolveList(mapping.childEPCs, record, 'childEPCs', errors);
94
+ const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, 'childQuantityList', errors);
95
+ return {
96
+ event: aggregationEvent({
97
+ eventTime, action, bizStep, disposition, parentID,
98
+ /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「자식이 없다」고 말하는 것이 된다. */
99
+ ...(childEPCs.length ? { childEPCs } : {}),
100
+ ...(childQuantityList.length ? { childQuantityList } : {}),
101
+ readPoint, bizLocation
102
+ }),
103
+ errors
104
+ };
22
105
  }
23
- return v;
106
+ const epc = resolve(mapping.epc, record, 'epc', errors);
107
+ return {
108
+ event: objectEvent({ eventTime, action, bizStep, disposition, epcList: epc ? [epc] : [], readPoint, bizLocation }),
109
+ errors
110
+ };
24
111
  }
25
- /** 단일 레코드 → 정규 EPCIS 이벤트(매핑만; 검증은 ingest 에서). */
112
+ /** 단일 레코드 → 정규 EPCIS 이벤트(매핑만). 매핑 문제까지 보려면 `mapRecordChecked`. */
26
113
  export function mapRecord(record, mapping, eventTime) {
27
- const epc = resolve(mapping.epc, record);
28
- return objectEvent({
29
- eventTime,
30
- action: (resolve(mapping.action, record) ?? ''),
31
- bizStep: resolve(mapping.bizStep, record) ?? '',
32
- disposition: resolve(mapping.disposition, record),
33
- epcList: epc ? [epc] : [],
34
- readPoint: resolve(mapping.readPoint, record),
35
- bizLocation: resolve(mapping.bizLocation, record)
36
- });
114
+ return mapRecordChecked(record, mapping, eventTime).event;
37
115
  }
38
116
  /**
39
117
  * 레거시 레코드 배열 → 정규 EPCIS 봉투. 매핑 후 검증, 실패분은 rejected(오염 차단).
@@ -50,9 +128,11 @@ export function ingest(records, rules, opts) {
50
128
  rejected.push({ record, errors: [`매칭 룰 없음: sourceType=${record['sourceType']}`] });
51
129
  continue;
52
130
  }
53
- const eventTime = (opts.eventTimePath ? resolve('$.' + opts.eventTimePath, record) : undefined) ?? opts.defaultEventTime;
54
- const ev = mapRecord(record, rule.mapping, eventTime);
55
- const errors = validateEpcisEvent(ev);
131
+ const timeErrors = [];
132
+ const eventTime = (opts.eventTimePath ? resolve('$.' + opts.eventTimePath, record, 'eventTime', timeErrors) : undefined) ?? opts.defaultEventTime;
133
+ const { event: ev, errors: mapErrors } = mapRecordChecked(record, rule.mapping, eventTime);
134
+ /* 매핑에서 드러난 문제를 검증 위반과 **같은 자리**에 담는다 — 부르는 쪽은 이유를 한 곳에서 읽는다. */
135
+ const errors = [...timeErrors, ...mapErrors, ...validateEpcisEvent(ev)];
56
136
  if (errors.length) {
57
137
  rejected.push({ record, errors });
58
138
  continue;
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,16 +117,20 @@ __export(index_exports, {
117
117
  ingest: () => ingest,
118
118
  ingestEnergyEquipmentRecords: () => ingestEnergyEquipmentRecords,
119
119
  ingestEnergyRecords: () => ingestEnergyRecords,
120
+ ingestOperationalRecords: () => ingestOperationalRecords,
121
+ isAggregationRecord: () => isAggregationRecord,
120
122
  isElectricalLocationType: () => isElectricalLocationType,
121
123
  isEnergyEquipmentRecord: () => isEnergyEquipmentRecord,
122
124
  isEnergyRecord: () => isEnergyRecord,
123
125
  isEquipmentLevel: () => isEquipmentLevel,
126
+ isOperationalRecord: () => isOperationalRecord,
124
127
  isoDurationHours: () => isoDurationHours,
125
128
  itemKeyOf: () => itemKeyOf,
126
129
  levelOfLocationType: () => levelOfLocationType,
127
130
  lgtinClass: () => lgtinClass,
128
131
  locationStatusOf: () => locationStatusOf,
129
132
  mapRecord: () => mapRecord,
133
+ mapRecordChecked: () => mapRecordChecked,
130
134
  meetsTests: () => meetsTests,
131
135
  minuteOfDayAt: () => minuteOfDayAt,
132
136
  monteCarloForecast: () => monteCarloForecast,
@@ -134,6 +138,7 @@ __export(index_exports, {
134
138
  objectEvent: () => objectEvent,
135
139
  offCalendarAt: () => offCalendarAt,
136
140
  offCalendarReasonAt: () => offCalendarReasonAt,
141
+ operationalKindOf: () => operationalKindOf,
137
142
  operationsCapabilityOf: () => operationsCapabilityOf,
138
143
  parseEpc: () => parseEpc,
139
144
  parseIsoDuration: () => parseIsoDuration,
@@ -2681,28 +2686,80 @@ function foldJobResponses(rows) {
2681
2686
  }
2682
2687
 
2683
2688
  // src/face2-adapter.ts
2689
+ function isAggregationRecord(record) {
2690
+ if (!record || typeof record !== "object") return false;
2691
+ const r = record;
2692
+ return typeof r.parentID === "string" && r.parentID.trim().length > 0 && r.epc === void 0;
2693
+ }
2684
2694
  function get(obj, path) {
2685
2695
  return path.split(".").reduce((o, k) => o == null ? o : o[k], obj);
2686
2696
  }
2687
- function resolve(v, record) {
2697
+ function resolve(v, record, field, errors) {
2688
2698
  if (v === void 0) return void 0;
2689
- if (v.startsWith("$.")) {
2690
- const r = get(record, v.slice(2));
2691
- return r === void 0 || r === null ? void 0 : String(r);
2699
+ if (!v.startsWith("$.")) return v;
2700
+ const r = get(record, v.slice(2));
2701
+ if (r === void 0 || r === null) return void 0;
2702
+ if (typeof r === "object") {
2703
+ errors.push(`${field} \uAC00 \uAC12\uC774 \uC544\uB2C8\uB77C ${Array.isArray(r) ? "\uBC30\uC5F4" : "\uAC1D\uCCB4"}\uB85C \uC654\uB2E4(${v}) \u2014 \uCEE4\uB125\uD130\uAC00 \uD45C\uC900 \uAD6C\uC870\uB97C \uADF8\uB300\uB85C \uC2E4\uC5C8\uB2E4: ${JSON.stringify(r).slice(0, 80)}`);
2704
+ return void 0;
2692
2705
  }
2693
- return v;
2706
+ return String(r);
2707
+ }
2708
+ function resolveList(v, record, field, errors) {
2709
+ if (v === void 0) return [];
2710
+ const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
2711
+ if (r === void 0 || r === null) return [];
2712
+ if (!Array.isArray(r) || r.some((x) => typeof x !== "string")) {
2713
+ errors.push(`${field} \uAC00 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4(${v}): ${JSON.stringify(r).slice(0, 80)}`);
2714
+ return [];
2715
+ }
2716
+ return r.slice();
2717
+ }
2718
+ function resolveQuantityList(v, record, field, errors) {
2719
+ if (v === void 0) return [];
2720
+ const r = get(record, v.startsWith("$.") ? v.slice(2) : v);
2721
+ if (r === void 0 || r === null) return [];
2722
+ if (!Array.isArray(r) || r.some((x) => !x || typeof x !== "object" || Array.isArray(x))) {
2723
+ errors.push(`${field} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4(${v}): ${JSON.stringify(r).slice(0, 80)}`);
2724
+ return [];
2725
+ }
2726
+ return r.map((x) => ({ ...x }));
2727
+ }
2728
+ function mapRecordChecked(record, mapping, eventTime) {
2729
+ const errors = [];
2730
+ const action = resolve(mapping.action, record, "action", errors) ?? "";
2731
+ const bizStep = resolve(mapping.bizStep, record, "bizStep", errors) ?? "";
2732
+ const disposition = resolve(mapping.disposition, record, "disposition", errors);
2733
+ const readPoint = resolve(mapping.readPoint, record, "readPoint", errors);
2734
+ const bizLocation = resolve(mapping.bizLocation, record, "bizLocation", errors);
2735
+ if (mapping.type === "AggregationEvent") {
2736
+ const parentID = resolve(mapping.parentID, record, "parentID", errors) ?? "";
2737
+ const childEPCs = resolveList(mapping.childEPCs, record, "childEPCs", errors);
2738
+ const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, "childQuantityList", errors);
2739
+ return {
2740
+ event: aggregationEvent({
2741
+ eventTime,
2742
+ action,
2743
+ bizStep,
2744
+ disposition,
2745
+ parentID,
2746
+ /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「자식이 없다」고 말하는 것이 된다. */
2747
+ ...childEPCs.length ? { childEPCs } : {},
2748
+ ...childQuantityList.length ? { childQuantityList } : {},
2749
+ readPoint,
2750
+ bizLocation
2751
+ }),
2752
+ errors
2753
+ };
2754
+ }
2755
+ const epc = resolve(mapping.epc, record, "epc", errors);
2756
+ return {
2757
+ event: objectEvent({ eventTime, action, bizStep, disposition, epcList: epc ? [epc] : [], readPoint, bizLocation }),
2758
+ errors
2759
+ };
2694
2760
  }
2695
2761
  function mapRecord(record, mapping, eventTime) {
2696
- const epc = resolve(mapping.epc, record);
2697
- return objectEvent({
2698
- eventTime,
2699
- action: resolve(mapping.action, record) ?? "",
2700
- bizStep: resolve(mapping.bizStep, record) ?? "",
2701
- disposition: resolve(mapping.disposition, record),
2702
- epcList: epc ? [epc] : [],
2703
- readPoint: resolve(mapping.readPoint, record),
2704
- bizLocation: resolve(mapping.bizLocation, record)
2705
- });
2762
+ return mapRecordChecked(record, mapping, eventTime).event;
2706
2763
  }
2707
2764
  function ingest(records, rules, opts) {
2708
2765
  const ruleByType = new Map(rules.map((r) => [r.sourceType, r]));
@@ -2715,9 +2772,10 @@ function ingest(records, rules, opts) {
2715
2772
  rejected.push({ record, errors: [`\uB9E4\uCE6D \uB8F0 \uC5C6\uC74C: sourceType=${record["sourceType"]}`] });
2716
2773
  continue;
2717
2774
  }
2718
- const eventTime = (opts.eventTimePath ? resolve("$." + opts.eventTimePath, record) : void 0) ?? opts.defaultEventTime;
2719
- const ev = mapRecord(record, rule.mapping, eventTime);
2720
- const errors = validateEpcisEvent(ev);
2775
+ const timeErrors = [];
2776
+ const eventTime = (opts.eventTimePath ? resolve("$." + opts.eventTimePath, record, "eventTime", timeErrors) : void 0) ?? opts.defaultEventTime;
2777
+ const { event: ev, errors: mapErrors } = mapRecordChecked(record, rule.mapping, eventTime);
2778
+ const errors = [...timeErrors, ...mapErrors, ...validateEpcisEvent(ev)];
2721
2779
  if (errors.length) {
2722
2780
  rejected.push({ record, errors });
2723
2781
  continue;
@@ -7166,6 +7224,273 @@ function ingestEnergyEquipmentRecords(records, opts) {
7166
7224
  return { accepted, rejected };
7167
7225
  }
7168
7226
 
7227
+ // src/operational-ingest.ts
7228
+ var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
7229
+ var EQUIPMENT_STATUS = ["idle", "busy", "down"];
7230
+ var PERSON_STATUS = ["idle", "busy"];
7231
+ var ASSET_STATUS = ["idle", "in-use"];
7232
+ var SPECS = {
7233
+ task: {
7234
+ eventType: OP_EVENT.task,
7235
+ identity: "taskId",
7236
+ /* 종류가 없으면 성과를 종류별로 접을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
7237
+ required: ["taskId", "kind", "status"],
7238
+ fields: {
7239
+ taskId: "string",
7240
+ kind: "string",
7241
+ status: "string",
7242
+ fromNode: "string",
7243
+ toNode: "string",
7244
+ itemRefs: "string[]",
7245
+ resourceRef: "string",
7246
+ resources: "string[]",
7247
+ personnel: "string[]",
7248
+ assets: "string[]",
7249
+ orderId: "string",
7250
+ intent: "string",
7251
+ progress: "number",
7252
+ remainingMs: "number",
7253
+ durationMs: "number",
7254
+ startedAtSimMs: "number",
7255
+ outcome: "string",
7256
+ priority: "number",
7257
+ startTime: "string",
7258
+ endTime: "string",
7259
+ materialActual: "object[]",
7260
+ recordTime: "string"
7261
+ },
7262
+ enums: {
7263
+ status: TASK_STATUS,
7264
+ intent: ["transport", "process", "dwell"],
7265
+ /* 품질 판정은 **있었던 작업만** — 없음은 「양품」이 아니라 「판정하지 않았다」다. */
7266
+ outcome: ["good", "scrap"]
7267
+ }
7268
+ },
7269
+ equipment: {
7270
+ eventType: OP_EVENT.equipment,
7271
+ identity: "moverId",
7272
+ // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
7273
+ required: ["moverId", "kind", "status"],
7274
+ // vocabulary-guard: allow 위와 같은 이유
7275
+ fields: {
7276
+ moverId: "string",
7277
+ kind: "string",
7278
+ status: "string",
7279
+ location: "string",
7280
+ homeLocation: "string",
7281
+ // vocabulary-guard: allow
7282
+ taskId: "string",
7283
+ held: "boolean",
7284
+ effectiveStart: "string",
7285
+ effectiveEnd: "string",
7286
+ recordTime: "string",
7287
+ /* 이동 구간 — 실 시스템도 줄 수 있는 사실이다(AGV·RTLS 가 출발·도착·소요를 낸다). 안쪽 필드까지
7288
+ 재검사하지는 않는다: 그 모양은 `EquipmentMotion` 계약이고, 여기서 두 번 지키면 두 벌이 된다. */
7289
+ motion: "object"
7290
+ },
7291
+ enums: { status: EQUIPMENT_STATUS }
7292
+ },
7293
+ person: {
7294
+ eventType: OP_EVENT.person,
7295
+ identity: "personId",
7296
+ required: ["personId", "status"],
7297
+ fields: {
7298
+ personId: "string",
7299
+ status: "string",
7300
+ personnelClassIds: "string[]",
7301
+ taskId: "string",
7302
+ location: "string",
7303
+ offShift: "boolean",
7304
+ effectiveStart: "string",
7305
+ effectiveEnd: "string",
7306
+ recordTime: "string"
7307
+ },
7308
+ enums: { status: PERSON_STATUS }
7309
+ },
7310
+ asset: {
7311
+ eventType: OP_EVENT.asset,
7312
+ identity: "assetId",
7313
+ required: ["assetId", "status"],
7314
+ fields: {
7315
+ assetId: "string",
7316
+ status: "string",
7317
+ assetClassIds: "string[]",
7318
+ location: "string",
7319
+ taskId: "string",
7320
+ carrying: "string",
7321
+ effectiveStart: "string",
7322
+ effectiveEnd: "string",
7323
+ recordTime: "string"
7324
+ },
7325
+ enums: { status: ASSET_STATUS }
7326
+ },
7327
+ order: {
7328
+ eventType: OP_EVENT.order,
7329
+ identity: "orderId",
7330
+ /*
7331
+ * 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
7332
+ * 그것은 「모른다」가 아니라 「아무것도 안 됐다」로 읽힌다 — 결측을 0 으로 메우지 않는다.
7333
+ */
7334
+ required: ["orderId", "kind", "status", "requested", "fulfilled"],
7335
+ fields: {
7336
+ orderId: "string",
7337
+ kind: "string",
7338
+ status: "string",
7339
+ requested: "number",
7340
+ fulfilled: "number",
7341
+ gtin: "string",
7342
+ held: "boolean",
7343
+ lines: "object[]",
7344
+ priority: "number",
7345
+ startTime: "string",
7346
+ endTime: "string",
7347
+ allocated: "string[]",
7348
+ bizTransaction: "string",
7349
+ dockDoor: "string",
7350
+ windowStartMs: "number",
7351
+ recordTime: "string"
7352
+ }
7353
+ /* 상태·종류는 도메인이 소유한다 — 닫지 않는다. */
7354
+ },
7355
+ quality: {
7356
+ eventType: OP_EVENT.quality,
7357
+ identity: "moverId",
7358
+ // vocabulary-guard: allow 저널 와이어 필드
7359
+ /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
7360
+ required: ["moverId", "good", "goodCount", "scrapCount"],
7361
+ // vocabulary-guard: allow
7362
+ fields: { moverId: "string", good: "boolean", goodCount: "number", scrapCount: "number", recordTime: "string" }
7363
+ // vocabulary-guard: allow
7364
+ }
7365
+ };
7366
+ function operationalKindOf(record) {
7367
+ if (!record || typeof record !== "object") return void 0;
7368
+ const r = record;
7369
+ if (r.epc !== void 0 || r.meterId !== void 0 || r.equipmentId !== void 0) return void 0;
7370
+ const has = (k) => typeof r[k] === "string" && r[k].trim().length > 0;
7371
+ if (has("moverId")) return r.good !== void 0 ? "quality" : "equipment";
7372
+ if (has("personId")) return "person";
7373
+ if (has("assetId")) return "asset";
7374
+ if (has("taskId")) return "task";
7375
+ if (has("orderId")) return "order";
7376
+ return void 0;
7377
+ }
7378
+ function isOperationalRecord(record) {
7379
+ return operationalKindOf(record) !== void 0;
7380
+ }
7381
+ function ingestOperationalRecords(records, opts) {
7382
+ const arr = Array.isArray(records) ? records : records ? [records] : [];
7383
+ const accepted = [];
7384
+ const rejected = [];
7385
+ let seq = 0;
7386
+ for (const record of arr) {
7387
+ const kind = operationalKindOf(record);
7388
+ if (!kind) {
7389
+ rejected.push({
7390
+ record,
7391
+ 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)"]
7392
+ // vocabulary-guard: allow 거부 이유가 계약 필드 이름을 말한다
7393
+ });
7394
+ continue;
7395
+ }
7396
+ const spec = SPECS[kind];
7397
+ const r = record;
7398
+ const errors = [];
7399
+ const unknown = Object.keys(r).filter((k) => k !== "at" && spec.fields[k] === void 0);
7400
+ if (unknown.length) errors.push(`${kind}: \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uD544\uB4DC \u2014 ${unknown.join(", ")}`);
7401
+ for (const name of spec.required) {
7402
+ const v = r[name];
7403
+ if (v === void 0 || v === null || v === "") errors.push(`${kind}: ${name} \uC5C6\uC74C \u2014 \uC9C0\uC5B4\uB0BC \uC218 \uC5C6\uB294 \uAC12\uC774\uB2E4`);
7404
+ }
7405
+ const data = {};
7406
+ for (const [name, type] of Object.entries(spec.fields)) {
7407
+ const v = r[name];
7408
+ if (v === void 0 || v === null || v === "") continue;
7409
+ switch (type) {
7410
+ case "string": {
7411
+ if (typeof v !== "string") {
7412
+ errors.push(`${kind}.${name} \uC774 \uBB38\uC790\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7413
+ break;
7414
+ }
7415
+ const allowed = spec.enums?.[name];
7416
+ if (allowed && !allowed.includes(v)) {
7417
+ 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 ")}`);
7418
+ break;
7419
+ }
7420
+ data[name] = v;
7421
+ break;
7422
+ }
7423
+ case "number": {
7424
+ const n = Number(v);
7425
+ if (typeof v === "boolean" || !Number.isFinite(n)) {
7426
+ errors.push(`${kind}.${name} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7427
+ break;
7428
+ }
7429
+ if (name === "progress" && (n < 0 || n > 1)) {
7430
+ errors.push(`${kind}.progress \uB294 0~1 \uBE44\uC728\uC774\uB2E4: ${n}`);
7431
+ break;
7432
+ }
7433
+ if ((name === "requested" || name === "fulfilled" || name === "goodCount" || name === "scrapCount") && n < 0) {
7434
+ errors.push(`${kind}.${name} \uAC00 \uC74C\uC218\uB2E4: ${n}`);
7435
+ break;
7436
+ }
7437
+ data[name] = n;
7438
+ break;
7439
+ }
7440
+ case "boolean": {
7441
+ if (typeof v !== "boolean") {
7442
+ errors.push(`${kind}.${name} \uAC00 \uCC38/\uAC70\uC9D3\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7443
+ break;
7444
+ }
7445
+ data[name] = v;
7446
+ break;
7447
+ }
7448
+ case "string[]": {
7449
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
7450
+ errors.push(`${kind}.${name} \uAC00 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7451
+ break;
7452
+ }
7453
+ data[name] = v.slice();
7454
+ break;
7455
+ }
7456
+ case "object": {
7457
+ if (Array.isArray(v) || typeof v !== "object") {
7458
+ errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7459
+ break;
7460
+ }
7461
+ data[name] = { ...v };
7462
+ break;
7463
+ }
7464
+ case "object[]": {
7465
+ if (!Array.isArray(v) || v.some((x) => !x || typeof x !== "object")) {
7466
+ errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
7467
+ break;
7468
+ }
7469
+ data[name] = v.map((x) => ({ ...x }));
7470
+ break;
7471
+ }
7472
+ }
7473
+ }
7474
+ const at = String(r.at ?? "").trim() || opts.defaultEventTime;
7475
+ const atMs = at ? Date.parse(at) : Number.NaN;
7476
+ if (!Number.isFinite(atMs)) {
7477
+ 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`);
7478
+ }
7479
+ if (errors.length) {
7480
+ rejected.push({ record, errors });
7481
+ continue;
7482
+ }
7483
+ accepted.push({
7484
+ eventId: `${opts.tenantId}-op-${kind}-${++seq}`,
7485
+ eventType: spec.eventType,
7486
+ eventTime: new Date(atMs).toISOString(),
7487
+ tenantId: opts.tenantId,
7488
+ data
7489
+ });
7490
+ }
7491
+ return { accepted, rejected };
7492
+ }
7493
+
7169
7494
  // src/energy-attribution.ts
7170
7495
  var near = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
7171
7496
  function attributeEnergy(opts) {
@@ -7350,7 +7675,7 @@ function electricityCost(input) {
7350
7675
  }
7351
7676
 
7352
7677
  // src/vocabulary.ts
7353
- var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
7678
+ var RETIRED_VOCABULARY = ["mover", "Mover", "MOVER", "node", "Node", "NODE", "BoardDef", "loadBoard"];
7354
7679
  var VOCABULARY_EXCEPTIONS = [
7355
7680
  /* ── 저널 와이어 필드 — append-only 역사이므로 이름을 바꾸지 않는다 ───────
7356
7681
  * 같은 사실이 시점에 따라 다른 키로 들어가면 낡은 이름보다 나쁘다. 개명은 이벤트 스키마
@@ -7491,16 +7816,20 @@ function retiredVocabularyIn(line) {
7491
7816
  ingest,
7492
7817
  ingestEnergyEquipmentRecords,
7493
7818
  ingestEnergyRecords,
7819
+ ingestOperationalRecords,
7820
+ isAggregationRecord,
7494
7821
  isElectricalLocationType,
7495
7822
  isEnergyEquipmentRecord,
7496
7823
  isEnergyRecord,
7497
7824
  isEquipmentLevel,
7825
+ isOperationalRecord,
7498
7826
  isoDurationHours,
7499
7827
  itemKeyOf,
7500
7828
  levelOfLocationType,
7501
7829
  lgtinClass,
7502
7830
  locationStatusOf,
7503
7831
  mapRecord,
7832
+ mapRecordChecked,
7504
7833
  meetsTests,
7505
7834
  minuteOfDayAt,
7506
7835
  monteCarloForecast,
@@ -7508,6 +7837,7 @@ function retiredVocabularyIn(line) {
7508
7837
  objectEvent,
7509
7838
  offCalendarAt,
7510
7839
  offCalendarReasonAt,
7840
+ operationalKindOf,
7511
7841
  operationsCapabilityOf,
7512
7842
  parseEpc,
7513
7843
  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.37",
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": {