@operato/twin-kernel 0.7.39 → 0.7.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.d.ts +176 -10
- package/dist/contract.js +46 -5
- package/dist/domain-catalog.d.ts +1 -1
- package/dist/domain-catalog.js +1 -1
- package/dist/domain-definition.d.ts +17 -0
- package/dist/domain-definition.js +7 -0
- package/dist/ems-kernel.js +13 -1
- package/dist/epcis.d.ts +22 -0
- package/dist/epcis.js +91 -2
- package/dist/face2-adapter.d.ts +21 -1
- package/dist/face2-adapter.js +12 -1
- package/dist/flow-engine.d.ts +156 -8
- package/dist/flow-engine.js +324 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/kernel.d.ts +15 -0
- package/dist/kernel.js +50 -16
- package/dist/mes-kernel.d.ts +117 -27
- package/dist/mes-kernel.js +354 -170
- package/dist/observed-reducer.d.ts +1 -1
- package/dist/observed-reducer.js +7 -4
- package/dist/yms-kernel.js +12 -5
- package/dist-cjs/index.cjs +648 -187
- package/package.json +1 -1
package/dist/contract.d.ts
CHANGED
|
@@ -89,7 +89,7 @@ export interface Hierarchy {
|
|
|
89
89
|
}): string[];
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
|
-
* 계층 색인을 만든다. **순환은 만들 때
|
|
92
|
+
* 계층 색인을 만든다. **순환은 만들 때 잡아 오류를 낸다** — 렌더 도중에 터지는 대신 여기서 한 번에.
|
|
93
93
|
* 순환을 조용히 잘라 내면 롤업이 틀린 값을 내고, 그건 이 함수가 막으려는 바로 그 실패다.
|
|
94
94
|
*/
|
|
95
95
|
export declare function hierarchyOf(s: {
|
|
@@ -426,6 +426,18 @@ export interface MaterialQuantity {
|
|
|
426
426
|
*
|
|
427
427
|
* **한 곳에서 정한다.** 소비처마다 `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 접히고,
|
|
428
428
|
* 그 순간 재고가 조용히 줄어든다(실제로 그랬다 — §ItemState.subLotId).
|
|
429
|
+
*
|
|
430
|
+
* ── 규약 (2026-08-21에 확정) ────────────────────────────────────────────────
|
|
431
|
+
* ① 물품 맵의 **키는 이 함수의 결과**다. 시뮬·미러 어느 쪽도 규칙을 인라인으로 다시 적지 않는다
|
|
432
|
+
* (미러가 `subLotId ?? epc` 를 세 곳에서 다시 적고 있었고, 그런 중복은 한쪽만 고쳐진다).
|
|
433
|
+
* ② **상태에 실리는 참조도 그 키**다(`TaskState.itemRefs` · `FlowTask.itemEpc`). 그래야 찾기가
|
|
434
|
+
* `get` 한 번으로 끝난다. 직렬 물품에서는 키가 곧 `epc` 이므로 대부분의 경로는 이미 그렇다.
|
|
435
|
+
* ③ 예외는 하나다: **원본이 준 참조**(운영 사실 유입·외부 씨앗)는 우리 키 규칙을 모른다. 그때만
|
|
436
|
+
* `FlowEngine.itemByRef` 의 대체 경로(전수 조회)를 지나고, 그 횟수를 `refScanCount()` 가 센다.
|
|
437
|
+
* 「대체 경로는 드물 것이다」를 짐작하지 않기 위해서다 — 인덱스를 얹을 근거는 그 수다.
|
|
438
|
+
*
|
|
439
|
+
* 왜 참조를 키로 통일하고 인덱스를 먼저 얹지 않았나: 키 규약이 두 갈래인 채로 인덱스를 얹으면
|
|
440
|
+
* **그 질문이 닫힌다**(두 갈래를 전제한 구조가 굳는다). 규약을 먼저 정하고, 남는 비용을 재서 넣는다.
|
|
429
441
|
*/
|
|
430
442
|
export declare function subLotIdOf(classUri: string, location: string): string;
|
|
431
443
|
export declare function itemKeyOf(item: {
|
|
@@ -1142,6 +1154,15 @@ export interface OrderState {
|
|
|
1142
1154
|
* 알 수 없다는 뜻이고, 확보분(`allocated`)이 같은 식으로 빠져 계보의 절반이 사라졌던 것과 같은 부류다.
|
|
1143
1155
|
*/
|
|
1144
1156
|
gtin?: string;
|
|
1157
|
+
/**
|
|
1158
|
+
* **무엇으로 만드나** — 이 오더가 든 레시피(표준 `OperationsRequest` → `SegmentRequirement`).
|
|
1159
|
+
*
|
|
1160
|
+
* 품목만으로는 「무엇으로」가 남지 않는다: 같은 품목에 대체 레시피가 있을 수 있고, 그러면 소요·라우트·
|
|
1161
|
+
* 소요시간이 다르다. 없으면 소비처는 **묻지 않는다**(레시피가 하나인 트윈이다).
|
|
1162
|
+
*/
|
|
1163
|
+
recipeKey?: string;
|
|
1164
|
+
/** 씨앗이 이 오더의 확보분을 다 심지 못했다 — 이 오더의 답은 부족한 씨앗 위에 있다. */
|
|
1165
|
+
seedIncomplete?: boolean;
|
|
1145
1166
|
progress?: number;
|
|
1146
1167
|
held?: boolean;
|
|
1147
1168
|
/**
|
|
@@ -1355,14 +1376,22 @@ export interface EnergyState {
|
|
|
1355
1376
|
/** 관측 시작 이후 최대 수요 — 월 경계는 여기서 정하지 않는다(위 주석). */
|
|
1356
1377
|
peakSince?: {
|
|
1357
1378
|
kW: number;
|
|
1358
|
-
windowStartMs: number;
|
|
1359
|
-
* 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — **버린 것을 세어 둔다.**
|
|
1360
|
-
*
|
|
1361
|
-
* 원천에 우리가 모르는 설비가 있다는 것은 그 자체로 알아야 할 사실이다(모델이 낡았거나 매핑이
|
|
1362
|
-
* 틀렸다). 조용히 버리면 「값이 왜 안 보이지」로만 남는다.
|
|
1363
|
-
*/
|
|
1364
|
-
unknownEquipment?: number;
|
|
1379
|
+
windowStartMs: number;
|
|
1365
1380
|
};
|
|
1381
|
+
/**
|
|
1382
|
+
* 우리 모델이 모르는 설비가 상태를 보내 온 횟수 — **버린 것을 세어 둔다.**
|
|
1383
|
+
*
|
|
1384
|
+
* 원천에 우리가 모르는 설비가 있다는 것은 그 자체로 알아야 할 사실이다(모델이 낡았거나 매핑이
|
|
1385
|
+
* 틀렸다). 조용히 버리면 「값이 왜 안 보이지」로만 남는다.
|
|
1386
|
+
*
|
|
1387
|
+
* **자리를 못 박아 둔다**: 이 칸은 `state.energy` 의 것이다 — 스냅샷 루트가 아니다. 예전에는 이 선언이
|
|
1388
|
+
* `peakSince` 의 타입 안에 갇혀 있었다(중괄호 하나가 닫히지 않았다). 타입은 통과했지만 계약이 사실과
|
|
1389
|
+
* 달라서, 읽는 쪽이 자리를 짐작하다 스냅샷 루트에서 읽고 **영원히 `null`** 을 받았다 — 오류 없이
|
|
1390
|
+
* 「에너지 트윈이 아니다」로 읽히는 종류의 거짓이다.
|
|
1391
|
+
*
|
|
1392
|
+
* 0 이면 이 칸을 만들지 않는다. 「세었고 0」과 「에너지 트윈이 아님」은 `energy` 자체가 있는지로 가른다.
|
|
1393
|
+
*/
|
|
1394
|
+
unknownEquipment?: number;
|
|
1366
1395
|
/** 현장이 선언한 계약전력(자리 속성) — 없으면 계약 대비 판정을 하지 않는다. */
|
|
1367
1396
|
contractKW?: number;
|
|
1368
1397
|
/**
|
|
@@ -1405,6 +1434,25 @@ export interface StateSnapshot {
|
|
|
1405
1434
|
* 없으면 소비처는 **재지 않는다** — 벽시계로 대신 재면 시뮬 트윈에서 엉뚱한 값이 나온다.
|
|
1406
1435
|
*/
|
|
1407
1436
|
nowTime?: ISOTime;
|
|
1437
|
+
/**
|
|
1438
|
+
* **이 트윈의 정체성이 어디서 왔나** — 값이 아니라 **근거**다(§`identityGroundingOf`).
|
|
1439
|
+
*
|
|
1440
|
+
* 트윈당 한 번 싣는다. 사건마다 싣는 것은 비싸고 같은 사실의 반복이다. 없으면 소비처는 **판정하지
|
|
1441
|
+
* 않는다** — 기본값으로 `issued` 를 가정하면 그 화면이 곧 거짓이 된다.
|
|
1442
|
+
*/
|
|
1443
|
+
identityGrounding?: IdentityGroundingView;
|
|
1444
|
+
/**
|
|
1445
|
+
* **원본과 어긋난 사실의 수** — 관측(미러) 구동에서만 생긴다.
|
|
1446
|
+
*
|
|
1447
|
+
* 미러는 원본을 비추는 쪽이라 어긋남을 만나도 멈추지 않는다. 그러면 그 사실이 사라지므로 여기 센다.
|
|
1448
|
+
* 없으면(0) 이 칸이 아예 없다 — 어긋난 적 없는 트윈에 빈 칸을 만들지 않는다.
|
|
1449
|
+
*/
|
|
1450
|
+
conformance?: {
|
|
1451
|
+
/** 관측 구동에서 없는 입력을 만난 횟수 — 받아들였지만 사실이 맞지 않았다. */
|
|
1452
|
+
transformInputsAbsent?: number;
|
|
1453
|
+
/** 씨앗이 심지 못한 참조의 수 — 원본이 말했지만 그 물품이 스냅샷에 없었다. */
|
|
1454
|
+
seedDanglingRefs?: number;
|
|
1455
|
+
};
|
|
1408
1456
|
locations: LocationState[];
|
|
1409
1457
|
items: ItemState[];
|
|
1410
1458
|
/**
|
|
@@ -1483,11 +1531,11 @@ export interface CommandAck {
|
|
|
1483
1531
|
error?: string;
|
|
1484
1532
|
}
|
|
1485
1533
|
/**
|
|
1486
|
-
* 호스트-facing 커맨드 채널(비동기) — 씬 컴포넌트 등 클라이언트가 트윈 호스트에 커맨드를
|
|
1534
|
+
* 호스트-facing 커맨드 채널(비동기) — 씬 컴포넌트 등 클라이언트가 트윈 호스트에 커맨드를 보내는 계약.
|
|
1487
1535
|
* 커널 in-process `dispatch`(동기)와 달리 원격 호스트(GraphQL/HTTP/…) 전송을 추상화.
|
|
1488
1536
|
* 씬 컨트롤 컴포넌트는 이 인터페이스에만 의존(커널 레벨) — concrete 전송은 각 호스트(things-factory 등)가 주입.
|
|
1489
1537
|
* → 컴포넌트가 특정 호스트 구현(things-factory)에 갇히지 않고, 어디서든 개발/재사용 가능.
|
|
1490
|
-
* 컴포넌트는 tenantId 등을 모르는 부분 커맨드를
|
|
1538
|
+
* 컴포넌트는 tenantId 등을 모르는 부분 커맨드를 보내고, 호스트가 보강한다(commandId/type/args 만 제공).
|
|
1491
1539
|
*/
|
|
1492
1540
|
export interface TwinCommandChannel {
|
|
1493
1541
|
dispatch(command: Pick<Command, 'commandId' | 'type' | 'args'>): Promise<CommandAck>;
|
|
@@ -1875,6 +1923,8 @@ export interface OrderStatusDelta {
|
|
|
1875
1923
|
* 잃었던 것과 같은 부류다(패리티 가드가 이것을 잡았다).
|
|
1876
1924
|
*/
|
|
1877
1925
|
gtin?: string;
|
|
1926
|
+
/** 어느 레시피로 만드는가 — 품목만으로는 「무엇으로」가 남지 않는다(§`FlowOrder.recipeKey`). */
|
|
1927
|
+
recipeKey?: string;
|
|
1878
1928
|
held?: boolean;
|
|
1879
1929
|
/**
|
|
1880
1930
|
* 오더 라인(SKU+수량) — 선택. 있으면 이행 예측이 "남은 데맨드(라인별 requested-fulfilled)를
|
|
@@ -2110,7 +2160,123 @@ export interface ProductionSpec {
|
|
|
2110
2160
|
companyPrefix?: string;
|
|
2111
2161
|
/** 쓸 레시피 키(미지정 시 첫 레시피) — 직렬 생산 경로(MES)만 쓴다. */
|
|
2112
2162
|
recipeKey?: string;
|
|
2163
|
+
/** 이 트윈의 **정체성이 어디서 오나** — §IdentityDeclaration. 없으면 근거는 선언 형태에서 파생된다. */
|
|
2164
|
+
identity?: IdentityDeclaration;
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* 정체성 선언 — **이 트윈이 자기 것이라 말하는 이름공간.**
|
|
2168
|
+
*
|
|
2169
|
+
* ── 왜 필요한가 (2026-08-20) ────────────────────────────────────────────────
|
|
2170
|
+
* 커널이 품목 식별자를 지어내고 있었다(`sgtinUri(prefix, 'WIP', …)` · 상수 프리픽스). 그 압력의 출처는
|
|
2171
|
+
* 우리 검증기였다: 수량 리스트의 `epcClass` 에 EPC 형식을 강제했으므로, GS1 프리픽스가 없는 현장은
|
|
2172
|
+
* **통과할 방법이 없었다.** 그래서 지어냈다.
|
|
2173
|
+
*
|
|
2174
|
+
* 원문을 보니 그 강제가 표준의 요구가 아니었다: EPCIS 2.0 §6.4 는 「**소유 권한이 있는** URI」를 요구하고,
|
|
2175
|
+
* CBV 2.0 은 클래스 식별에 일반 HTTP URL 을 명시한다. 즉 **도메인만 있으면 GS1 발급 없이도 정합**이다.
|
|
2176
|
+
* 진입장벽은 우리가 만든 것이었다.
|
|
2177
|
+
*
|
|
2178
|
+
* 그래서 이름공간을 **현장이 선언한다.** 검증은 두 조건의 곱이다: 소유 권한이 있는 형태인가(표준) ∧
|
|
2179
|
+
* 이 트윈이 자기 것이라 선언한 범위인가(모델). 하나만 요구하면 각각 아무 도메인이나 통과하거나,
|
|
2180
|
+
* 소유 권한 없는 스킴이 통과한다.
|
|
2181
|
+
*/
|
|
2182
|
+
export interface IdentityDeclaration {
|
|
2183
|
+
/**
|
|
2184
|
+
* 이 트윈의 식별자가 속하는 이름공간들 — 예: `https://chef.example.com/product/` ·
|
|
2185
|
+
* `urn:epc:idpat:sgtin:0952000.` · `urn:oid:1.3.6.1.4.1.<PEN>.`
|
|
2186
|
+
*
|
|
2187
|
+
* 커널은 이 목록을 **정하지 않고 묻기만** 한다. 비어 있으면 선언이 없는 것이고, 없는 것을 채우지 않는다.
|
|
2188
|
+
*
|
|
2189
|
+
* **이름공간은 소속만 정한다.** 값의 모양(HTTP URL 의 `/class/` 표지 · URN 의 `:class:`)은 표준이 정하고
|
|
2190
|
+
* `classIdentifierViolation()` 이 판정한다 — 이름공간에 넣었다고 통과하는 것이 아니다.
|
|
2191
|
+
*
|
|
2192
|
+
* **선택이다.** GDTI 문서 타입만 선언하는 현장은 이름공간을 갖지 않는다(§`documentTypes`). 없는 것을
|
|
2193
|
+
* 억지로 적게 하면 그 현장은 빈 목록이나 거짓 이름공간을 쓰게 되고, 그때 근거 판정이 함께 거짓이 된다.
|
|
2194
|
+
*/
|
|
2195
|
+
namespaces?: string[];
|
|
2196
|
+
/**
|
|
2197
|
+
* 이 이름공간이 **발급받은 GS1 키**라는 주장.
|
|
2198
|
+
*
|
|
2199
|
+
* **주장이다 — 우리는 검증하지 않는다.** GEPIR 조회를 하지 않으므로 우리가 아는 것은 「모델이 그렇게
|
|
2200
|
+
* 말했다」까지다. 이 칸이 없으면 근거는 `declared` 이고, 그것도 정직한 값이다(형식은 GS1 이어도 출처는
|
|
2201
|
+
* 모델이다 — 실제로 우리 픽스처가 GS1 이 버린 예제 프리픽스를 그 자리에 두고 있었다).
|
|
2202
|
+
*/
|
|
2203
|
+
issuedClaim?: boolean;
|
|
2204
|
+
/**
|
|
2205
|
+
* **거래 문서 식별자**(작업지시 등)를 만들 때 쓰는 GDTI **문서 타입** — 종류별로 선언한다.
|
|
2206
|
+
*
|
|
2207
|
+
* ── 왜 이 칸이 필요한가 (2026-08-21) ─────────────────────────────────────
|
|
2208
|
+
* GDTI 는 `회사 프리픽스 + 문서 타입 + 일련번호`이고, **문서 타입은 GS1 이 공표하는 목록이 아니다** —
|
|
2209
|
+
* 프리픽스를 배정받은 회사가 자기 번호 용량에서 정한다. 그런데 커널이 `'403'`(작업지시)·`'401'`·
|
|
2210
|
+
* `'402'`·`'404'` 를 스스로 정해 저널에 영구히 기록하고 있었다. 회사의 배정 권한을 커널이 대신
|
|
2211
|
+
* 행사한 것이고, 프리픽스 날조와 같은 종류의 결함이다.
|
|
2212
|
+
*
|
|
2213
|
+
* **선언하지 않아도 된다.** 표준은 거래 문서 식별자에 GDTI 만 허용하지 않는다 — 선언된 이름공간
|
|
2214
|
+
* 아래 `.../bt/<id>`(CBV §8.5.5) 또는 `urn:<이름공간>:**:bt:<id>`(§8.5.4)가 정합이고, 그쪽이
|
|
2215
|
+
* **회사가 도메인만 있으면 되는** 더 쉬운 길이다. 이 칸은 GDTI 를 쓰는 현장을 위해 열어 둔다.
|
|
2216
|
+
*
|
|
2217
|
+
* 키는 문서의 종류(예: `workorder`), 값은 그 현장이 배정한 문서 타입이다.
|
|
2218
|
+
*/
|
|
2219
|
+
documentTypes?: Record<string, string>;
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* 정체성의 **근거** — 값이 어디서 왔나. 「우리가 구현했나」와 **다른 축**이다.
|
|
2223
|
+
*
|
|
2224
|
+
* 커버리지 세 축(구조·거동·표면)은 모두 `full` 이면서 그 값이 남의 번호일 수 있다. 그래서 이 축이 없으면
|
|
2225
|
+
* 「정체성 지원 완료」가 참인 동시에 저널에 위조가 쌓인다.
|
|
2226
|
+
*
|
|
2227
|
+
* ── 값마다 **신뢰도가 다르다** ──────────────────────────────────────────────
|
|
2228
|
+
* · `issued` — 모델이 발급받았다고 **주장**한 것. 우리는 확인하지 않는다(가장 약한 값).
|
|
2229
|
+
* · `declared` — 모델이 선언한 이름공간에서 왔다. 이것은 **확실히 안다**(선언이 근거다).
|
|
2230
|
+
* · `fabricated` — 코드 리터럴에서 왔다. 이것도 확실히 안다 — 그리고 **릴리즈를 통과해선 안 된다.**
|
|
2231
|
+
*
|
|
2232
|
+
* 어댑터의 `grounding`(`vendor-doc`·`standard`·`facsimile`)과 낱말은 겹치지만 **값 집합이 다르다**:
|
|
2233
|
+
* 저쪽은 「우리 매핑의 근거가 무엇인가」, 이쪽은 「식별자가 어디서 왔는가」다. 서로 대입하지 말 것.
|
|
2234
|
+
*
|
|
2235
|
+
* ── 표준의 권고는 여전히 EPC URI 다 ────────────────────────────────────────
|
|
2236
|
+
* CBV 2.0 §8.3.3·§8.3.4 는 둘 다 이렇게 말한다: *"both CBV-Compliant and CBV-Compatible documents **SHOULD**
|
|
2237
|
+
* use the EPC URI form unless there is a **strong** reason to do otherwise."* 즉 「사내 식별자도 표준이다」는
|
|
2238
|
+
* 맞지만, 표준이 **권하는 것은 GS1 키**다. 「GS1 프리픽스가 없다」가 그 strong reason 에 해당한다.
|
|
2239
|
+
* 이 축은 GS1 을 권하지 않으려고 있는 것이 아니라, **없는 현장을 막지 않으려고** 있다.
|
|
2240
|
+
*
|
|
2241
|
+
* ── 이름공간에 든다고 통과가 아니다 ────────────────────────────────────────
|
|
2242
|
+
* 판정은 두 겹이고 **서로 다른 것을 본다**:
|
|
2243
|
+
* · (a) **모양** — 소유 권한을 말할 수 있는 형태인가. HTTP(S) URL 은 `…/class/<Objclassid>` 표지가
|
|
2244
|
+
* **필수**이고(CBV §8.3.4), URN 은 `…:class:<Objclassid>` 가 필수다(§8.3.3). 즉 이름공간이
|
|
2245
|
+
* `https://chef.example.com` 이어도 값이 `…/product/<uuid>` 면 **부적합**하다 — `…/class/<uuid>` 여야 한다.
|
|
2246
|
+
* · (b) **소속** — 그 값이 `namespaces` 가 말한 범위 안인가.
|
|
2247
|
+
*
|
|
2248
|
+
* (a) 의 규칙은 한 곳에만 있다: `epcis.ts` 의 `classIdentifierViolation()`. 소비처는 그것을 **부르고**,
|
|
2249
|
+
* 규칙을 다시 적지 않는다(두 벌이 되면 한쪽만 고쳐진다).
|
|
2250
|
+
*/
|
|
2251
|
+
export type IdentityGrounding = 'issued' | 'declared' | 'fabricated';
|
|
2252
|
+
/** 근거 판정 — 값과, 왜 그 값인지, 그리고 검증기가 쓸 이름공간. */
|
|
2253
|
+
export interface IdentityGroundingView {
|
|
2254
|
+
grounding: IdentityGrounding;
|
|
2255
|
+
/**
|
|
2256
|
+
* 그 판정의 근거 자리 — 화면·게이트가 「왜」를 말할 수 있게.
|
|
2257
|
+
* · `claimed-issued` — 모델이 발급을 주장했다
|
|
2258
|
+
* · `declared-namespace` — 모델이 이름공간을 선언했다
|
|
2259
|
+
* · `gs1-shaped` — `companyPrefix`+`binding` 은 있으나 발급 주장은 없다(형식만 GS1)
|
|
2260
|
+
* · `kernel-constant` — 선언이 없어 커널 상수로 돈다(레거시 내장 시나리오)
|
|
2261
|
+
*/
|
|
2262
|
+
basis: 'claimed-issued' | 'declared-namespace' | 'gs1-shaped' | 'kernel-constant';
|
|
2263
|
+
/**
|
|
2264
|
+
* 이 트윈이 자기 것이라 선언한 이름공간들 — 검증기의 두 번째 조건이 이것을 쓴다.
|
|
2265
|
+
*
|
|
2266
|
+
* 선언이 없는 레거시 트윈에는 **커널 상수에서 파생된** 값이 들어온다. 그것이 통과를 만들어 주지만
|
|
2267
|
+
* 정직성은 `grounding: 'fabricated'` 가 지킨다 — 릴리즈 게이트가 그 값으로 막는다. 레거시 경로의
|
|
2268
|
+
* 사건을 여기서 거절하면 그 트윈은 아무 사실도 남기지 못하는데, 그건 지금 고치는 문제가 아니다.
|
|
2269
|
+
*/
|
|
2270
|
+
namespaces: string[];
|
|
2113
2271
|
}
|
|
2272
|
+
/**
|
|
2273
|
+
* 선언에서 근거를 **파생한다** — 새 사실을 만들지 않는다.
|
|
2274
|
+
*
|
|
2275
|
+
* `companyPrefix` 가 있다는 사실을 `issued` 로 읽지 **않는다**: 그 값이 이 회사에 발급되었다는 뜻이
|
|
2276
|
+
* 아니기 때문이다(우리 픽스처가 반례다 — GS1 이 2022 년에 버린 예제 프리픽스 `0614141` 이 그 자리에
|
|
2277
|
+
* 있었다). 발급은 모델이 **따로 말해야** 하고, 그것도 주장이다.
|
|
2278
|
+
*/
|
|
2279
|
+
export declare function identityGroundingOf(spec: ProductionSpec | undefined, kernelConstantPrefix?: string): IdentityGroundingView;
|
|
2114
2280
|
/**
|
|
2115
2281
|
* 공장을 전환한 결과 — **몇 개가 늘고 몇 개가 사라졌나.**
|
|
2116
2282
|
*
|
package/dist/contract.js
CHANGED
|
@@ -57,7 +57,7 @@ export function isEquipmentLevel(v) {
|
|
|
57
57
|
return typeof v === 'string' && EQUIPMENT_LEVEL.includes(v);
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
* 계층 색인을 만든다. **순환은 만들 때
|
|
60
|
+
* 계층 색인을 만든다. **순환은 만들 때 잡아 오류를 낸다** — 렌더 도중에 터지는 대신 여기서 한 번에.
|
|
61
61
|
* 순환을 조용히 잘라 내면 롤업이 틀린 값을 내고, 그건 이 함수가 막으려는 바로 그 실패다.
|
|
62
62
|
*/
|
|
63
63
|
export function hierarchyOf(s,
|
|
@@ -150,7 +150,7 @@ export function testPassedAt(r, at) {
|
|
|
150
150
|
return false;
|
|
151
151
|
if (!r.expiresAt || !at)
|
|
152
152
|
return r.result === 'pass';
|
|
153
|
-
return
|
|
153
|
+
return parsedMs(at) <= parsedMs(r.expiresAt);
|
|
154
154
|
}
|
|
155
155
|
/**
|
|
156
156
|
* 이 개체가 **요구된 시험들을 만족하나** — 등급이 요구하고 개체가 기록을 든다.
|
|
@@ -293,8 +293,8 @@ export function priorityRank(p) {
|
|
|
293
293
|
export function dueStatusOf(x, nowIso) {
|
|
294
294
|
if (!x.endTime || !nowIso)
|
|
295
295
|
return undefined;
|
|
296
|
-
const due =
|
|
297
|
-
const now =
|
|
296
|
+
const due = parsedMs(x.endTime);
|
|
297
|
+
const now = parsedMs(nowIso);
|
|
298
298
|
if (!Number.isFinite(due) || !Number.isFinite(now))
|
|
299
299
|
return undefined;
|
|
300
300
|
return now > due ? 'late' : 'on-time';
|
|
@@ -316,6 +316,18 @@ export function dueStatusOf(x, nowIso) {
|
|
|
316
316
|
*
|
|
317
317
|
* **한 곳에서 정한다.** 소비처마다 `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 접히고,
|
|
318
318
|
* 그 순간 재고가 조용히 줄어든다(실제로 그랬다 — §ItemState.subLotId).
|
|
319
|
+
*
|
|
320
|
+
* ── 규약 (2026-08-21에 확정) ────────────────────────────────────────────────
|
|
321
|
+
* ① 물품 맵의 **키는 이 함수의 결과**다. 시뮬·미러 어느 쪽도 규칙을 인라인으로 다시 적지 않는다
|
|
322
|
+
* (미러가 `subLotId ?? epc` 를 세 곳에서 다시 적고 있었고, 그런 중복은 한쪽만 고쳐진다).
|
|
323
|
+
* ② **상태에 실리는 참조도 그 키**다(`TaskState.itemRefs` · `FlowTask.itemEpc`). 그래야 찾기가
|
|
324
|
+
* `get` 한 번으로 끝난다. 직렬 물품에서는 키가 곧 `epc` 이므로 대부분의 경로는 이미 그렇다.
|
|
325
|
+
* ③ 예외는 하나다: **원본이 준 참조**(운영 사실 유입·외부 씨앗)는 우리 키 규칙을 모른다. 그때만
|
|
326
|
+
* `FlowEngine.itemByRef` 의 대체 경로(전수 조회)를 지나고, 그 횟수를 `refScanCount()` 가 센다.
|
|
327
|
+
* 「대체 경로는 드물 것이다」를 짐작하지 않기 위해서다 — 인덱스를 얹을 근거는 그 수다.
|
|
328
|
+
*
|
|
329
|
+
* 왜 참조를 키로 통일하고 인덱스를 먼저 얹지 않았나: 키 규약이 두 갈래인 채로 인덱스를 얹으면
|
|
330
|
+
* **그 질문이 닫힌다**(두 갈래를 전제한 구조가 굳는다). 규약을 먼저 정하고, 남는 비용을 재서 넣는다.
|
|
319
331
|
*/
|
|
320
332
|
export function subLotIdOf(classUri, location) {
|
|
321
333
|
return `${classUri}@${location}`;
|
|
@@ -651,7 +663,7 @@ export function capabilityOf(r, ctx) {
|
|
|
651
663
|
if (r.status === 'down')
|
|
652
664
|
return { available: false, reason: 'down' };
|
|
653
665
|
if (at) {
|
|
654
|
-
const ms =
|
|
666
|
+
const ms = parsedMs(at);
|
|
655
667
|
if (Number.isFinite(ms)) {
|
|
656
668
|
const why = offCalendarReasonAt(r, ms, ctx?.utcOffsetMinutes);
|
|
657
669
|
if (why === 'non-working')
|
|
@@ -812,3 +824,32 @@ export function readBoardAssets(def) {
|
|
|
812
824
|
const list = (def.assets ?? []);
|
|
813
825
|
return list.map(a => normalizeHomeLocation(a));
|
|
814
826
|
}
|
|
827
|
+
/**
|
|
828
|
+
* 선언에서 근거를 **파생한다** — 새 사실을 만들지 않는다.
|
|
829
|
+
*
|
|
830
|
+
* `companyPrefix` 가 있다는 사실을 `issued` 로 읽지 **않는다**: 그 값이 이 회사에 발급되었다는 뜻이
|
|
831
|
+
* 아니기 때문이다(우리 픽스처가 반례다 — GS1 이 2022 년에 버린 예제 프리픽스 `0614141` 이 그 자리에
|
|
832
|
+
* 있었다). 발급은 모델이 **따로 말해야** 하고, 그것도 주장이다.
|
|
833
|
+
*/
|
|
834
|
+
export function identityGroundingOf(spec, kernelConstantPrefix) {
|
|
835
|
+
const declared = spec?.identity?.namespaces?.filter(n => typeof n === 'string' && n.length > 0) ?? [];
|
|
836
|
+
if (spec?.identity?.issuedClaim && declared.length) {
|
|
837
|
+
return { grounding: 'issued', basis: 'claimed-issued', namespaces: [...declared] };
|
|
838
|
+
}
|
|
839
|
+
if (declared.length)
|
|
840
|
+
return { grounding: 'declared', basis: 'declared-namespace', namespaces: [...declared] };
|
|
841
|
+
if (spec?.companyPrefix && spec.binding) {
|
|
842
|
+
/* 형식은 GS1 이지만 출처는 모델이다 — 그 프리픽스가 발급되었다는 근거가 우리에게 없다. */
|
|
843
|
+
return { grounding: 'declared', basis: 'gs1-shaped', namespaces: gs1Namespaces(spec.companyPrefix) };
|
|
844
|
+
}
|
|
845
|
+
/* 선언이 없다 — 이 트윈은 커널 상수로 돈다. 그 사실을 값으로 말한다. */
|
|
846
|
+
return {
|
|
847
|
+
grounding: 'fabricated',
|
|
848
|
+
basis: 'kernel-constant',
|
|
849
|
+
namespaces: kernelConstantPrefix ? gs1Namespaces(kernelConstantPrefix) : []
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/** 회사 프리픽스가 정하는 GS1 이름공간들 — 커널이 값을 고르지 않고 선언을 펼친다. */
|
|
853
|
+
function gs1Namespaces(prefix) {
|
|
854
|
+
return [`urn:epc:idpat:sgtin:${prefix}.`, `urn:epc:class:lgtin:${prefix}.`, `urn:epc:id:sgtin:${prefix}.`];
|
|
855
|
+
}
|
package/dist/domain-catalog.d.ts
CHANGED
|
@@ -225,7 +225,7 @@ export declare const propertiesOf: (axis: string) => TwinPropertyInfo[];
|
|
|
225
225
|
*/
|
|
226
226
|
export declare const axisSource: (axis: string) => "document" | "state" | "journal";
|
|
227
227
|
/**
|
|
228
|
-
* 문서에서 이 축을 찾을 자리 — **문서 축이 아니면
|
|
228
|
+
* 문서에서 이 축을 찾을 자리 — **문서 축이 아니면 오류를 낸다.**
|
|
229
229
|
*
|
|
230
230
|
* 상태·저널 축에는 board 안의 자리가 없다. 그런데 옛 소비처는 `info.path` 를 무조건 읽었으므로,
|
|
231
231
|
* 그 자리가 `undefined` 면 board 루트를 읽고 **오류 없이 0 을 답한다**(이 프로젝트가 겪은 실패 모양).
|
package/dist/domain-catalog.js
CHANGED
|
@@ -202,7 +202,7 @@ export const propertiesOf = (axis) => TWIN_PROPERTIES.filter(p => p.axis === axi
|
|
|
202
202
|
*/
|
|
203
203
|
export const axisSource = (axis) => TWIN_AXES.find(a => a.axis === axis)?.source ?? 'document';
|
|
204
204
|
/**
|
|
205
|
-
* 문서에서 이 축을 찾을 자리 — **문서 축이 아니면
|
|
205
|
+
* 문서에서 이 축을 찾을 자리 — **문서 축이 아니면 오류를 낸다.**
|
|
206
206
|
*
|
|
207
207
|
* 상태·저널 축에는 board 안의 자리가 없다. 그런데 옛 소비처는 `info.path` 를 무조건 읽었으므로,
|
|
208
208
|
* 그 자리가 `undefined` 면 board 루트를 읽고 **오류 없이 0 을 답한다**(이 프로젝트가 겪은 실패 모양).
|
|
@@ -29,6 +29,23 @@ export interface MaterialDef {
|
|
|
29
29
|
key: string;
|
|
30
30
|
label: string;
|
|
31
31
|
identity?: Identity;
|
|
32
|
+
/**
|
|
33
|
+
* 이 자재가 놓이는 자리 타입(`LocationTypeDef.key`) — **보관처는 자재의 성질이다.**
|
|
34
|
+
*
|
|
35
|
+
* 이 자리가 없던 동안 커널은 `'raw-store'`·`'fg-store'` 라는 이름을 **코드에 박아 두고** 있었다.
|
|
36
|
+
* 그래서 현장은 자기 창고를 그 이름으로 **개명해야** 트윈이 굴러갔다 — 커널이 현장의 낱말을 정하는
|
|
37
|
+
* 셈이고, 개명하지 않은 트윈은 조용히 멈추거나(자재가 영원히 안 들어옴) 터졌다.
|
|
38
|
+
*
|
|
39
|
+
* **왜 레시피 줄이 아니라 자재인가**: 같은 밀가루를 쓰는 레시피가 열이면 줄마다 같은 값을 열 번
|
|
40
|
+
* 적게 되고, 창고를 옮길 때 열 곳을 고쳐야 한다. 보관처는 제품 구조(BOM)의 성질이 아니다 —
|
|
41
|
+
* 실 시스템도 그렇게 두지 않는다: 조사한 식음료 MES 에서 **품목이 창고를 들고 BOM 줄에는 없었다.**
|
|
42
|
+
* (커널은 어느 제품에도 기대지 않는다 — 실 시스템은 이 결함이 실재한다는 **증인**이고, 커널의 모양을
|
|
43
|
+
* 정하는 것은 표준과 원칙이다.)
|
|
44
|
+
*
|
|
45
|
+
* 선택 필드로 둔 것은 자재를 선언만 하고 쓰지 않는 정의를 막지 않으려는 것이다. **레시피가 쓰는
|
|
46
|
+
* 자재는 이것을 반드시 선언해야 한다** — `validateDomainDefinition` 이 그때 위반으로 잡는다.
|
|
47
|
+
*/
|
|
48
|
+
locationType?: string;
|
|
32
49
|
}
|
|
33
50
|
/** 로케이션(수동 위치) 타입. */
|
|
34
51
|
export interface LocationTypeDef {
|
|
@@ -48,6 +48,7 @@ export function validateDomainDefinition(def) {
|
|
|
48
48
|
const locationKeys = new Set((def.locationTypes || []).map(n => n.key));
|
|
49
49
|
const resKeys = new Set((def.resourceTypes || []).map(r => r.key));
|
|
50
50
|
const matKeys = new Set((def.materials || []).map(m => m.key));
|
|
51
|
+
const matByKey = new Map((def.materials || []).map(m => [m.key, m]));
|
|
51
52
|
const opKeys = new Set((def.operations || []).map(o => o.key));
|
|
52
53
|
const routeKeys = new Set((def.routes || []).map(r => r.key));
|
|
53
54
|
for (const [name, arr] of [['locationTypes', def.locationTypes], ['resourceTypes', def.resourceTypes], ['materials', def.materials], ['operations', def.operations], ['routes', def.routes], ['recipes', def.recipes]]) {
|
|
@@ -81,6 +82,12 @@ export function validateDomainDefinition(def) {
|
|
|
81
82
|
v.push(`recipe '${rc.key}' material '${p.material}' 미정의`);
|
|
82
83
|
if (typeof p.qty !== 'number' || p.qty <= 0)
|
|
83
84
|
v.push(`recipe '${rc.key}' material '${p.material}' qty 부정`);
|
|
85
|
+
/* 레시피가 쓰는 자재는 자기 자리를 말해야 한다 — 커널은 그것을 지어내지 않는다(MaterialDef.locationType). */
|
|
86
|
+
const mat = matByKey.get(p.material);
|
|
87
|
+
if (mat && !mat.locationType)
|
|
88
|
+
v.push(`recipe '${rc.key}' material '${p.material}' locationType 미선언`);
|
|
89
|
+
if (mat?.locationType && !locationKeys.has(mat.locationType))
|
|
90
|
+
v.push(`recipe '${rc.key}' material '${p.material}' locationType '${mat.locationType}' 미정의`);
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
return v;
|
package/dist/ems-kernel.js
CHANGED
|
@@ -436,6 +436,18 @@ export class EmsKernel extends FlowEngine {
|
|
|
436
436
|
startMs: w.startMs,
|
|
437
437
|
endMs: w.endMs,
|
|
438
438
|
...(w.maxKW !== undefined ? { maxKW: w.maxKW } : {}),
|
|
439
|
+
/*
|
|
440
|
+
* ── 선언된 것만의 합도 **사실로 남긴다** (2026-08-20) ────────────────────
|
|
441
|
+
*
|
|
442
|
+
* 상태에만 두면 저널을 읽는 쪽(성과·타임라인·요금 화면)은 총합만 본다. 실 서버에서 확인한 모양이
|
|
443
|
+
* 그랬다: 유령 계량기 300kW 가 섞인 구간이 「계약 1,000 초과 1,100」으로만 남고, 그중 얼마가
|
|
444
|
+
* 정체 모를 계량기인지 되짚을 길이 없었다. 지나간 구간의 두 수는 나중에 계산할 수 없다 —
|
|
445
|
+
* 그 순간의 지점별 kW 를 커널이 보관하지 않기 때문이다.
|
|
446
|
+
*
|
|
447
|
+
* `overContract` 는 여기서도 총합으로만 판정한다(요금이 그렇게 매겨진다). 선언된 것만으로는
|
|
448
|
+
* 넘지 않았다는 판단은 **읽는 쪽이** 이 두 수로 한다 — 판정을 두 벌 만들지 않는다.
|
|
449
|
+
*/
|
|
450
|
+
...(w.maxDeclaredKW !== undefined ? { maxDeclaredKW: w.maxDeclaredKW } : {}),
|
|
439
451
|
/* 총부하도 사실로 남긴다 — 이것이 없으면 저널을 읽는 쪽은 「무엇이 깎였나」를 되짚을 수 없다
|
|
440
452
|
(요금은 순수요로 매겨지지만, 그 수요를 만든 것은 설비 부하다). */
|
|
441
453
|
...(w.grossMaxKW !== undefined ? { grossMaxKW: w.grossMaxKW } : {}),
|
|
@@ -609,7 +621,7 @@ export class EmsKernel extends FlowEngine {
|
|
|
609
621
|
* **아무 일도 하지 않는다** — 그런데 조용히 넘기지 않는다: 이 커널이 그런 요청을 받았다는 것은
|
|
610
622
|
* **배선이 잘못됐다는 사실**이고(EMS 트윈에 물류 명령을 보낸 것), 조용히 넘기면 그 사실이 사라진다.
|
|
611
623
|
*
|
|
612
|
-
*
|
|
624
|
+
* 오류를 내지도 않는다: 저널 재생 중이라면 트윈 전체가 멈춘다. 그래서 커널이 담을 줄 모르는 사건을
|
|
613
625
|
* 세는 자리(`ObservedReducer.unhandled`)와 같은 규율으로, **개수를 세어 상태로 낸다.**
|
|
614
626
|
*/
|
|
615
627
|
flowRequests = new Map();
|
package/dist/epcis.d.ts
CHANGED
|
@@ -252,6 +252,26 @@ export interface ParsedEpc {
|
|
|
252
252
|
export declare function parseEpc(uri: string): ParsedEpc;
|
|
253
253
|
/** 거래문서 식별자 = GDTI (PO/SO/WO/어포인트먼트 등). */
|
|
254
254
|
export declare function gdtiUri(companyPrefix: string, docType: string, serial: number): string;
|
|
255
|
+
/**
|
|
256
|
+
* **선언된 이름공간 아래의 거래 문서 식별자** — GDTI 를 쓰지 않는 길.
|
|
257
|
+
*
|
|
258
|
+
* ── 왜 이 길이 필요한가 (2026-08-21) ───────────────────────────────────────
|
|
259
|
+
* GDTI 는 `회사 프리픽스 + 문서 타입 + 일련번호`이고 **문서 타입은 회사가 배정한다**(GS1 이 공표하는
|
|
260
|
+
* 목록이 아니다). 그런데 커널이 `'403'`(작업지시)·`'401'`·`'402'`·`'404'` 를 스스로 정하고 있었다.
|
|
261
|
+
*
|
|
262
|
+
* 표준은 거래 문서 식별자에 GDTI 만 요구하지 않는다. CBV 2.0 이 세 형태를 더 정한다:
|
|
263
|
+
* · §8.5.5 `http(s)://[Subdomain.]Domain/⁎⁎/bt/transID` — 그 도메인 소유자가 배정
|
|
264
|
+
* · §8.5.4 `urn:URNNamespace:⁎⁎:bt:transID` — URN 이름공간 소유자가 배정
|
|
265
|
+
* · §8.5.3 `urn:epcglobal:cbv:bt:gln:transID` — GLN 소유자가 배정
|
|
266
|
+
*
|
|
267
|
+
* `bt` 표지가 **필수**이고, `transID` 는 경로 성분 **하나**다(「only one URI path component SHALL
|
|
268
|
+
* follow the /bt/」). 그래서 회사가 **도메인만 있으면** 문서 타입을 정할 일이 없다 — 이것이 GDTI 보다
|
|
269
|
+
* 진입장벽이 낮은 길이다.
|
|
270
|
+
*
|
|
271
|
+
* 이름공간의 모양으로 URL 형태와 URN 형태를 가른다. 판정할 수 없는 모양이면 **답하지 않는다**(지어내지
|
|
272
|
+
* 않는다) — 호출부가 다른 길을 고르게 한다.
|
|
273
|
+
*/
|
|
274
|
+
export declare function bizTransactionUri(namespace: string, transId: string | number): string | undefined;
|
|
255
275
|
/** 모든 빌더가 공통으로 받는 표준 헤더 옵션(선택) — 방출부가 필요할 때 채운다. */
|
|
256
276
|
export interface EpcisHeaderOptions {
|
|
257
277
|
eventID?: string;
|
|
@@ -312,5 +332,7 @@ export declare function transformationEvent(p: {
|
|
|
312
332
|
bizLocation?: string;
|
|
313
333
|
bizTransactionList?: BizTransactionElement[];
|
|
314
334
|
} & EpcisHeaderOptions): EpcisTransformationEvent;
|
|
335
|
+
/** 위반 문구, 또는 통과면 `undefined`. */
|
|
336
|
+
export declare function classIdentifierViolation(epcClass: string | undefined): string | undefined;
|
|
315
337
|
export declare function validateEpcisEvent(e: EpcisEvent): string[];
|
|
316
338
|
export {};
|
package/dist/epcis.js
CHANGED
|
@@ -113,6 +113,46 @@ export function parseEpc(uri) {
|
|
|
113
113
|
export function gdtiUri(companyPrefix, docType, serial) {
|
|
114
114
|
return `urn:epc:id:gdti:${companyPrefix}.${docType}.${serial}`;
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* **선언된 이름공간 아래의 거래 문서 식별자** — GDTI 를 쓰지 않는 길.
|
|
118
|
+
*
|
|
119
|
+
* ── 왜 이 길이 필요한가 (2026-08-21) ───────────────────────────────────────
|
|
120
|
+
* GDTI 는 `회사 프리픽스 + 문서 타입 + 일련번호`이고 **문서 타입은 회사가 배정한다**(GS1 이 공표하는
|
|
121
|
+
* 목록이 아니다). 그런데 커널이 `'403'`(작업지시)·`'401'`·`'402'`·`'404'` 를 스스로 정하고 있었다.
|
|
122
|
+
*
|
|
123
|
+
* 표준은 거래 문서 식별자에 GDTI 만 요구하지 않는다. CBV 2.0 이 세 형태를 더 정한다:
|
|
124
|
+
* · §8.5.5 `http(s)://[Subdomain.]Domain/⁎⁎/bt/transID` — 그 도메인 소유자가 배정
|
|
125
|
+
* · §8.5.4 `urn:URNNamespace:⁎⁎:bt:transID` — URN 이름공간 소유자가 배정
|
|
126
|
+
* · §8.5.3 `urn:epcglobal:cbv:bt:gln:transID` — GLN 소유자가 배정
|
|
127
|
+
*
|
|
128
|
+
* `bt` 표지가 **필수**이고, `transID` 는 경로 성분 **하나**다(「only one URI path component SHALL
|
|
129
|
+
* follow the /bt/」). 그래서 회사가 **도메인만 있으면** 문서 타입을 정할 일이 없다 — 이것이 GDTI 보다
|
|
130
|
+
* 진입장벽이 낮은 길이다.
|
|
131
|
+
*
|
|
132
|
+
* 이름공간의 모양으로 URL 형태와 URN 형태를 가른다. 판정할 수 없는 모양이면 **답하지 않는다**(지어내지
|
|
133
|
+
* 않는다) — 호출부가 다른 길을 고르게 한다.
|
|
134
|
+
*/
|
|
135
|
+
export function bizTransactionUri(namespace, transId) {
|
|
136
|
+
const ns = namespace?.trim();
|
|
137
|
+
if (!ns)
|
|
138
|
+
return undefined;
|
|
139
|
+
const id = String(transId);
|
|
140
|
+
/* `transID` 에 구분자가 들어가면 표준이 요구하는 「성분 하나」가 깨진다. */
|
|
141
|
+
if (!id || id.includes('/') || id.includes(':'))
|
|
142
|
+
return undefined;
|
|
143
|
+
if (/^https?:\/\/[^/\s]+/.test(ns))
|
|
144
|
+
return `${ns.replace(/\/+$/, '')}/bt/${id}`;
|
|
145
|
+
/*
|
|
146
|
+
* **GS1 이 소유한 URN 공간에는 우리가 `:bt:` 를 만들 수 없다.** `urn:epc:`·`urn:epcglobal:` 의
|
|
147
|
+
* 소유 권한자는 GS1 이고(EPCIS §6.4), 그 안의 형태는 표준이 정해 둔 것만 유효하다. 그 공간을
|
|
148
|
+
* 이름공간으로 선언한 현장은 GDTI 문서 타입을 선언하는 쪽으로 가야 한다.
|
|
149
|
+
*/
|
|
150
|
+
if (/^urn:epc(global)?:/.test(ns))
|
|
151
|
+
return undefined;
|
|
152
|
+
if (/^urn:[^:\s]+/.test(ns))
|
|
153
|
+
return `${ns.replace(/:+$/, '')}:bt:${id}`;
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
116
156
|
function header(type, eventTime, bizStep, opts) {
|
|
117
157
|
const h = {
|
|
118
158
|
'@context': EPCIS_CONTEXT,
|
|
@@ -221,6 +261,54 @@ export function transformationEvent(p) {
|
|
|
221
261
|
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
222
262
|
const TZ_RE = /^[+-]\d{2}:\d{2}$/;
|
|
223
263
|
const ACTIONS = ['ADD', 'OBSERVE', 'DELETE'];
|
|
264
|
+
/*
|
|
265
|
+
* ★ 클래스 식별자 판정 — **표준이 요구하는 것은 GS1 발급이 아니라 「소유 권한」이다.**
|
|
266
|
+
*
|
|
267
|
+
* ── 무엇이 틀렸나 (2026-08-20) ──────────────────────────────────────────────
|
|
268
|
+
* 예전에는 `urn:epc:idpat:` · `urn:epc:class:` **둘만** 통과시켰다. 그래서 GS1 회사 프리픽스가 없는
|
|
269
|
+
* 현장은 **통과할 길이 없었고**, 통과시키려면 프리픽스를 지어내야 했다. 커널의 `0614141`(GS1 이 예제로
|
|
270
|
+
* 쓰다 버린 값, 실 배정 대역)이 그 압력의 산물이다. **막는 쪽이 위조의 원인이었다.**
|
|
271
|
+
*
|
|
272
|
+
* ── 원문 (Release 2.0, Ratified Jun 2022) ──────────────────────────────────
|
|
273
|
+
* EPCIS 2.0 §6.4: "The types of URIs admissible as Vocabulary Elements are those URIs **for which
|
|
274
|
+
* there is an owning authority.**" — EPC URI · 절대 URL(도메인 소유자) · PEN 기반 `oid` URN ·
|
|
275
|
+
* `epc`/`epcglobal` URN · GS1 Digital Link.
|
|
276
|
+
* EPCIS 2.0 §6.2: `epcClass`(Object Class) 는 **User Vocabulary** 다.
|
|
277
|
+
*
|
|
278
|
+
* CBV 2.0 이 클래스 식별자의 **모양**을 규정한다:
|
|
279
|
+
* · §8.3.3 `urn:URNNamespace:**:class:ObjClassid` — URN 이름공간 소유자가 배정
|
|
280
|
+
* · §8.3.4 `http(s)://[Subdomain.]Domain/⁎⁎/class/ObjClassid` — 그 인터넷 도메인 소유자가 배정
|
|
281
|
+
* (`ObjClassid` 에 `/` 불가 — "only one URI path component SHALL follow the /class/")
|
|
282
|
+
*
|
|
283
|
+
* `class` 표지가 **필수**다. 그냥 절대 URL 이면 되는 것이 아니다 —
|
|
284
|
+
* `https://chef.example.com/product/<uuid>` 는 **부적합**이고 `.../class/<uuid>` 가 적합이다.
|
|
285
|
+
*
|
|
286
|
+
* ── 아직 판정하지 않는 것 (좁힘을 드러내 둔다) ──────────────────────────────
|
|
287
|
+
* **비정규형 GS1 Digital Link**(§8.3.2 의 `https://example.com/some/path/info/8003/…` 류)는 거절한다.
|
|
288
|
+
* 정규형(`https://id.gs1.org/…`)만 받는다. 판정에 GS1 Application Identifier 지식이 필요하고 지금
|
|
289
|
+
* 소비처가 없다. **표준보다 좁은 것을 알고 있다** — Digital Link 를 쓰는 원천이 붙는 날 이 절을 연다.
|
|
290
|
+
*/
|
|
291
|
+
const EPC_CLASS_PREFIXES = ['urn:epc:idpat:', 'urn:epc:class:'];
|
|
292
|
+
/** CBV §8.3.2 정규형 Digital Link. 비정규형은 위 주석대로 아직 판정하지 않는다. */
|
|
293
|
+
const DL_CANONICAL = 'https://id.gs1.org/';
|
|
294
|
+
/** CBV §8.3.4 — 도메인 소유자가 배정. `ObjClassid` 는 경로 성분 **하나**(RFC3986 segment-nz). */
|
|
295
|
+
const CBV_URL_CLASS = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*class\/[^/\s]+$/;
|
|
296
|
+
/** CBV §8.3.3 — URN 이름공간 소유자가 배정. `ObjClassid` 에 콜론 불가. */
|
|
297
|
+
const CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
|
|
298
|
+
/** 위반 문구, 또는 통과면 `undefined`. */
|
|
299
|
+
export function classIdentifierViolation(epcClass) {
|
|
300
|
+
if (!epcClass)
|
|
301
|
+
return `quantity epcClass 부정: ${epcClass}`;
|
|
302
|
+
if (EPC_CLASS_PREFIXES.some(p => epcClass.startsWith(p)))
|
|
303
|
+
return undefined;
|
|
304
|
+
if (epcClass.startsWith(DL_CANONICAL))
|
|
305
|
+
return undefined;
|
|
306
|
+
if (CBV_URL_CLASS.test(epcClass) || CBV_URN_CLASS.test(epcClass))
|
|
307
|
+
return undefined;
|
|
308
|
+
return (`quantity epcClass 부정: ${epcClass} — 클래스 식별자는 소유 권한을 말할 수 있는 모양이어야 한다 ` +
|
|
309
|
+
`(EPC 클래스 urn:epc:idpat:/urn:epc:class: · CBV §8.3.4 http(s)://<도메인>/**/class/<id> · ` +
|
|
310
|
+
`CBV §8.3.3 urn:<이름공간>:**:class:<id>)`);
|
|
311
|
+
}
|
|
224
312
|
export function validateEpcisEvent(e) {
|
|
225
313
|
const v = [];
|
|
226
314
|
if (e['@context'] !== EPCIS_CONTEXT)
|
|
@@ -323,8 +411,9 @@ export function validateEpcisEvent(e) {
|
|
|
323
411
|
for (const list of qtyLists)
|
|
324
412
|
for (const q of list ?? []) {
|
|
325
413
|
/* 클래스 식별자만 — 개체(urn:epc:id:)는 epcList 의 몫이다. */
|
|
326
|
-
|
|
327
|
-
|
|
414
|
+
const bad = classIdentifierViolation(q.epcClass);
|
|
415
|
+
if (bad)
|
|
416
|
+
v.push(bad);
|
|
328
417
|
/* 세 경우(EPCIS 2.0 §7.3.3.1) — 느슨하게 통과시키면 "모름" 과 "0" 이 섞인다. */
|
|
329
418
|
const hasQty = q.quantity !== undefined && q.quantity !== null;
|
|
330
419
|
if (!hasQty) {
|
package/dist/face2-adapter.d.ts
CHANGED
|
@@ -8,7 +8,27 @@ export interface ObjectEventMapping {
|
|
|
8
8
|
action: MapValue;
|
|
9
9
|
bizStep: MapValue;
|
|
10
10
|
disposition?: MapValue;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* 개체 하나의 식별자 → `epcList: [epc]`. **`quantityList` 를 쓰면 없어도 된다** — 낱개 번호가 없는
|
|
13
|
+
* 자재(밀가루 3.5kg)는 개체가 없다. 둘 다 없으면 무엇을 관측했는지 말하지 않은 것이므로 오류다.
|
|
14
|
+
*/
|
|
15
|
+
epc?: MapValue;
|
|
16
|
+
/**
|
|
17
|
+
* 클래스와 수량 → `quantityList` (`{ epcClass, quantity, uom? }` 배열).
|
|
18
|
+
*
|
|
19
|
+
* ── 왜 뒤늦게 생겼나 (2026-08-20) ───────────────────────────────────────────
|
|
20
|
+
* `AggregationEventMapping.childQuantityList` 가 같은 이유로 하루 먼저 생겼다(적재를 말할 길이 없었다).
|
|
21
|
+
* 개체 관측에는 그 자리가 없어서, **원본이 「이 자리에 이 품목이 얼마 남았다」를 말할 길이 없었다.**
|
|
22
|
+
* 받는 쪽은 처음부터 준비돼 있었다 — `ObservedReducer` 는 `quantityList` 를 읽고, 개체 없이 수량만 오는
|
|
23
|
+
* 경우까지 따로 다룬다(비직렬 자재는 클래스 식별자가 곧 물품의 키다). 검증도 유효로 판정한다
|
|
24
|
+
* (`epcList` 와 `quantityList` 가 **둘 다** 비었을 때만 오류). 문만 없었다.
|
|
25
|
+
*
|
|
26
|
+
* 비직렬 수량이 주된 경로인 원본(식품 제조 MES)은 이 자리가 없으면 라이브를 낼 수 없다.
|
|
27
|
+
*
|
|
28
|
+
* **잔량 절대값을 싣는다** — 「얼마 뺐다」가 아니라 「얼마 남았다」다. 그래야 사건 하나를 놓쳐도 다음
|
|
29
|
+
* 사건이 정답을 다시 말해 주고 오차가 쌓이지 않는다(시뮬 `consumeMaterials` 가 택한 것과 같은 규약).
|
|
30
|
+
*/
|
|
31
|
+
quantityList?: MapValue;
|
|
12
32
|
readPoint?: MapValue;
|
|
13
33
|
bizLocation?: MapValue;
|
|
14
34
|
}
|
package/dist/face2-adapter.js
CHANGED
|
@@ -104,8 +104,19 @@ export function mapRecordChecked(record, mapping, eventTime) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
const epc = resolve(mapping.epc, record, 'epc', errors);
|
|
107
|
+
const quantityList = resolveQuantityList(mapping.quantityList, record, 'quantityList', errors);
|
|
108
|
+
/* 개체도 수량도 없으면 무엇을 관측했는지 말하지 않은 것이다 — 검증에 넘기기 전에 여기서 말해 준다
|
|
109
|
+
(검증 메시지는 이벤트를 가리키고, 이 메시지는 **매핑**을 가리킨다: 커넥터가 고칠 곳이 다르다). */
|
|
110
|
+
if (!epc && !quantityList.length) {
|
|
111
|
+
errors.push('epc 와 quantityList 가 둘 다 비었다 — 개체 식별자나 클래스+수량 중 하나는 있어야 한다');
|
|
112
|
+
}
|
|
107
113
|
return {
|
|
108
|
-
event: objectEvent({
|
|
114
|
+
event: objectEvent({
|
|
115
|
+
eventTime, action, bizStep, disposition,
|
|
116
|
+
epcList: epc ? [epc] : [],
|
|
117
|
+
...(quantityList.length ? { quantityList } : {}),
|
|
118
|
+
readPoint, bizLocation
|
|
119
|
+
}),
|
|
109
120
|
errors
|
|
110
121
|
};
|
|
111
122
|
}
|