@operato/ops-contract 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +23 -0
  2. package/dist/capability.d.ts +142 -0
  3. package/dist/capability.js +127 -0
  4. package/dist/capacity.d.ts +99 -0
  5. package/dist/capacity.js +172 -0
  6. package/dist/contract.d.ts +3557 -0
  7. package/dist/contract.js +1248 -0
  8. package/dist/domain-catalog.d.ts +280 -0
  9. package/dist/domain-catalog.js +322 -0
  10. package/dist/domain-definition.d.ts +356 -0
  11. package/dist/domain-definition.js +137 -0
  12. package/dist/ems-profile.d.ts +147 -0
  13. package/dist/ems-profile.js +367 -0
  14. package/dist/energy-ingest.d.ts +214 -0
  15. package/dist/energy-ingest.js +801 -0
  16. package/dist/epcis.d.ts +458 -0
  17. package/dist/epcis.js +640 -0
  18. package/dist/face2-adapter.d.ts +191 -0
  19. package/dist/face2-adapter.js +284 -0
  20. package/dist/index.d.ts +18 -0
  21. package/dist/index.js +39 -0
  22. package/dist/iso-duration.d.ts +5 -0
  23. package/dist/iso-duration.js +43 -0
  24. package/dist/master-data.d.ts +46 -0
  25. package/dist/master-data.js +100 -0
  26. package/dist/mes-profile.d.ts +14 -0
  27. package/dist/mes-profile.js +59 -0
  28. package/dist/operational-ingest.d.ts +44 -0
  29. package/dist/operational-ingest.js +379 -0
  30. package/dist/operations-capability.d.ts +117 -0
  31. package/dist/operations-capability.js +120 -0
  32. package/dist/scenario-validate.d.ts +15 -0
  33. package/dist/scenario-validate.js +72 -0
  34. package/dist/vocabulary.d.ts +28 -0
  35. package/dist/vocabulary.js +81 -0
  36. package/dist/wms-profile.d.ts +20 -0
  37. package/dist/wms-profile.js +58 -0
  38. package/dist/yms-profile.d.ts +15 -0
  39. package/dist/yms-profile.js +39 -0
  40. package/dist-cjs/index.cjs +3767 -0
  41. package/package.json +30 -0
