@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.
- package/README.md +23 -0
- package/dist/capability.d.ts +142 -0
- package/dist/capability.js +127 -0
- package/dist/capacity.d.ts +99 -0
- package/dist/capacity.js +172 -0
- package/dist/contract.d.ts +3557 -0
- package/dist/contract.js +1248 -0
- package/dist/domain-catalog.d.ts +280 -0
- package/dist/domain-catalog.js +322 -0
- package/dist/domain-definition.d.ts +356 -0
- package/dist/domain-definition.js +137 -0
- package/dist/ems-profile.d.ts +147 -0
- package/dist/ems-profile.js +367 -0
- package/dist/energy-ingest.d.ts +214 -0
- package/dist/energy-ingest.js +801 -0
- package/dist/epcis.d.ts +458 -0
- package/dist/epcis.js +640 -0
- package/dist/face2-adapter.d.ts +191 -0
- package/dist/face2-adapter.js +284 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +39 -0
- package/dist/iso-duration.d.ts +5 -0
- package/dist/iso-duration.js +43 -0
- package/dist/master-data.d.ts +46 -0
- package/dist/master-data.js +100 -0
- package/dist/mes-profile.d.ts +14 -0
- package/dist/mes-profile.js +59 -0
- package/dist/operational-ingest.d.ts +44 -0
- package/dist/operational-ingest.js +379 -0
- package/dist/operations-capability.d.ts +117 -0
- package/dist/operations-capability.js +120 -0
- package/dist/scenario-validate.d.ts +15 -0
- package/dist/scenario-validate.js +72 -0
- package/dist/vocabulary.d.ts +28 -0
- package/dist/vocabulary.js +81 -0
- package/dist/wms-profile.d.ts +20 -0
- package/dist/wms-profile.js +58 -0
- package/dist/yms-profile.d.ts +15 -0
- package/dist/yms-profile.js +39 -0
- package/dist-cjs/index.cjs +3767 -0
- package/package.json +30 -0
package/dist/epcis.js
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* EPCIS — GS1 EPCIS 2.0 이벤트 machinery (도메인-중립). WMS/YMS/MES 모두 이 표준 위에서 방출한다.
|
|
3
|
+
* 타입 + 빌더(Object/Aggregation/Transaction/Transformation) + 무의존 구조 검증기 + GS1 URI + CBV disposition.
|
|
4
|
+
* 도메인별 어휘(bizStep/btt)는 각 프로파일(wms/yms/mes-profile)에 둔다.
|
|
5
|
+
*
|
|
6
|
+
* EPCIS 2.0 JSON 바인딩: 각 이벤트는 @context/type/eventTime/eventTimeZoneOffset 를 포함하는
|
|
7
|
+
* 자기기술적(self-describing) JSON-LD 오브젝트.
|
|
8
|
+
*/
|
|
9
|
+
/** EPCIS 2.0 JSON-LD 컨텍스트 — 이벤트를 자기기술적 EPCIS 오브젝트로 만든다. */
|
|
10
|
+
export const EPCIS_CONTEXT = 'https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld';
|
|
11
|
+
/** 커널 시계는 UTC(BASE_EPOCH) 기준 → 고정 오프셋. */
|
|
12
|
+
export const UTC_OFFSET = '+00:00';
|
|
13
|
+
// disposition — CBV URN (GS1 표준 상태 어휘, 전 도메인 공용).
|
|
14
|
+
export const DISP = {
|
|
15
|
+
in_progress: 'urn:epcglobal:cbv:disp:in_progress',
|
|
16
|
+
sellable: 'urn:epcglobal:cbv:disp:sellable_accessible',
|
|
17
|
+
reserved: 'urn:epcglobal:cbv:disp:reserved',
|
|
18
|
+
in_transit: 'urn:epcglobal:cbv:disp:in_transit',
|
|
19
|
+
non_sellable: 'urn:epcglobal:cbv:disp:non_sellable_other', // 불량/scrap
|
|
20
|
+
/**
|
|
21
|
+
* **기한이 지났다** — CBV `expired`.
|
|
22
|
+
*
|
|
23
|
+
* ── 왜 `non_sellable` 로 계산하지 않나 (2026-08-24) ─────────────────────────────
|
|
24
|
+
* 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
|
|
25
|
+
* 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
|
|
26
|
+
* 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
|
|
27
|
+
*
|
|
28
|
+
* 그리고 표준에 **정확한 낱말이 있다** — 합치는 것은 있는 낱말을 버리는 것이다.
|
|
29
|
+
*
|
|
30
|
+
* ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
|
|
31
|
+
* `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
|
|
32
|
+
* 「기한 지남」을 상태로 선언하는 시스템이 있다 — 그때 이 값은 관측이다.
|
|
33
|
+
*
|
|
34
|
+
* 둘이 어긋나면(날짜는 남았는데 상태가 지남, 또는 그 반대) **어느 쪽이 맞다고 정하지 않는다** —
|
|
35
|
+
* 아직 그 판정을 세울 근거가 없다. 어긋남의 구분을 없애지 않는 것이 지금의 규율이다.
|
|
36
|
+
*
|
|
37
|
+
* ── 원문으로 확인했다 (2026-08-24) ────────────────────────────────────────
|
|
38
|
+
* 1차 출처: **CBV Standard Release 2.0, Ratified Jun 2022** §7.2.3 처분 값 표(38개). 이 객체의
|
|
39
|
+
* 다른 값들(`in_progress`·`sellable_accessible`·`reserved`·`in_transit`·`non_sellable_other`)도
|
|
40
|
+
* 그 표에 있다.
|
|
41
|
+
*
|
|
42
|
+
* **`non_sellable_expired` 를 쓰지 않는 이유**: 그 값은 CBV 1.0 의 것이고 표준이 **폐기**했다 —
|
|
43
|
+
* 「deprecated in favour of new disposition values expired, damaged, disposed, … introduced in
|
|
44
|
+
* CBV 1.1」. 폐기된 값을 쓰면 새 소비처가 읽지 못한다.
|
|
45
|
+
*
|
|
46
|
+
* 참고: GS1 어휘 등록처(`ref.gs1.org/cbv/…`)로는 확인할 수 없었다 — **없는 값에도 같은 응답**을
|
|
47
|
+
* 준다(지어낸 값의 JSON-LD 가 실재 값과 바이트까지 같았다). 그 경로를 근거로 삼지 말 것.
|
|
48
|
+
*/
|
|
49
|
+
expired: 'urn:epcglobal:cbv:disp:expired',
|
|
50
|
+
/**
|
|
51
|
+
* **검사에 합격했다 / 불합격했다** — CBV `conformant` / `non_conformant`.
|
|
52
|
+
*
|
|
53
|
+
* 1차 출처(CBV 2.0 §7.2.3) 정의 그대로다.
|
|
54
|
+
*
|
|
55
|
+
* conformant Outcome of a successful/passed inspection in an inspecting or repairing step
|
|
56
|
+
* non_conformant Outcome of an unsuccessful/failed inspection in an inspecting or repairing step
|
|
57
|
+
*
|
|
58
|
+
* ── 왜 시험 결과 축을 자원에 더하지 않고 이것을 쓰나 (2026-08-24) ────────────
|
|
59
|
+
* 로트의 검사 판정을 담을 자리를 찾다가 `ItemState.testResults` 를 더하려 했다. 그런데 표준은 그
|
|
60
|
+
* 사실을 **이미 처분으로 말한다**: `bizStep: inspecting` 사건에 이 처분이 붙는다.
|
|
61
|
+
*
|
|
62
|
+
* 처분을 쓰면 두 가지가 공짜로 성립한다.
|
|
63
|
+
* ① **상태 ⊆ 이벤트** — 처분은 이미 사건에서 온다. 상태에만 있는 축을 만들지 않는다
|
|
64
|
+
* ② **운영에 곧 닿는다** — 「이 자재를 쓸 수 있나」가 처분으로 답해진다(판정을 따로 읽지 않는다)
|
|
65
|
+
*
|
|
66
|
+
* 시험의 **자세한 내용**(어느 명세로, 무엇을 재어)은 다른 물음이고, 표준은 그것을 `TestResult` 로
|
|
67
|
+
* 두며 결과가 대상을 가리킨다(`TestableObjectID`) — 대상이 결과를 들지 않는다. 그 축이 필요해지면
|
|
68
|
+
* 그때 열되, **판정 자체는 여기서 끝난다.**
|
|
69
|
+
*/
|
|
70
|
+
conformant: 'urn:epcglobal:cbv:disp:conformant',
|
|
71
|
+
non_conformant: 'urn:epcglobal:cbv:disp:non_conformant'
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* **자재 소비·산출의 CBV 단계** — 도메인 무관하게 코어가 쓴다.
|
|
75
|
+
*
|
|
76
|
+
* 업종별 단계(입고·피킹·출하…)는 각 프로파일이 갖지만, "자재가 들어갔다/나왔다" 는 셋 다 하는 일이라
|
|
77
|
+
* 코어에 있어야 한다. 프로파일 하나에 두면 다른 업종이 그것을 가져다 쓰면서 방언이 생긴다.
|
|
78
|
+
*/
|
|
79
|
+
export const CBV_BIZSTEP = {
|
|
80
|
+
/** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
|
|
81
|
+
consuming: 'urn:epcglobal:cbv:bizstep:consuming',
|
|
82
|
+
/** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
|
|
83
|
+
commissioning: 'urn:epcglobal:cbv:bizstep:commissioning',
|
|
84
|
+
/**
|
|
85
|
+
* **검사** — CBV `inspecting`. 1차 출처(CBV 2.0) 정의: 「Process of reviewing objects to address
|
|
86
|
+
* potential physical or documentation defects」이고, 「표본과 달리 검사된 대상은 그대로 남는다」고
|
|
87
|
+
* 이어진다(즉 검사는 물건을 소비하지 않는다).
|
|
88
|
+
*
|
|
89
|
+
* 이 단계에 `DISP.conformant`/`DISP.non_conformant` 가 붙어 판정이 처분으로 남는다 — 입고검수·
|
|
90
|
+
* 공정 중 검사가 그 모양이다.
|
|
91
|
+
*/
|
|
92
|
+
inspecting: 'urn:epcglobal:cbv:bizstep:inspecting',
|
|
93
|
+
/**
|
|
94
|
+
* **어느 단계로도 이름 붙지 않는 활동** — 원문: 「A business step not identified by any other」.
|
|
95
|
+
*
|
|
96
|
+
* 이것은 근사가 아니라 **표준이 준 낱말**이다. 그 구별이 중요하다: 재고 조정처럼 한 낱말이 두 일을
|
|
97
|
+
* 하는 원천(세어 보고 맞춘 것 · 사람이 정정한 것)을 `cycle_counting` 으로 옮기면 **일어나지 않은
|
|
98
|
+
* 계수를 기록**하게 된다. 그때 쓰는 것이 이 값이고, 무슨 일이었는지는 값으로 함께 나른다.
|
|
99
|
+
*
|
|
100
|
+
* 원문 확인: CBV Standard Release 2.0(Ratified Jun 2022) §7.1.
|
|
101
|
+
*/
|
|
102
|
+
other: 'urn:epcglobal:cbv:bizstep:other'
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* **봉투에 실리는 EPCIS 사건 종류의 이름** — 소비처가 손으로 적지 않게 (2026-08-28).
|
|
106
|
+
*
|
|
107
|
+
* 봉투의 `eventType` 은 `epcis.<클래스>` 다(§`face2-adapter`·§`flow-engine` 이 그 접두사를 붙인다).
|
|
108
|
+
* 그 문자열을 화면·조회가 손으로 적으면 곧 방언이 되고, 클래스가 늘 때 한쪽만 고쳐진다.
|
|
109
|
+
*
|
|
110
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
111
|
+
* 「물건이 움직인 사건만 보는 목록」이 지금 **제외 목록**으로 만들어져 있다 — 운영·에너지 종류 열아홉
|
|
112
|
+
* 개를 적어 빼는 방식이다. 그 방식은 두 가지로 약하다.
|
|
113
|
+
*
|
|
114
|
+
* ① **닫히지 않는다.** 커널이 세 번째 계열을 만들면 그 계열 전부가 오류 없이 「움직임」으로 들어온다.
|
|
115
|
+
* 실제로 그 일이 세 번 있었다(사람 상태·자산 상태·에너지).
|
|
116
|
+
* ② **색인을 못 쓴다.** 제외 + 시각순은 색인이 듣지 않아, 그 트윈의 종류 분포가 비용을 정한다
|
|
117
|
+
* (실측: 에너지가 0.008%인 트윈에서 같은 모양이 37초였다).
|
|
118
|
+
*
|
|
119
|
+
* 담을 것을 말하면 정의가 닫힌다 — **물건이 움직인 사건 = EPCIS 사건**이고 그 클래스는 표준이 정한다.
|
|
120
|
+
* 커널이 채널을 늘려도 이 집합은 늘지 않는다.
|
|
121
|
+
*
|
|
122
|
+
* 우리 커널이 다루는 클래스는 넷이다. 표준의 `AssociationEvent` 는 아직 만들지 않았고, 만들면 이 상수에
|
|
123
|
+
* 더한다 — 그 한 곳만 고치면 소비처가 함께 따라온다.
|
|
124
|
+
*/
|
|
125
|
+
export const EPCIS_EVENT = {
|
|
126
|
+
object: 'epcis.ObjectEvent',
|
|
127
|
+
aggregation: 'epcis.AggregationEvent',
|
|
128
|
+
transaction: 'epcis.TransactionEvent',
|
|
129
|
+
transformation: 'epcis.TransformationEvent'
|
|
130
|
+
};
|
|
131
|
+
/** 봉투의 `eventType` 이 EPCIS 사건인가 — 접두사 하나로 판정한다(클래스가 늘어도 그대로 산다). */
|
|
132
|
+
export function isEpcisEventType(eventType) {
|
|
133
|
+
return typeof eventType === 'string' && eventType.startsWith('epcis.');
|
|
134
|
+
}
|
|
135
|
+
// ── GS1 EPC URI 헬퍼 (표준) ────────────────────────────────────────────────
|
|
136
|
+
/** SSCC (물류단위: 팔레트/화물/트레일러) — 결정적 카운터 기반. */
|
|
137
|
+
export function ssccUri(companyPrefix, serial) {
|
|
138
|
+
return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, '0')}`;
|
|
139
|
+
}
|
|
140
|
+
/** 비직렬 수량용 SGTIN 패턴(epcClass) — 로트를 따지지 않는 품목 클래스. quantityList 에 사용. */
|
|
141
|
+
export function sgtinClass(companyPrefix, itemRef) {
|
|
142
|
+
return `urn:epc:idpat:sgtin:${companyPrefix}.${itemRef}.*`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 우리가 방출하는 개체·로트 마스터데이터의 속성 이름 — **한 곳에 모은다.**
|
|
146
|
+
*
|
|
147
|
+
* 표준은 이 이름들을 정의하지 않는다(§7.3.8: 상위 문서 소관). 그래서 값을 실으려면 이름을 정해야 하는데,
|
|
148
|
+
* 흩뿌리면 곧 방언이 된다. GS1 CBV 마스터데이터 이름공간(`urn:epcglobal:cbv:mda:`)의 정식 명칭을
|
|
149
|
+
* 확인하면 **이 상수만 바꾸면 되도록** 여기 모아 둔다.
|
|
150
|
+
*
|
|
151
|
+
* ⚠ 아래 이름은 **아직 정본 확인 전**이다(CBV 마스터데이터 문서 필요). 받는 경로는 이름과 무관하게
|
|
152
|
+
* 동작하므로(받은 것을 잃지 않고 통과) 이 상수는 우리가 **생산할 때**만 쓰인다.
|
|
153
|
+
*/
|
|
154
|
+
export const ILMD_ATTR = {
|
|
155
|
+
/** 유통기한·만료(로트 단위). */
|
|
156
|
+
expiry: 'cbvmda:itemExpirationDate',
|
|
157
|
+
/** 로트·배치 번호(직렬 개체에 로트를 붙일 때). */
|
|
158
|
+
lot: 'cbvmda:lotNumber'
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* 품번 + 로트 클래스(LGTIN) — 낱개 일련번호가 없고 **로트로 관리**하는 자재의 표준 식별자.
|
|
162
|
+
*
|
|
163
|
+
* 원자재·화학·식품이 이 경우다. 로트가 식별자의 한 마디로 들어가므로 "로트별 재고" 가 별도 필드 없이
|
|
164
|
+
* epcClass 별 집계가 된다. 개체가 아니라 클래스이므로 `epcList` 가 아니라 `quantityList` 에 쓴다.
|
|
165
|
+
*
|
|
166
|
+
* 문법(EPC Tag Data Standard 2.1.0 §6.4.1):
|
|
167
|
+
* urn:epc:class:lgtin:CompanyPrefix.ItemRefAndIndicator.Lot
|
|
168
|
+
* 예) urn:epc:class:lgtin:4012345.012345.998877
|
|
169
|
+
* 두 숫자 마디의 자릿수 합은 13(점 제외), Lot 은 GS3A3Component(URI 이스케이프 허용).
|
|
170
|
+
*/
|
|
171
|
+
export function lgtinClass(companyPrefix, itemRefAndIndicator, lot) {
|
|
172
|
+
return `urn:epc:class:lgtin:${companyPrefix}.${itemRefAndIndicator}.${encodeURIComponent(lot)}`;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* EPC/클래스 식별자 파서 — **표준 지식이라 커널이 소유한다.**
|
|
176
|
+
*
|
|
177
|
+
* 소비처가 `uri.split(':').pop()` 으로 자르면 규칙이 어긋난다(실제로 화면이 LGTIN 의 마지막 마디인
|
|
178
|
+
* 로트를 SKU 이름으로 표시할 위험이 있었다). 뜯는 일은 여기 한 곳에서 한다.
|
|
179
|
+
*/
|
|
180
|
+
export function parseEpc(uri) {
|
|
181
|
+
const raw = String(uri ?? '');
|
|
182
|
+
/*
|
|
183
|
+
* ── CBV 형식을 먼저 읽는다 (2026-08-21) ──────────────────────────────────
|
|
184
|
+
* GS1 프리픽스가 없는 현장은 선언된 이름공간 아래에서 식별자를 만든다(CBV 2.0 §8.2.3·§8.2.4 개체
|
|
185
|
+
* `obj` · §8.3.3·§8.3.4 클래스 `class`). 그 형식을 모르면 `scheme: 'unknown'` · `instance: false` 가
|
|
186
|
+
* 되고, 소비처가 **개체를 클래스로 읽는다** — 미러가 트레일러의 개체 URI 를 그 물품의 품번으로 적었다
|
|
187
|
+
* (적합성 대조가 「미러만 아는 것=[gtin]」으로 잡았다).
|
|
188
|
+
*
|
|
189
|
+
* 표지가 종류를 말한다. 그것이 이 형식의 설계이고(그래서 표지가 필수다) 여기서 그대로 읽는다.
|
|
190
|
+
*/
|
|
191
|
+
if (/(?:\/obj\/|:obj:)[^/:\s]+$/.test(raw))
|
|
192
|
+
return { scheme: 'unknown', instance: true, uri: raw };
|
|
193
|
+
if (/(?:\/class\/|:class:)[^/:\s]+$/.test(raw))
|
|
194
|
+
return { scheme: 'unknown', instance: false, uri: raw };
|
|
195
|
+
const cls = raw.match(/^urn:epc:class:lgtin:(.+)$/);
|
|
196
|
+
if (cls) {
|
|
197
|
+
const seg = cls[1].split('.');
|
|
198
|
+
/* LGTIN 은 숫자 두 마디 + 로트. 로트에 점이 들어갈 수 있으므로 앞 둘만 떼고 나머지를 로트로. */
|
|
199
|
+
const lot = seg.slice(2).join('.');
|
|
200
|
+
return {
|
|
201
|
+
scheme: 'lgtin',
|
|
202
|
+
instance: false,
|
|
203
|
+
gtinKey: seg.slice(0, 2).join('.'),
|
|
204
|
+
lot: lot ? decodeURIComponent(lot) : undefined,
|
|
205
|
+
uri: raw
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const pat = raw.match(/^urn:epc:idpat:sgtin:(.+)$/);
|
|
209
|
+
if (pat) {
|
|
210
|
+
const seg = pat[1].split('.');
|
|
211
|
+
return { scheme: 'idpat', instance: false, gtinKey: seg.slice(0, 2).join('.'), uri: raw };
|
|
212
|
+
}
|
|
213
|
+
const id = raw.match(/^urn:epc:id:([a-z]+):(.+)$/);
|
|
214
|
+
if (id) {
|
|
215
|
+
const scheme = id[1];
|
|
216
|
+
const seg = id[2].split('.');
|
|
217
|
+
const known = ['sgtin', 'sscc', 'gdti', 'grai', 'giai', 'sgln'].includes(scheme);
|
|
218
|
+
return {
|
|
219
|
+
scheme: known ? scheme : 'unknown',
|
|
220
|
+
instance: true,
|
|
221
|
+
...(scheme === 'sgtin' ? { gtinKey: seg.slice(0, 2).join('.'), serial: seg[2] } : { serial: seg.slice(1).join('.') }),
|
|
222
|
+
uri: raw
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { scheme: 'unknown', instance: false, uri: raw };
|
|
226
|
+
}
|
|
227
|
+
/** 거래문서 식별자 = GDTI (PO/SO/WO/어포인트먼트 등). */
|
|
228
|
+
export function gdtiUri(companyPrefix, docType, serial) {
|
|
229
|
+
return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* **선언된 이름공간 아래의 거래 문서 식별자** — GDTI 를 쓰지 않는 길.
|
|
233
|
+
*
|
|
234
|
+
* ── 왜 이 길이 필요한가 (2026-08-21) ───────────────────────────────────────
|
|
235
|
+
* GDTI 는 `회사 프리픽스 + 문서 타입 + 일련번호`이고 **문서 타입은 회사가 배정한다**(GS1 이 공표하는
|
|
236
|
+
* 목록이 아니다). 그런데 커널이 `'403'`(작업지시)·`'401'`·`'402'`·`'404'` 를 스스로 정하고 있었다.
|
|
237
|
+
*
|
|
238
|
+
* 표준은 거래 문서 식별자에 GDTI 만 요구하지 않는다. CBV 2.0 이 세 형태를 더 정한다:
|
|
239
|
+
* · §8.5.5 `http(s)://[Subdomain.]Domain/⁎⁎/bt/transID` — 그 도메인 소유자가 배정
|
|
240
|
+
* · §8.5.4 `urn:URNNamespace:⁎⁎:bt:transID` — URN 이름공간 소유자가 배정
|
|
241
|
+
* · §8.5.3 `urn:epcglobal:cbv:bt:gln:transID` — GLN 소유자가 배정
|
|
242
|
+
*
|
|
243
|
+
* `bt` 표지가 **필수**이고, `transID` 는 경로 성분 **하나**다(「only one URI path component SHALL
|
|
244
|
+
* follow the /bt/」). 그래서 회사가 **도메인만 있으면** 문서 타입을 정할 일이 없다 — 이것이 GDTI 보다
|
|
245
|
+
* 진입장벽이 낮은 길이다.
|
|
246
|
+
*
|
|
247
|
+
* 이름공간의 모양으로 URL 형태와 URN 형태를 가른다. 판정할 수 없는 모양이면 **답하지 않는다**(지어내지
|
|
248
|
+
* 않는다) — 호출부가 다른 길을 고르게 한다.
|
|
249
|
+
*/
|
|
250
|
+
/**
|
|
251
|
+
* **선언된 이름공간 아래의 개체 식별자** — SSCC·GRAI 를 쓰지 않는 길.
|
|
252
|
+
*
|
|
253
|
+
* 팔레트(SSCC)·트레일러(GRAI)는 개체 식별자이고, 그 조립에는 GS1 회사 프리픽스가 필요하다. 프리픽스가
|
|
254
|
+
* 없는 현장은 그 길로 갈 수 없다 — 그런데 커널이 프리픽스를 지어내면 저널에 남의 번호가 영구히 남는다.
|
|
255
|
+
*
|
|
256
|
+
* 표준이 다른 길을 정해 두었다.
|
|
257
|
+
* · CBV 2.0 §8.2.4 `http(s)://[Subdomain.]Domain/⁎⁎/obj/Objid` — 그 도메인 소유자가 배정
|
|
258
|
+
* · CBV 2.0 §8.2.3 `urn:URNNamespace:⁎⁎:obj:Objid` — URN 이름공간 소유자가 배정
|
|
259
|
+
*
|
|
260
|
+
* `obj` 표지가 필수다(클래스의 `class`·거래문서의 `bt` 와 같은 구조다). 다만 표준은 EPC URI 나 Digital
|
|
261
|
+
* Link 를 **권한다**(SHOULD) — 이 길은 프리픽스가 없을 때의 정합 경로다.
|
|
262
|
+
*/
|
|
263
|
+
export function objectUri(namespace, objId) {
|
|
264
|
+
return underNamespace(namespace, 'obj', objId);
|
|
265
|
+
}
|
|
266
|
+
export function bizTransactionUri(namespace, transId) {
|
|
267
|
+
return underNamespace(namespace, 'bt', transId);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 선언된 이름공간 아래에 표지를 붙여 식별자를 만든다 — 표지만 다르고 규칙은 같다.
|
|
271
|
+
*
|
|
272
|
+
* `obj`(개체 §8.2.3·§8.2.4) · `class`(클래스 §8.3.3·§8.3.4) · `bt`(거래문서 §8.5.4·§8.5.5) 가 같은
|
|
273
|
+
* 모양이다. 규칙을 세 곳에 적으면 한 곳만 고쳐지는 날이 온다.
|
|
274
|
+
*/
|
|
275
|
+
function underNamespace(namespace, marker, id) {
|
|
276
|
+
const ns = namespace?.trim();
|
|
277
|
+
if (!ns)
|
|
278
|
+
return undefined;
|
|
279
|
+
const v = String(id);
|
|
280
|
+
/* 표준이 요구하는 「성분 하나」가 깨진다 — 구분자가 든 값은 만들지 않는다. */
|
|
281
|
+
if (!v || v.includes('/') || v.includes(':'))
|
|
282
|
+
return undefined;
|
|
283
|
+
if (/^https?:\/\/[^/\s]+/.test(ns))
|
|
284
|
+
return `${ns.replace(/\/+$/, '')}/${marker}/${v}`;
|
|
285
|
+
/*
|
|
286
|
+
* **GS1 이 소유한 URN 공간에는 우리가 표지를 만들 수 없다.** `urn:epc:`·`urn:epcglobal:` 의 소유
|
|
287
|
+
* 권한자는 GS1 이고(EPCIS §6.4), 그 안의 형태는 표준이 정해 둔 것만 유효하다. 그 공간을 이름공간으로
|
|
288
|
+
* 선언한 현장은 GS1 키(SSCC·GRAI·GDTI)를 쓰는 쪽으로 가야 한다.
|
|
289
|
+
*/
|
|
290
|
+
if (/^urn:epc(global)?:/.test(ns))
|
|
291
|
+
return undefined;
|
|
292
|
+
if (/^urn:[^:\s]+/.test(ns))
|
|
293
|
+
return `${ns.replace(/:+$/, '')}:${marker}:${v}`;
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
function header(type, eventTime, bizStep, opts) {
|
|
297
|
+
const h = {
|
|
298
|
+
'@context': EPCIS_CONTEXT,
|
|
299
|
+
type,
|
|
300
|
+
eventTime,
|
|
301
|
+
eventTimeZoneOffset: UTC_OFFSET,
|
|
302
|
+
bizStep
|
|
303
|
+
};
|
|
304
|
+
/* 값이 없으면 키를 만들지 않는다 — 표준 이벤트에 빈 필드를 싣지 않기 위해. */
|
|
305
|
+
if (opts?.eventID)
|
|
306
|
+
h.eventID = opts.eventID;
|
|
307
|
+
if (opts?.recordTime)
|
|
308
|
+
h.recordTime = opts.recordTime;
|
|
309
|
+
if (opts?.errorDeclaration)
|
|
310
|
+
h.errorDeclaration = opts.errorDeclaration;
|
|
311
|
+
if (opts?.ilmd)
|
|
312
|
+
h.ilmd = opts.ilmd;
|
|
313
|
+
if (opts?.sourceList)
|
|
314
|
+
h.sourceList = opts.sourceList;
|
|
315
|
+
if (opts?.destinationList)
|
|
316
|
+
h.destinationList = opts.destinationList;
|
|
317
|
+
if (opts?.persistentDisposition)
|
|
318
|
+
h.persistentDisposition = opts.persistentDisposition;
|
|
319
|
+
if (opts?.sensorElementList)
|
|
320
|
+
h.sensorElementList = opts.sensorElementList;
|
|
321
|
+
if (opts?.certificationInfo)
|
|
322
|
+
h.certificationInfo = opts.certificationInfo;
|
|
323
|
+
return h;
|
|
324
|
+
}
|
|
325
|
+
function common(type, eventTime, action, bizStep, opts) {
|
|
326
|
+
return { ...header(type, eventTime, bizStep, opts), action };
|
|
327
|
+
}
|
|
328
|
+
export function objectEvent(p) {
|
|
329
|
+
const e = { ...common('ObjectEvent', p.eventTime, p.action, p.bizStep, p), epcList: p.epcList };
|
|
330
|
+
if (p.disposition)
|
|
331
|
+
e.disposition = p.disposition;
|
|
332
|
+
if (p.quantityList)
|
|
333
|
+
e.quantityList = p.quantityList;
|
|
334
|
+
if (p.readPoint)
|
|
335
|
+
e.readPoint = { id: p.readPoint };
|
|
336
|
+
if (p.bizLocation)
|
|
337
|
+
e.bizLocation = { id: p.bizLocation };
|
|
338
|
+
if (p.bizTransactionList)
|
|
339
|
+
e.bizTransactionList = p.bizTransactionList;
|
|
340
|
+
return e;
|
|
341
|
+
}
|
|
342
|
+
export function aggregationEvent(p) {
|
|
343
|
+
const e = { ...common('AggregationEvent', p.eventTime, p.action, p.bizStep, p), parentID: p.parentID };
|
|
344
|
+
if (p.disposition)
|
|
345
|
+
e.disposition = p.disposition;
|
|
346
|
+
if (p.childEPCs)
|
|
347
|
+
e.childEPCs = p.childEPCs;
|
|
348
|
+
if (p.childQuantityList)
|
|
349
|
+
e.childQuantityList = p.childQuantityList;
|
|
350
|
+
if (p.readPoint)
|
|
351
|
+
e.readPoint = { id: p.readPoint };
|
|
352
|
+
if (p.bizLocation)
|
|
353
|
+
e.bizLocation = { id: p.bizLocation };
|
|
354
|
+
return e;
|
|
355
|
+
}
|
|
356
|
+
export function transactionEvent(p) {
|
|
357
|
+
const e = {
|
|
358
|
+
...common('TransactionEvent', p.eventTime, p.action, p.bizStep, p),
|
|
359
|
+
bizTransactionList: p.bizTransactionList
|
|
360
|
+
};
|
|
361
|
+
if (p.disposition)
|
|
362
|
+
e.disposition = p.disposition;
|
|
363
|
+
if (p.parentID)
|
|
364
|
+
e.parentID = p.parentID;
|
|
365
|
+
if (p.epcList)
|
|
366
|
+
e.epcList = p.epcList;
|
|
367
|
+
if (p.quantityList)
|
|
368
|
+
e.quantityList = p.quantityList;
|
|
369
|
+
if (p.readPoint)
|
|
370
|
+
e.readPoint = { id: p.readPoint };
|
|
371
|
+
if (p.bizLocation)
|
|
372
|
+
e.bizLocation = { id: p.bizLocation };
|
|
373
|
+
return e;
|
|
374
|
+
}
|
|
375
|
+
/** 변환(제조) — 입력 EPC/수량 소비 → 출력 EPC/수량 생산. action 없음. */
|
|
376
|
+
export function transformationEvent(p) {
|
|
377
|
+
const e = header('TransformationEvent', p.eventTime, p.bizStep, p);
|
|
378
|
+
if (p.disposition)
|
|
379
|
+
e.disposition = p.disposition;
|
|
380
|
+
if (p.inputEPCList)
|
|
381
|
+
e.inputEPCList = p.inputEPCList;
|
|
382
|
+
if (p.inputQuantityList)
|
|
383
|
+
e.inputQuantityList = p.inputQuantityList;
|
|
384
|
+
if (p.outputEPCList)
|
|
385
|
+
e.outputEPCList = p.outputEPCList;
|
|
386
|
+
if (p.outputQuantityList)
|
|
387
|
+
e.outputQuantityList = p.outputQuantityList;
|
|
388
|
+
if (p.transformationID)
|
|
389
|
+
e.transformationID = p.transformationID;
|
|
390
|
+
if (p.readPoint)
|
|
391
|
+
e.readPoint = { id: p.readPoint };
|
|
392
|
+
if (p.bizLocation)
|
|
393
|
+
e.bizLocation = { id: p.bizLocation };
|
|
394
|
+
if (p.bizTransactionList)
|
|
395
|
+
e.bizTransactionList = p.bizTransactionList;
|
|
396
|
+
return e;
|
|
397
|
+
}
|
|
398
|
+
// ── 구조 검증기 (무의존 — 커널 zero-dep 원칙) ──────────────────────────────
|
|
399
|
+
// EPCIS 2.0 이벤트의 필수 필드·형태를 검증. 위반 목록을 반환(빈 배열 = 유효).
|
|
400
|
+
// JSON Schema 파일 파생은 후속 — 이 함수가 제약의 SoT.
|
|
401
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
402
|
+
const TZ_RE = /^[+-]\d{2}:\d{2}$/;
|
|
403
|
+
const ACTIONS = ['ADD', 'OBSERVE', 'DELETE'];
|
|
404
|
+
/*
|
|
405
|
+
* ★ 클래스 식별자 판정 — **표준이 요구하는 것은 GS1 발급이 아니라 「소유 권한」이다.**
|
|
406
|
+
*
|
|
407
|
+
* ── 무엇이 틀렸나 (2026-08-20) ──────────────────────────────────────────────
|
|
408
|
+
* 예전에는 `urn:epc:idpat:` · `urn:epc:class:` **둘만** 통과시켰다. 그래서 GS1 회사 프리픽스가 없는
|
|
409
|
+
* 현장은 **통과할 길이 없었고**, 통과시키려면 프리픽스를 지어내야 했다. 커널의 `0614141`(GS1 이 예제로
|
|
410
|
+
* 쓰다 버린 값, 실 배정 대역)이 그 압력의 산물이다. **막는 쪽이 위조의 원인이었다.**
|
|
411
|
+
*
|
|
412
|
+
* ── 원문 (Release 2.0, Ratified Jun 2022) ──────────────────────────────────
|
|
413
|
+
* EPCIS 2.0 §6.4: "The types of URIs admissible as Vocabulary Elements are those URIs **for which
|
|
414
|
+
* there is an owning authority.**" — EPC URI · 절대 URL(도메인 소유자) · PEN 기반 `oid` URN ·
|
|
415
|
+
* `epc`/`epcglobal` URN · GS1 Digital Link.
|
|
416
|
+
* EPCIS 2.0 §6.2: `epcClass`(Object Class) 는 **User Vocabulary** 다.
|
|
417
|
+
*
|
|
418
|
+
* CBV 2.0 이 클래스 식별자의 **모양**을 규정한다:
|
|
419
|
+
* · §8.3.3 `urn:URNNamespace:**:class:ObjClassid` — URN 이름공간 소유자가 배정
|
|
420
|
+
* · §8.3.4 `http(s)://[Subdomain.]Domain/⁎⁎/class/ObjClassid` — 그 인터넷 도메인 소유자가 배정
|
|
421
|
+
* (`ObjClassid` 에 `/` 불가 — "only one URI path component SHALL follow the /class/")
|
|
422
|
+
*
|
|
423
|
+
* `class` 표지가 **필수**다. 그냥 절대 URL 이면 되는 것이 아니다 —
|
|
424
|
+
* `https://chef.example.com/product/<uuid>` 는 **부적합**이고 `.../class/<uuid>` 가 적합이다.
|
|
425
|
+
*
|
|
426
|
+
* ── 아직 판정하지 않는 것 (좁힘을 드러내 둔다) ──────────────────────────────
|
|
427
|
+
* **비정규형 GS1 Digital Link**(§8.3.2 의 `https://example.com/some/path/info/8003/…` 류)는 거절한다.
|
|
428
|
+
* 정규형(`https://id.gs1.org/…`)만 받는다. 판정에 GS1 Application Identifier 지식이 필요하고 지금
|
|
429
|
+
* 소비처가 없다. **표준보다 좁은 것을 알고 있다** — Digital Link 를 쓰는 원천이 붙는 날 이 절을 연다.
|
|
430
|
+
*/
|
|
431
|
+
const EPC_CLASS_PREFIXES = ['urn:epc:idpat:', 'urn:epc:class:'];
|
|
432
|
+
/** CBV §8.3.2 정규형 Digital Link. 비정규형은 위 주석대로 아직 판정하지 않는다. */
|
|
433
|
+
const DL_CANONICAL = 'https://id.gs1.org/';
|
|
434
|
+
/** CBV §8.3.4 — 도메인 소유자가 배정. `ObjClassid` 는 경로 성분 **하나**(RFC3986 segment-nz). */
|
|
435
|
+
const CBV_URL_CLASS = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*class\/[^/\s]+$/;
|
|
436
|
+
/** CBV §8.3.3 — URN 이름공간 소유자가 배정. `ObjClassid` 에 콜론 불가. */
|
|
437
|
+
const CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
|
|
438
|
+
/** 위반 문구, 또는 통과면 `undefined`. */
|
|
439
|
+
/**
|
|
440
|
+
* **GS1 키의 자리 수 규약** — TDS 가 정한 합계다(회사 프리픽스 + 참조 = 고정 자리).
|
|
441
|
+
*
|
|
442
|
+
* ── 왜 여기서 보는가 (2026-08-22) ──────────────────────────────────────────
|
|
443
|
+
* 검증기가 모양(`urn:epc:idpat:sgtin:`)만 보고 **자리 수를 보지 않았다.** 실측: 12자리·14자리 SGTIN 이
|
|
444
|
+
* 전부 통과했다. 프리픽스를 바꾸는 작업에서 품목참조 자리 수를 맞추지 않으면 **조용히 어긋난
|
|
445
|
+
* 식별자가 저널에 영구히 남고**, 어느 화면도 그것을 말해 주지 않는다.
|
|
446
|
+
*
|
|
447
|
+
* 합계만 본다 — 프리픽스가 몇 자리인지는 GS1 이 회사마다 다르게 배정하므로 우리가 알 수 없다.
|
|
448
|
+
* 합계는 키 종류가 정한다: SGTIN 13(GTIN-14 의 표시자+13자리) · SSCC 17 · GRAI 12 · GDTI 12.
|
|
449
|
+
*/
|
|
450
|
+
const GS1_KEY_DIGITS = { sgtin: 13, sscc: 17, grai: 12, gdti: 12 };
|
|
451
|
+
/**
|
|
452
|
+
* 그 GS1 키의 자리 수가 맞나 — 어긋나면 왜인지 말한다.
|
|
453
|
+
*
|
|
454
|
+
* `urn:epc:{id,idpat,class}:<키>:<프리픽스>.<참조>[.<직렬>]` 모양만 본다. 그 밖의 형식(도메인 기반
|
|
455
|
+
* 식별자 등)은 이 규약의 대상이 아니므로 아무 말도 하지 않는다.
|
|
456
|
+
*/
|
|
457
|
+
export function gs1KeyDigitViolation(uri) {
|
|
458
|
+
if (!uri)
|
|
459
|
+
return undefined;
|
|
460
|
+
const m = /^urn:epc:(?:id|idpat|class):([a-z]+):([^:]+)$/.exec(uri);
|
|
461
|
+
if (!m)
|
|
462
|
+
return undefined;
|
|
463
|
+
const want = GS1_KEY_DIGITS[m[1]];
|
|
464
|
+
if (!want)
|
|
465
|
+
return undefined;
|
|
466
|
+
const parts = m[2].split('.');
|
|
467
|
+
if (parts.length < 2)
|
|
468
|
+
return `${m[1]} 형식 오류: ${uri} — 회사 프리픽스와 참조가 '.' 로 갈려야 한다`;
|
|
469
|
+
const [prefix, ref] = parts;
|
|
470
|
+
/* 패턴의 `*` 는 자리 수를 말하지 않는다 — 참조 자리가 `*` 면 합계를 셀 수 없으므로 넘어간다. */
|
|
471
|
+
if (ref === '*')
|
|
472
|
+
return undefined;
|
|
473
|
+
if (!/^\d+$/.test(prefix) || !/^\d+$/.test(ref)) {
|
|
474
|
+
return `${m[1]} 형식 오류: ${uri} — 회사 프리픽스와 참조는 숫자다`;
|
|
475
|
+
}
|
|
476
|
+
const got = prefix.length + ref.length;
|
|
477
|
+
if (got === want)
|
|
478
|
+
return undefined;
|
|
479
|
+
return (`${m[1]} 자리 수 오류: ${uri} — 회사 프리픽스(${prefix.length}) + 참조(${ref.length}) = ${got} 이지만 ` +
|
|
480
|
+
`${want} 여야 한다(GS1 TDS). 프리픽스를 바꾸면 참조 자리 수를 함께 맞춰야 한다.`);
|
|
481
|
+
}
|
|
482
|
+
export function classIdentifierViolation(epcClass) {
|
|
483
|
+
if (!epcClass)
|
|
484
|
+
return `quantity epcClass 부정: ${epcClass}`;
|
|
485
|
+
const digits = gs1KeyDigitViolation(epcClass);
|
|
486
|
+
if (digits)
|
|
487
|
+
return `quantity epcClass 부정: ${digits}`;
|
|
488
|
+
if (EPC_CLASS_PREFIXES.some(p => epcClass.startsWith(p)))
|
|
489
|
+
return undefined;
|
|
490
|
+
if (epcClass.startsWith(DL_CANONICAL))
|
|
491
|
+
return undefined;
|
|
492
|
+
if (CBV_URL_CLASS.test(epcClass) || CBV_URN_CLASS.test(epcClass))
|
|
493
|
+
return undefined;
|
|
494
|
+
return (`quantity epcClass 부정: ${epcClass} — 클래스 식별자는 소유 권한을 말할 수 있는 모양이어야 한다 ` +
|
|
495
|
+
`(EPC 클래스 urn:epc:idpat:/urn:epc:class: · CBV §8.3.4 http(s)://<도메인>/**/class/<id> · ` +
|
|
496
|
+
`CBV §8.3.3 urn:<이름공간>:**:class:<id>)`);
|
|
497
|
+
}
|
|
498
|
+
export function validateEpcisEvent(e) {
|
|
499
|
+
const v = [];
|
|
500
|
+
/*
|
|
501
|
+
* ── 개체 식별자의 자리 수도 본다 (2026-08-22) ──────────────────────────────
|
|
502
|
+
* 클래스만 보고 개체를 보지 않으면, 같은 어긋남이 `epcList` 를 타고 그대로 저널에 들어간다. 판정은
|
|
503
|
+
* 한 규칙이므로 실리는 모든 자리에 같이 댄다(§`gs1KeyDigitViolation`).
|
|
504
|
+
*/
|
|
505
|
+
for (const [field, list] of [
|
|
506
|
+
['epcList', e.epcList],
|
|
507
|
+
['childEPCs', e.childEPCs],
|
|
508
|
+
['inputEPCList', e.inputEPCList],
|
|
509
|
+
['outputEPCList', e.outputEPCList],
|
|
510
|
+
['parentID', e.parentID ? [e.parentID] : undefined]
|
|
511
|
+
]) {
|
|
512
|
+
for (const epc of list ?? []) {
|
|
513
|
+
const bad = gs1KeyDigitViolation(epc);
|
|
514
|
+
if (bad)
|
|
515
|
+
v.push(`${field}: ${bad}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (e['@context'] !== EPCIS_CONTEXT)
|
|
519
|
+
v.push('@context 누락/불일치');
|
|
520
|
+
if (!['ObjectEvent', 'AggregationEvent', 'TransactionEvent', 'TransformationEvent'].includes(e.type))
|
|
521
|
+
v.push(`알 수 없는 type: ${e.type}`);
|
|
522
|
+
if (typeof e.eventTime !== 'string' || !ISO_RE.test(e.eventTime))
|
|
523
|
+
v.push('eventTime ISO8601 아님');
|
|
524
|
+
if (typeof e.eventTimeZoneOffset !== 'string' || !TZ_RE.test(e.eventTimeZoneOffset))
|
|
525
|
+
v.push('eventTimeZoneOffset 형식 오류');
|
|
526
|
+
if (typeof e.bizStep !== 'string' || !e.bizStep)
|
|
527
|
+
v.push('bizStep 누락');
|
|
528
|
+
/* 선택 헤더 — 있으면 형식을 지킨다(없는 것은 위반이 아니다). */
|
|
529
|
+
if (e.eventID !== undefined && (typeof e.eventID !== 'string' || !e.eventID))
|
|
530
|
+
v.push('eventID 가 빈 문자열');
|
|
531
|
+
if (e.recordTime !== undefined && (typeof e.recordTime !== 'string' || !ISO_RE.test(e.recordTime))) {
|
|
532
|
+
v.push('recordTime ISO8601 아님');
|
|
533
|
+
}
|
|
534
|
+
/* 개체·로트 마스터데이터는 **생겨나는 순간에만** 실린다(§7.3.8) — ObjectEvent(ADD) · Transformation.
|
|
535
|
+
* 아무 이벤트에나 허용하면 "생애 동안 정적" 이라는 성질이 깨지고 이벤트마다 값이 어긋난다. */
|
|
536
|
+
if (e.ilmd !== undefined) {
|
|
537
|
+
const allowed = (e.type === 'ObjectEvent' && e.action === 'ADD') || e.type === 'TransformationEvent';
|
|
538
|
+
if (!allowed)
|
|
539
|
+
v.push('ilmd 는 ObjectEvent(action=ADD) 또는 TransformationEvent 에만 실을 수 있다');
|
|
540
|
+
}
|
|
541
|
+
/* 이전 당사자 — 종류와 당사자가 둘 다 있어야 뜻이 된다(한쪽만 있으면 누가/무엇이 빈다). */
|
|
542
|
+
for (const sd of e.sourceList ?? []) {
|
|
543
|
+
if (!sd?.type || !sd?.source)
|
|
544
|
+
v.push('sourceList 항목에 type 또는 source 누락');
|
|
545
|
+
}
|
|
546
|
+
for (const sd of e.destinationList ?? []) {
|
|
547
|
+
if (!sd?.type || !sd?.destination)
|
|
548
|
+
v.push('destinationList 항목에 type 또는 destination 누락');
|
|
549
|
+
}
|
|
550
|
+
/* 지속 상태 — 같은 조건을 동시에 설정하고 해제하면 뜻이 모순된다. */
|
|
551
|
+
if (e.persistentDisposition !== undefined) {
|
|
552
|
+
const set = e.persistentDisposition.set ?? [];
|
|
553
|
+
const unset = e.persistentDisposition.unset ?? [];
|
|
554
|
+
if (!set.length && !unset.length)
|
|
555
|
+
v.push('persistentDisposition 이 set·unset 둘 다 비어있음');
|
|
556
|
+
const both = set.filter(x => unset.includes(x));
|
|
557
|
+
if (both.length)
|
|
558
|
+
v.push(`persistentDisposition 이 같은 값을 set·unset 동시 지정: ${both.join(', ')}`);
|
|
559
|
+
}
|
|
560
|
+
/* 센서 — 관측 보고가 하나 이상 있어야 한다(표준: SHALL contain one or more sensorReport). */
|
|
561
|
+
for (const se of e.sensorElementList ?? []) {
|
|
562
|
+
if (!Array.isArray(se?.sensorReport) || se.sensorReport.length === 0) {
|
|
563
|
+
v.push('sensorElement 에 sensorReport 가 하나도 없음');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (e.errorDeclaration !== undefined) {
|
|
567
|
+
const d = e.errorDeclaration;
|
|
568
|
+
/* 정정인데 언제 정정했는지 모르면 정정 순서를 판정할 수 없다 → 표준 필수. */
|
|
569
|
+
if (typeof d?.declarationTime !== 'string' || !ISO_RE.test(d.declarationTime)) {
|
|
570
|
+
v.push('errorDeclaration.declarationTime ISO8601 아님/누락');
|
|
571
|
+
}
|
|
572
|
+
if (d?.correctiveEventIDs !== undefined) {
|
|
573
|
+
if (!Array.isArray(d.correctiveEventIDs))
|
|
574
|
+
v.push('errorDeclaration.correctiveEventIDs 배열 아님');
|
|
575
|
+
else if (d.correctiveEventIDs.some(x => typeof x !== 'string' || !x)) {
|
|
576
|
+
v.push('errorDeclaration.correctiveEventIDs 에 빈 값');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (e.type === 'ObjectEvent') {
|
|
581
|
+
if (!ACTIONS.includes(e.action))
|
|
582
|
+
v.push(`action 부정: ${e.action}`);
|
|
583
|
+
if (!Array.isArray(e.epcList))
|
|
584
|
+
v.push('ObjectEvent.epcList 누락');
|
|
585
|
+
else if (e.epcList.length === 0 && !e.quantityList?.length)
|
|
586
|
+
v.push('ObjectEvent: epcList/quantityList 둘 다 비어있음');
|
|
587
|
+
}
|
|
588
|
+
else if (e.type === 'AggregationEvent') {
|
|
589
|
+
if (!ACTIONS.includes(e.action))
|
|
590
|
+
v.push(`action 부정: ${e.action}`);
|
|
591
|
+
if (!e.parentID)
|
|
592
|
+
v.push('AggregationEvent.parentID 누락');
|
|
593
|
+
if (!e.childEPCs?.length && !e.childQuantityList?.length)
|
|
594
|
+
v.push('AggregationEvent: childEPCs/childQuantityList 둘 다 비어있음');
|
|
595
|
+
}
|
|
596
|
+
else if (e.type === 'TransactionEvent') {
|
|
597
|
+
if (!ACTIONS.includes(e.action))
|
|
598
|
+
v.push(`action 부정: ${e.action}`);
|
|
599
|
+
if (!Array.isArray(e.bizTransactionList) || e.bizTransactionList.length === 0)
|
|
600
|
+
v.push('TransactionEvent.bizTransactionList 누락');
|
|
601
|
+
}
|
|
602
|
+
else if (e.type === 'TransformationEvent') {
|
|
603
|
+
if (!e.inputEPCList?.length && !e.inputQuantityList?.length)
|
|
604
|
+
v.push('TransformationEvent: input 비어있음');
|
|
605
|
+
if (!e.outputEPCList?.length && !e.outputQuantityList?.length)
|
|
606
|
+
v.push('TransformationEvent: output 비어있음');
|
|
607
|
+
}
|
|
608
|
+
// 참조 무결성(경량): 모든 수량 리스트의 epcClass 는 idpat/class
|
|
609
|
+
const qtyLists = [
|
|
610
|
+
'quantityList' in e ? e.quantityList : undefined,
|
|
611
|
+
'childQuantityList' in e ? e.childQuantityList : undefined,
|
|
612
|
+
'inputQuantityList' in e ? e.inputQuantityList : undefined,
|
|
613
|
+
'outputQuantityList' in e ? e.outputQuantityList : undefined
|
|
614
|
+
];
|
|
615
|
+
for (const list of qtyLists)
|
|
616
|
+
for (const q of list ?? []) {
|
|
617
|
+
/* 클래스 식별자만 — 개체(urn:epc:id:)는 epcList 의 몫이다. */
|
|
618
|
+
const bad = classIdentifierViolation(q.epcClass);
|
|
619
|
+
if (bad)
|
|
620
|
+
v.push(bad);
|
|
621
|
+
/* 세 경우(EPCIS 2.0 §7.3.3.1) — 느슨하게 통과시키면 "모름" 과 "0" 이 섞인다. */
|
|
622
|
+
const hasQty = q.quantity !== undefined && q.quantity !== null;
|
|
623
|
+
if (!hasQty) {
|
|
624
|
+
/* 수량 미지정 = "모른다". 그때 단위만 남아 있으면 뜻이 없다 → 표준은 생략을 요구한다. */
|
|
625
|
+
if (q.uom !== undefined)
|
|
626
|
+
v.push('quantity 없으면 uom 도 없어야 한다(수량 미지정)');
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (typeof q.quantity !== 'number' || !Number.isFinite(q.quantity) || q.quantity <= 0) {
|
|
630
|
+
v.push('quantity 는 양수여야 한다(모르면 생략)');
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
/* uom 없으면 개수 → 정수여야 한다. uom 있으면 물리량 → 소수 허용. */
|
|
634
|
+
if (q.uom === undefined && !Number.isInteger(q.quantity))
|
|
635
|
+
v.push('uom 없는 quantity 는 정수(개수)여야 한다');
|
|
636
|
+
if (q.uom !== undefined && !/^[A-Z0-9]{2,3}$/.test(q.uom))
|
|
637
|
+
v.push(`uom 형식 부정(UN/CEFACT 권고 20 의 2~3자 코드): ${q.uom}`);
|
|
638
|
+
}
|
|
639
|
+
return v;
|
|
640
|
+
}
|