@@ -0,0 +1,191 @@
1
+ import type { CanonicalEnvelope } from './contract.ts';
2
+ import type { EpcisEvent } from './epcis.ts';
3
+ /** 매핑 값: 리터럴, 또는 소스 필드 참조("$.field.path"). */
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
+ }
33
+ /** ObjectEvent 매핑 스펙(선언적). */
34
+ export interface ObjectEventMapping {
35
+ type: 'ObjectEvent';
36
+ action: MapValue;
37
+ bizStep: MapValue;
38
+ disposition?: MapValue;
39
+ /**
40
+ * 개체 하나의 식별자 → `epcList: [epc]`. **`quantityList` 를 쓰면 없어도 된다** — 낱개 번호가 없는
41
+ * 자재(밀가루 3.5kg)는 개체가 없다. 둘 다 없으면 무엇을 관측했는지 말하지 않은 것이므로 오류다.
42
+ */
43
+ epc?: MapValue;
44
+ /**
45
+ * 클래스와 수량 → `quantityList` (`{ epcClass, quantity, uom? }` 배열).
46
+ *
47
+ * ── 왜 뒤늦게 생겼나 (2026-08-20) ───────────────────────────────────────────
48
+ * `AggregationEventMapping.childQuantityList` 가 같은 이유로 하루 먼저 생겼다(적재를 말할 길이 없었다).
49
+ * 개체 관측에는 그 자리가 없어서, **원본이 「이 자리에 이 품목이 얼마 남았다」를 말할 길이 없었다.**
50
+ * 받는 쪽은 처음부터 준비돼 있었다 — `ObservedReducer` 는 `quantityList` 를 읽고, 개체 없이 수량만 오는
51
+ * 경우까지 따로 다룬다(비직렬 자재는 클래스 식별자가 곧 물품의 키다). 검증도 유효로 판정한다
52
+ * (`epcList` 와 `quantityList` 가 **둘 다** 비었을 때만 오류). 문만 없었다.
53
+ *
54
+ * 비직렬 수량이 주된 경로인 원본(식품 제조 MES)은 이 자리가 없으면 라이브를 낼 수 없다.
55
+ *
56
+ * **잔량 절대값을 싣는다** — 「얼마 뺐다」가 아니라 「얼마 남았다」다. 그래야 사건 하나를 놓쳐도 다음
57
+ * 사건이 정답을 다시 말해 주고 오차가 쌓이지 않는다(시뮬 `consumeMaterials` 가 택한 것과 같은 규약).
58
+ */
59
+ quantityList?: MapValue;
60
+ /**
61
+ * **개체·로트의 마스터데이터** → `ilmd`(EPCIS 2.0 Instance/Lot Master Data).
62
+ *
63
+ * ── 왜 뒤늦게 생겼나 (2026-08-24 실측) ─────────────────────────────────────
64
+ * `quantityList` 와 **똑같은 모양의 결함**이었다: 받는 쪽은 처음부터 준비돼 있는데 **문이 없었다.**
65
+ * `ObservedReducer` 는 `ilmd` 를 읽어 로트와 소비기한을 세운다(`expiryOf` · `ILMD_ATTR.lot`), 그리고
66
+ * 이름을 모르는 속성도 버리지 않고 원문으로 든다. 그런데 이 매핑에 자리가 없어서 **커넥터가 그것을
67
+ * 말할 길이 없었다.**
68
+ *
69
+ * 실측(포천): 원본 재고 1,704행 **전부**에 소비기한이 있는데 투영된 상태에는 **0건**이었다. 지난
70
+ * 재고 950건·59,785kg 이 어디에도 나타나지 않았다 — 화면이 비어 있는 것이 「이상 없음」으로 읽혔다.
71
+ *
72
+ * 값은 **객체 그대로** 받는다(`{ 'cbvmda:lotNumber': …, 'cbvmda:itemExpirationDate': … }`). 키 어휘는
73
+ * 표준(CBV MDA)의 것이고 커널이 `ILMD_ATTR` 로 그 이름을 안다 — 여기서 이름을 다시 정하지 않는다.
74
+ */
75
+ ilmd?: MapValue;
76
+ readPoint?: MapValue;
77
+ bizLocation?: MapValue;
78
+ /** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
79
+ errorDeclaration?: ErrorDeclarationMapping;
80
+ }
81
+ /**
82
+ * AggregationEvent 매핑 스펙 — **담김의 사실**(무엇이 무엇에 실렸나).
83
+ *
84
+ * ── 왜 뒤늦게 생겼나 (2026-08-19) ───────────────────────────────────────────
85
+ * 이 파일은 오래 *"Agg/Txn 매핑은 확장 지점"* 이라고만 적어 두었고, 그래서 원본이 **적재를 말할 길이
86
+ * 없었다.** 관측 리듀서는 `AggregationEvent` 를 받으면 자식 물품에 `parent` 를 붙이도록 이미 되어 있었는데
87
+ * (3D 가 「이 상자가 어느 팔레트에 실렸나」를 묻는 자리) 그 사실이 문 앞에서 사라졌다 — 시뮬 원본을
88
+ * 붙여 돌려 보고서야 그것이 로그로 드러났다(*"is not carried to the twin"*).
89
+ *
90
+ * 자식은 **두 가지로 말할 수 있다**(표준이 그렇고 우리 커널도 둘 다 낸다):
91
+ * · `childEPCs` — 개체 하나하나(상자 3개의 식별자).
92
+ * · `childQuantityList` — 클래스와 수량(「이 품번 40개」). 낱개 식별자가 없는 입고·포장이 이 모양이다.
93
+ * 하나만 받으면 나머지 사실이 문 앞에서 사라진다 — 실제로 그렇게 사라지고 있었다(로그가 짚었다).
94
+ */
95
+ export interface AggregationEventMapping {
96
+ type: 'AggregationEvent';
97
+ action: MapValue;
98
+ bizStep: MapValue;
99
+ disposition?: MapValue;
100
+ parentID: MapValue;
101
+ /** 자식 목록 참조("$.childEPCs") — 문자열 배열이어야 한다. */
102
+ childEPCs?: MapValue;
103
+ /** 수량으로 담긴 자식 참조("$.childQuantityList") — `{ epcClass, quantity, uom? }` 배열. */
104
+ childQuantityList?: MapValue;
105
+ readPoint?: MapValue;
106
+ bizLocation?: MapValue;
107
+ /** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
108
+ errorDeclaration?: ErrorDeclarationMapping;
109
+ }
110
+ /**
111
+ * **변환의 사실** — 무엇이 들어가 무엇이 나왔나(EPCIS `TransformationEvent`).
112
+ *
113
+ * ── 왜 이 갈래가 필요한가 (2026-08-21) ─────────────────────────────────────
114
+ * 정규 레코드 계약이 `ObjectEvent`·`AggregationEvent` 둘만 다뤄서, 원본이 「이 로트를 소비해 이 제품을
115
+ * 만들었다」고 말해도 실을 자리가 없었다. 그 사실이 문 앞에서 사라진다.
116
+ *
117
+ * **HACCP 회수 범위 판정이 정확히 이 자리를 요구한다** — 「이 로트가 어느 제품에 들어갔나」다. 로트를
118
+ * 개체로 식별할 수 없는 현장(연속량·분할 로트)에서는 소비를 `inputQuantityList` 의 **LGTIN 클래스**
119
+ * (`urn:epc:class:lgtin:<프리픽스>.<품번>.<로트>`)로 말한다 — 표준이 로트 단위 클래스를 그렇게 정한다.
120
+ *
121
+ * `action` 이 없다: 표준이 `TransformationEvent` 에 `action` 을 두지 않는다(들어감과 나옴이 곧 뜻이다).
122
+ */
123
+ export interface TransformationEventMapping {
124
+ type: 'TransformationEvent';
125
+ bizStep: MapValue;
126
+ disposition?: MapValue;
127
+ /** 소비된 개체들("$.inputEPCList") — 문자열 배열. */
128
+ inputEPCList?: MapValue;
129
+ /** 소비된 클래스+수량("$.inputQuantityList") — 로트 단위 소비가 이 자리다. */
130
+ inputQuantityList?: MapValue;
131
+ /** 산출된 개체들("$.outputEPCList"). */
132
+ outputEPCList?: MapValue;
133
+ /** 산출된 클래스+수량("$.outputQuantityList"). */
134
+ outputQuantityList?: MapValue;
135
+ /** 여러 이벤트를 한 변환으로 잇는 식별자("$.transformationID") — 한 오더의 여러 단계가 이것으로 묶인다. */
136
+ transformationID?: MapValue;
137
+ readPoint?: MapValue;
138
+ bizLocation?: MapValue;
139
+ /** 이 레코드가 앞선 사건의 정정이면 그 선언(§ErrorDeclarationMapping). */
140
+ errorDeclaration?: ErrorDeclarationMapping;
141
+ }
142
+ export type EventMapping = ObjectEventMapping | AggregationEventMapping | TransformationEventMapping;
143
+ /**
144
+ * 이 품목 레코드가 **담김의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
145
+ *
146
+ * 담김은 부모와 자식으로 말한다(`parentID`·`childEPCs`). 개체 하나의 관측(`epc`)과 섞이지 않게, 부모가
147
+ * 있으면 담김으로 본다 — 둘 다 있으면 커넥터가 무엇을 말하려는지 알 수 없으므로 담김으로 받지 않는다.
148
+ */
149
+ export declare function isAggregationRecord(record: unknown): boolean;
150
+ /**
151
+ * 이 레코드가 **변환의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
152
+ *
153
+ * 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
154
+ * 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
155
+ *
156
+ * 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 다 있어야 유효한 이벤트가 되지만(아래),
157
+ * 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
158
+ * 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
159
+ */
160
+ export declare function isTransformationRecord(record: unknown): boolean;
161
+ /** 룰: 소스 레코드의 판별자(sourceType)로 매핑을 고른다. */
162
+ export interface AdapterRule {
163
+ sourceType: string;
164
+ mapping: EventMapping;
165
+ }
166
+ export interface IngestOptions {
167
+ tenantId: string;
168
+ /** eventTime 소스 필드(없으면 defaultEventTime). */
169
+ eventTimePath?: string;
170
+ defaultEventTime: string;
171
+ }
172
+ export interface IngestResult {
173
+ accepted: CanonicalEnvelope[];
174
+ rejected: {
175
+ record: unknown;
176
+ errors: string[];
177
+ }[];
178
+ }
179
+ type Rec = Record<string, unknown>;
180
+ export declare function mapRecordChecked(record: Rec, mapping: EventMapping, eventTime: string): {
181
+ event: EpcisEvent;
182
+ errors: string[];
183
+ };
184
+ /** 단일 레코드 → 정규 EPCIS 이벤트(매핑만). 매핑 문제까지 보려면 `mapRecordChecked`. */
185
+ export declare function mapRecord(record: Rec, mapping: EventMapping, eventTime: string): EpcisEvent;
186
+ /**
187
+ * 레거시 레코드 배열 → 정규 EPCIS 봉투. 매핑 후 검증, 실패분은 rejected(오염 차단).
188
+ * 실 EPCIS-native 소스면 매핑은 거의 항등, 검증만 통과시키면 됨.
189
+ */
190
+ export declare function ingest(records: Rec[], rules: AdapterRule[], opts: IngestOptions): IngestResult;
191
+ export {};
@@ -0,0 +1,284 @@
1
+ /*
2
+ * Face 2 — 레거시 ↔ 레퍼런스 어댑터 (anti-corruption layer, 모니터링 ingress).
3
+ * 설계 SoT: integration/face2-adapters.md
4
+ *
5
+ * 실 WMS 의 다양한 페이로드를 **선언적 매핑**으로 정규 EPCIS 로 변환 + **검증**.
6
+ * 여기서 `validateEpcisEvent` 가 실 소비처를 얻는다(검증 실패 = ACL 이 오염 차단).
7
+ *
8
+ * 매핑 엔진은 walking-skeleton 범위에서 무의존 declarative spec 사용.
9
+ * (jsonata/템플릿 엔진 최종 선택은 열린 결정 — face2-adapters.md. 커널 zero-dep 유지 위해 여기선 미도입.)
10
+ */
11
+ import { aggregationEvent, objectEvent, transformationEvent, 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
+ }
24
+ /**
25
+ * 이 레코드가 **변환의 사실**인가 — 라우팅 판정을 한 곳에 둔다(소비처가 각자 짐작하지 않게).
26
+ *
27
+ * 변환은 **들어간 것과 나온 것**으로 말한다. 개체 하나의 관측(`epc`)이나 담김(`parentID`)과 섞이지
28
+ * 않게, 그 둘이 없고 입력·출력 중 하나라도 있으면 변환으로 본다.
29
+ *
30
+ * 한쪽이라도 있으면 **변환하려는 레코드로 본다.** 양쪽이 다 있어야 유효한 이벤트가 되지만(아래),
31
+ * 판정은 「무엇을 말하려는 레코드인가」이므로 한쪽만 있어도 이 갈래로 보내야 한다 — 그러지 않으면
32
+ * 반쪽만 온 레코드가 개체 관측으로 잘못 흘러가고, 무엇이 빠졌는지 아무도 말해 주지 않는다.
33
+ */
34
+ export function isTransformationRecord(record) {
35
+ if (!record || typeof record !== 'object')
36
+ return false;
37
+ const r = record;
38
+ if (r.epc !== undefined || r.parentID !== undefined)
39
+ return false;
40
+ const has = (k) => Array.isArray(r[k]) && r[k].length > 0;
41
+ return has('inputEPCList') || has('inputQuantityList') || has('outputEPCList') || has('outputQuantityList');
42
+ }
43
+ function get(obj, path) {
44
+ return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
45
+ }
46
+ /**
47
+ * 매핑 값 해석: "$.path" 는 소스 참조, 그 외는 리터럴.
48
+ *
49
+ * ── 객체를 문자열로 굳히지 않는다 (2026-08-19 실측으로 고침) ────────────────
50
+ * 예전에는 무엇이 오든 `String(값)` 이었다. 그래서 커널이 낸 EPCIS 의 `readPoint`(= `{ id }`)를 이 문에
51
+ * 그대로 실은 커넥터가 있었고, **오류 하나 없이** `readPoint: { id: "[object Object]" }` 가 저널에 쌓였다
52
+ * (실 앱에서 관측). 자리를 잃은 관측은 「어디 있나」에 답하지 못하는데 화면은 값이 있다고 믿는다.
53
+ *
54
+ * 그러니 객체·배열은 **매핑 오류**다. 조용히 버리지도(그러면 자리가 없어진 이유를 아무도 모른다),
55
+ * 문자열로 굳히지도 않고, 그 레코드를 이유와 함께 거부한다.
56
+ */
57
+ function resolve(v, record, field, errors) {
58
+ if (v === undefined)
59
+ return undefined;
60
+ if (!v.startsWith('$.'))
61
+ return v;
62
+ const r = get(record, v.slice(2));
63
+ if (r === undefined || r === null)
64
+ return undefined;
65
+ if (typeof r === 'object') {
66
+ errors.push(`${field} 가 값이 아니라 ${Array.isArray(r) ? '배열' : '객체'}로 왔다(${v}) — 커넥터가 표준 구조를 그대로 실었다: ${JSON.stringify(r).slice(0, 80)}`);
67
+ return undefined;
68
+ }
69
+ return String(r);
70
+ }
71
+ /** 목록 참조 해석 — 문자열 배열만 받는다(하나만 온 것을 배열로 지어내지 않는다). */
72
+ function resolveList(v, record, field, errors) {
73
+ if (v === undefined)
74
+ return [];
75
+ const r = get(record, v.startsWith('$.') ? v.slice(2) : v);
76
+ if (r === undefined || r === null)
77
+ return [];
78
+ if (!Array.isArray(r) || r.some(x => typeof x !== 'string')) {
79
+ errors.push(`${field} 가 문자열 배열이 아니다(${v}): ${JSON.stringify(r).slice(0, 80)}`);
80
+ return [];
81
+ }
82
+ return r.slice();
83
+ }
84
+ /**
85
+ * 수량 목록 참조 해석 — `{ epcClass, quantity, uom? }` 배열.
86
+ *
87
+ * 값의 옳고 그름(클래스 식별자인가·수량이 양수인가)은 **검증이 본다**(`validateEpcisEvent`) — 여기서
88
+ * 다시 판정하면 규칙이 두 벌이 된다. 이 자리는 「배열인가·객체인가」만 지킨다.
89
+ */
90
+ function resolveQuantityList(v, record, field, errors) {
91
+ if (v === undefined)
92
+ return [];
93
+ const r = get(record, v.startsWith('$.') ? v.slice(2) : v);
94
+ if (r === undefined || r === null)
95
+ return [];
96
+ if (!Array.isArray(r) || r.some(x => !x || typeof x !== 'object' || Array.isArray(x))) {
97
+ errors.push(`${field} 가 객체 배열이 아니다(${v}): ${JSON.stringify(r).slice(0, 80)}`);
98
+ return [];
99
+ }
100
+ return r.map(x => ({ ...x }));
101
+ }
102
+ /**
103
+ * 객체 하나를 그대로 꺼낸다 — `ilmd` 처럼 **구조가 곧 값**인 자리.
104
+ *
105
+ * `resolve` 는 객체가 오면 오류로 본다(스칼라 자리에 표준 구조를 실은 것이 실제 결함이었다). 여기서는
106
+ * 반대다: 객체가 맞고 스칼라가 틀렸다. 그래서 함수를 갈라 두고 각자 자기 모양을 단정한다.
107
+ */
108
+ function resolveObject(v, record, field, errors) {
109
+ if (v === undefined)
110
+ return undefined;
111
+ const r = get(record, v.startsWith('$.') ? v.slice(2) : v);
112
+ if (r === undefined || r === null)
113
+ return undefined;
114
+ if (typeof r !== 'object' || Array.isArray(r)) {
115
+ errors.push(`${field} 가 객체가 아니다(${v}): ${JSON.stringify(r).slice(0, 80)}`);
116
+ return undefined;
117
+ }
118
+ const o = { ...r };
119
+ /* 빈 객체는 「말하지 않은 것」이다 — 실으면 소비처가 「마스터데이터가 있는데 비었다」로 읽는다. */
120
+ return Object.keys(o).length ? o : undefined;
121
+ }
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
+ }
157
+ export function mapRecordChecked(record, mapping, eventTime) {
158
+ const errors = [];
159
+ const bizStep = resolve(mapping.bizStep, record, 'bizStep', errors) ?? '';
160
+ const disposition = resolve(mapping.disposition, record, 'disposition', errors);
161
+ const readPoint = resolve(mapping.readPoint, record, 'readPoint', errors);
162
+ const bizLocation = resolve(mapping.bizLocation, record, 'bizLocation', errors);
163
+ /* 정정 선언은 표준 **헤더**의 것이므로 세 종류 모두에 담긴다(§7.5). 한 종류에만 내면 같은 결함이
164
+ 나머지 둘에 남는다 — 그것을 이미 두 번 겪었다(`quantityList` · `ilmd`). */
165
+ const errorDeclaration = resolveErrorDeclaration(mapping.errorDeclaration, record, errors);
166
+ if (mapping.type === 'TransformationEvent') {
167
+ const inputEPCList = resolveList(mapping.inputEPCList, record, 'inputEPCList', errors);
168
+ const inputQuantityList = resolveQuantityList(mapping.inputQuantityList, record, 'inputQuantityList', errors);
169
+ const outputEPCList = resolveList(mapping.outputEPCList, record, 'outputEPCList', errors);
170
+ const outputQuantityList = resolveQuantityList(mapping.outputQuantityList, record, 'outputQuantityList', errors);
171
+ const transformationID = resolve(mapping.transformationID, record, 'transformationID', errors);
172
+ /*
173
+ * **양쪽이 다 있어야 한다** — EPCIS 2.0 §7.4.5 의 정의다: 「objects … are fully or partially
174
+ * consumed as inputs **and** one or more objects … are produced as outputs」. 네 목록 필드가
175
+ * 각각 선택인 것은 개체로 말하든 수량으로 말하든 되기 때문이고, 한쪽을 빼도 된다는 뜻이 아니다.
176
+ *
177
+ * 검증기도 같은 것을 본다. 여기서 함께 말하는 이유는 **가리키는 곳이 다르기** 때문이다 — 검증
178
+ * 메시지는 이벤트를 가리키고 이 메시지는 매핑을 가리킨다. 커넥터가 고칠 자리가 다르다.
179
+ */
180
+ if (!inputEPCList.length && !inputQuantityList.length) {
181
+ errors.push('입력이 비었다 — 변환은 무엇이 들어갔는지 말해야 한다(EPCIS 2.0 §7.4.5)');
182
+ }
183
+ if (!outputEPCList.length && !outputQuantityList.length) {
184
+ errors.push('출력이 비었다 — 변환은 무엇이 나왔는지 말해야 한다(EPCIS 2.0 §7.4.5)');
185
+ }
186
+ return {
187
+ event: transformationEvent({
188
+ eventTime, bizStep, disposition,
189
+ /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「없다」고 말하는 것이 된다.
190
+ 개체로 말한 쪽과 수량으로 말한 쪽 중 하나만 쓰는 것이 정상이다. */
191
+ ...(inputEPCList.length ? { inputEPCList } : {}),
192
+ ...(inputQuantityList.length ? { inputQuantityList } : {}),
193
+ ...(outputEPCList.length ? { outputEPCList } : {}),
194
+ ...(outputQuantityList.length ? { outputQuantityList } : {}),
195
+ ...(transformationID ? { transformationID } : {}),
196
+ ...(errorDeclaration ? { errorDeclaration } : {}),
197
+ readPoint, bizLocation
198
+ }),
199
+ errors
200
+ };
201
+ }
202
+ if (mapping.type === 'AggregationEvent') {
203
+ const action = (resolve(mapping.action, record, 'action', errors) ?? '');
204
+ const parentID = resolve(mapping.parentID, record, 'parentID', errors) ?? '';
205
+ const childEPCs = resolveList(mapping.childEPCs, record, 'childEPCs', errors);
206
+ const childQuantityList = resolveQuantityList(mapping.childQuantityList, record, 'childQuantityList', errors);
207
+ return {
208
+ event: aggregationEvent({
209
+ eventTime, action, bizStep, disposition, parentID,
210
+ /* 없는 쪽은 **필드를 만들지 않는다** — 빈 배열을 실으면 「자식이 없다」고 말하는 것이 된다. */
211
+ ...(childEPCs.length ? { childEPCs } : {}),
212
+ ...(childQuantityList.length ? { childQuantityList } : {}),
213
+ ...(errorDeclaration ? { errorDeclaration } : {}),
214
+ readPoint, bizLocation
215
+ }),
216
+ errors
217
+ };
218
+ }
219
+ const action = (resolve(mapping.action, record, 'action', errors) ?? '');
220
+ const epc = resolve(mapping.epc, record, 'epc', errors);
221
+ const quantityList = resolveQuantityList(mapping.quantityList, record, 'quantityList', errors);
222
+ /* 개체도 수량도 없으면 무엇을 관측했는지 말하지 않은 것이다 — 검증에 넘기기 전에 여기서 말해 준다
223
+ (검증 메시지는 이벤트를 가리키고, 이 메시지는 **매핑**을 가리킨다: 커넥터가 고칠 곳이 다르다). */
224
+ if (!epc && !quantityList.length) {
225
+ errors.push('epc 와 quantityList 가 둘 다 비었다 — 개체 식별자나 클래스+수량 중 하나는 있어야 한다');
226
+ }
227
+ const ilmd = resolveObject(mapping.ilmd, record, 'ilmd', errors);
228
+ return {
229
+ event: objectEvent({
230
+ eventTime, action, bizStep, disposition,
231
+ epcList: epc ? [epc] : [],
232
+ ...(quantityList.length ? { quantityList } : {}),
233
+ ...(ilmd ? { ilmd } : {}),
234
+ ...(errorDeclaration ? { errorDeclaration } : {}),
235
+ readPoint, bizLocation
236
+ }),
237
+ errors
238
+ };
239
+ }
240
+ /** 단일 레코드 → 정규 EPCIS 이벤트(매핑만). 매핑 문제까지 보려면 `mapRecordChecked`. */
241
+ export function mapRecord(record, mapping, eventTime) {
242
+ return mapRecordChecked(record, mapping, eventTime).event;
243
+ }
244
+ /**
245
+ * 레거시 레코드 배열 → 정규 EPCIS 봉투. 매핑 후 검증, 실패분은 rejected(오염 차단).
246
+ * 실 EPCIS-native 소스면 매핑은 거의 항등, 검증만 통과시키면 됨.
247
+ */
248
+ export function ingest(records, rules, opts) {
249
+ const ruleByType = new Map(rules.map(r => [r.sourceType, r]));
250
+ const accepted = [];
251
+ const rejected = [];
252
+ let seq = 0;
253
+ for (const record of records) {
254
+ const rule = ruleByType.get(String(record['sourceType']));
255
+ if (!rule) {
256
+ rejected.push({ record, errors: [`매칭 룰 없음: sourceType=${record['sourceType']}`] });
257
+ continue;
258
+ }
259
+ const timeErrors = [];
260
+ const eventTime = (opts.eventTimePath ? resolve('$.' + opts.eventTimePath, record, 'eventTime', timeErrors) : undefined) ?? opts.defaultEventTime;
261
+ const { event: ev, errors: mapErrors } = mapRecordChecked(record, rule.mapping, eventTime);
262
+ /* 매핑에서 드러난 문제를 검증 위반과 **같은 자리**에 담는다 — 부르는 쪽은 이유를 한 곳에서 읽는다. */
263
+ const errors = [...timeErrors, ...mapErrors, ...validateEpcisEvent(ev)];
264
+ if (errors.length) {
265
+ rejected.push({ record, errors });
266
+ continue;
267
+ }
268
+ /*
269
+ * 사람이 적어 둔 말은 **봉투에** 싣는다(§`CanonicalEnvelope.description`) — EPCIS 사건 안에 넣으면
270
+ * 그 사건이 표준을 벗어난다. 지난 기록에는 봉투가 그대로 남으므로 잃지 않는다.
271
+ * 비어 있으면 이 칸을 만들지 않는다 — 빈 문장은 「적지 않았다」와 다르게 보인다.
272
+ */
273
+ const note = typeof record['description'] === 'string' ? String(record['description']).trim() : '';
274
+ accepted.push({
275
+ eventId: `${opts.tenantId}-ingest-${++seq}`,
276
+ eventType: `epcis.${ev.type}`,
277
+ eventTime: ev.eventTime,
278
+ tenantId: opts.tenantId,
279
+ ...(note ? { description: note } : {}),
280
+ data: ev
281
+ });
282
+ }
283
+ return { accepted, rejected };
284
+ }
@@ -0,0 +1,18 @@
1
+ export * from './capability.ts';
2
+ export * from './capacity.ts';
3
+ export * from './contract.ts';
4
+ export * from './domain-catalog.ts';
5
+ export * from './domain-definition.ts';
6
+ export * from './ems-profile.ts';
7
+ export * from './energy-ingest.ts';
8
+ export * from './epcis.ts';
9
+ export * from './face2-adapter.ts';
10
+ export * from './iso-duration.ts';
11
+ export * from './master-data.ts';
12
+ export * from './mes-profile.ts';
13
+ export * from './operational-ingest.ts';
14
+ export * from './operations-capability.ts';
15
+ export * from './scenario-validate.ts';
16
+ export * from './vocabulary.ts';
17
+ export * from './wms-profile.ts';
18
+ export * from './yms-profile.ts';
package/dist/index.js ADDED
@@ -0,0 +1,39 @@
1
+ /*
2
+ * 운영 도메인의 **계약** — 사실을 만드는 쪽과 읽는 쪽이 합의해야 하는 것.
3
+ *
4
+ * ── 왜 커널에서 갈라졌나 (2026-08-30) ─────────────────────────────────────
5
+ * 사실을 **만드는 쪽**(MES · 커넥터)은 계약만 필요하고 엔진은 필요 없다. 한 패키지에 두면 레코드
6
+ * 하나를 만들려고 시뮬레이션 엔진과 네 도메인 커널을 의존 그래프에 올리게 된다. 의존 방향이 틀린 것은
7
+ * 시간이 지나도 저절로 고쳐지지 않는다.
8
+ *
9
+ * 타입만이면 `import type` 으로 피할 수 있다. 그런데 **판별 함수는 런타임 함수다**(`isEnergyRecord`
10
+ * 계열) — 보내는 쪽이 보내기 전에 스스로 검증하려면 실제로 필요하다. 그래서 우회가 성립하지 않는다.
11
+ *
12
+ * ── 무엇이 여기 있나 ──────────────────────────────────────────────────────
13
+ * 타입 · 판별 함수 · 정합 검증 · 어휘 상수. **인자만 보고 답한다.**
14
+ *
15
+ * 정책과 엔진은 여기 없다 — 그 사실로 무엇을 할지는 `@operato/twin-kernel` 이 정한다.
16
+ * 「상태가 없으면 계약」이 아니다. `firstFitPolicy` 는 상태가 없지만 **실행 정책**이라 커널에 있다.
17
+ *
18
+ * ── 이름에 `twin` 이 없는 이유 ────────────────────────────────────────────
19
+ * 이 어휘는 트윈 것이 아니다. EPCIS 2.0 · ISA-95 · IEC 61850 이고, 트윈이 없어도 성립한다.
20
+ * 실제로 MES 가 트윈이 아닌데 이것을 쓴다 — 그것이 갈라 둔 이유 자체다.
21
+ */
22
+ export * from "./capability.js";
23
+ export * from "./capacity.js";
24
+ export * from "./contract.js";
25
+ export * from "./domain-catalog.js";
26
+ export * from "./domain-definition.js";
27
+ export * from "./ems-profile.js";
28
+ export * from "./energy-ingest.js";
29
+ export * from "./epcis.js";
30
+ export * from "./face2-adapter.js";
31
+ export * from "./iso-duration.js";
32
+ export * from "./master-data.js";
33
+ export * from "./mes-profile.js";
34
+ export * from "./operational-ingest.js";
35
+ export * from "./operations-capability.js";
36
+ export * from "./scenario-validate.js";
37
+ export * from "./vocabulary.js";
38
+ export * from "./wms-profile.js";
39
+ export * from "./yms-profile.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ISO 8601 기간 → 밀리초. 해석할 수 없으면 **undefined**(0 이 아니다 — "0초" 와 "모른다" 는 다르다).
3
+ * 연·월(`Y`·`P…M` 의 날짜부 M)은 달력 의존이라 거부한다.
4
+ */
5
+ export declare function parseIsoDuration(text?: string): number | undefined;
@@ -0,0 +1,43 @@
1
+ /*
2
+ * ISO 8601 기간 파서 — **표준 표기를 그대로 받기 위한 최소 도구.**
3
+ *
4
+ * ISA-95 의 `OperationsSegment.Duration` 은 `xsd:duration` 이다(B2MML `DurationType` =
5
+ * `<xsd:restriction base="xsd:duration"/>`). 그래서 명세가 말하는 소요시간은 `PT12M`·`PT1H15M`
6
+ * 같은 문자열로 온다 — 우리가 숫자 밀리초로 바꿔 부르는 순간 표준과 어긋나므로, **계약은 표준 표기로
7
+ * 받고 여기서 한 번만 해석한다**(소비처가 문자열을 자르지 않게).
8
+ *
9
+ * 지원 범위: `PnYnMnWnDTnHnMnS` 의 주·일·시·분·초(소수 초 포함). **연·월은 거부한다** —
10
+ * 길이가 달력에 따라 달라져 밀리초로 확정할 수 없다(28~31일). 모르면 꾸미지 않고 undefined 를 낸다.
11
+ */
12
+ const RE = /^(-)?P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
13
+ /**
14
+ * ISO 8601 기간 → 밀리초. 해석할 수 없으면 **undefined**(0 이 아니다 — "0초" 와 "모른다" 는 다르다).
15
+ * 연·월(`Y`·`P…M` 의 날짜부 M)은 달력 의존이라 거부한다.
16
+ */
17
+ export function parseIsoDuration(text) {
18
+ if (typeof text !== 'string')
19
+ return undefined;
20
+ const s = text.trim();
21
+ if (!s || s === 'P' || s === 'PT')
22
+ return undefined;
23
+ if (/\d+Y/.test(s))
24
+ return undefined; // 연 — 달력 의존
25
+ /* 날짜부의 M(월)과 시간부의 M(분)을 구별한다: T 앞의 M 은 월이므로 거부. */
26
+ const tIdx = s.indexOf('T');
27
+ const datePart = tIdx === -1 ? s : s.slice(0, tIdx);
28
+ if (/\d+M/.test(datePart))
29
+ return undefined; // 월 — 달력 의존
30
+ const m = RE.exec(s);
31
+ if (!m)
32
+ return undefined;
33
+ const [, sign, w, d, h, min, sec] = m;
34
+ if (!w && !d && !h && !min && !sec)
35
+ return undefined;
36
+ const ms = (Number(w ?? 0) * 7 + Number(d ?? 0)) * 86_400_000 +
37
+ Number(h ?? 0) * 3_600_000 +
38
+ Number(min ?? 0) * 60_000 +
39
+ Number(sec ?? 0) * 1000;
40
+ if (!Number.isFinite(ms))
41
+ return undefined;
42
+ return sign ? -ms : ms;
43
+ }
@@ -0,0 +1,46 @@
1
+ /** 표준 어휘 타입 — 이 세 가지가 우리 상태 축에 닿는다. 그 밖은 보관하고 알린다. */
2
+ export declare const VOCABULARY_TYPE: {
3
+ /** 품목·로트 클래스의 속성 — 유통기한·로트번호가 여기 온다. */
4
+ readonly epcClass: "urn:epcglobal:epcis:vtype:EPCClass";
5
+ /** 관측 지점의 속성. */
6
+ readonly readPoint: "urn:epcglobal:epcis:vtype:ReadPoint";
7
+ /** 업무 자리의 속성. */
8
+ readonly bizLocation: "urn:epcglobal:epcis:vtype:BizLocation";
9
+ };
10
+ export type VocabularyType = (typeof VOCABULARY_TYPE)[keyof typeof VOCABULARY_TYPE];
11
+ /** 어휘 요소 하나 — 표준의 모양. */
12
+ export interface VocabularyElement {
13
+ /** 그 어휘에서의 식별자(클래스 식별자·자리 식별자). */
14
+ id: string;
15
+ /** 속성 — 이름을 해석하지 않고 그대로 든다. */
16
+ attributes: Record<string, unknown>;
17
+ }
18
+ /**
19
+ * 원본이 보내는 마스터데이터 한 건 — 어휘 타입과 함께 온다.
20
+ *
21
+ * 타입을 **받는다.** 로트 전용 문을 만들지 않는 이유는 자리·품목이 같은 통로로 들어와야 하고, 표준이
22
+ * 이미 타입으로 갈라 두었기 때문이다.
23
+ */
24
+ export interface MasterDataRecord extends VocabularyElement {
25
+ vocabularyType: string;
26
+ }
27
+ /** 이 레코드가 마스터데이터인가 — 갈래 판정은 **커널이 한 곳에서** 한다(어휘 넷을 가르는 방식과 같다). */
28
+ export declare function isMasterDataRecord(record: unknown): record is MasterDataRecord;
29
+ export interface MasterDataResult {
30
+ accepted: MasterDataRecord[];
31
+ rejected: {
32
+ record: unknown;
33
+ errors: string[];
34
+ }[];
35
+ }
36
+ /**
37
+ * 마스터데이터를 받아들인다 — **거부한 것은 이유와 함께 남는다**(다른 어휘와 같은 규율).
38
+ *
39
+ * 빈 속성은 거부한다: 「이 로트에 대해 아무것도 모른다」를 보내는 것은 아무 뜻이 없고, 받아 두면
40
+ * 기존 값을 지우려는 것인지 아무 말도 아닌지 구별할 수 없다.
41
+ */
42
+ export declare function ingestMasterData(records: MasterDataRecord | MasterDataRecord[] | undefined | null): MasterDataResult;
43
+ /** 마스터 속성에서 유통기한을 읽는다 — 사건의 `ilmd` 와 **같은 이름**을 본다. */
44
+ export declare function expiryFromAttributes(attrs: Record<string, unknown> | undefined): number | undefined;
45
+ /** 마스터 속성에서 로트 번호를 읽는다 — 사건의 `ilmd` 와 같은 이름. */
46
+ export declare function lotFromAttributes(attrs: Record<string, unknown> | undefined): string | undefined;