@operato/ops-contract 0.9.3 → 0.9.4

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.
@@ -3269,11 +3269,26 @@ export interface QualityDelta {
3269
3269
  * 표준 매핑은 커널이 이미 적어 두었다 — `MaterialDefinitionID` → `definitionId`,
3270
3270
  * `MaterialUse` → `use`. 그 이름을 그대로 쓴다.
3271
3271
  */
3272
+ /**
3273
+ * **실적의 자재 쓰임** — 표준 `MaterialUse`. 들어갔나 나왔나 둘이다.
3274
+ *
3275
+ * 공정 **명세**의 쓰임(`OperationMaterial.use`)은 셋이다 — 거기에는 `consumable` 이 있다(쓰이지만
3276
+ * 제품에 남지 않는 것: 세척수·윤활유). **실적에는 그 낱말이 없다** — 일어난 일을 적는 자리이고,
3277
+ * 소모품이 실제로 들어간 것은 `consumed` 다.
3278
+ *
3279
+ * 둘을 한 어휘로 묶지 않는다. 묶으면 실적에 `consumable` 이 올 수 있게 되고, 그때 그것이
3280
+ * 「들어갔다」인지 「쓰였지만 안 남았다」인지 받는 쪽이 정해야 한다.
3281
+ *
3282
+ * 유입 게이트가 이 상수를 가리킨다(§`operational-ingest.ts` 의 `shapes`) — 낱말을 두 곳에 적으면
3283
+ * 한쪽만 늘어나는 날 게이트가 계약에 없는 낱말을 통과시킨다.
3284
+ */
3285
+ export declare const MATERIAL_ACTUAL_USE: readonly ["consumed", "produced"];
3286
+ export type MaterialActualUse = (typeof MATERIAL_ACTUAL_USE)[number];
3272
3287
  export interface MaterialActual {
3273
3288
  /** 표준 `MaterialDefinitionID`. */
3274
3289
  definitionId: string;
3275
- /** 표준 `MaterialUse`. */
3276
- use: 'consumed' | 'produced';
3290
+ /** 표준 `MaterialUse` — 실적은 둘이다(§`MATERIAL_ACTUAL_USE`). */
3291
+ use: MaterialActualUse;
3277
3292
  quantity: number;
3278
3293
  uom?: string;
3279
3294
  /**
package/dist/contract.js CHANGED
@@ -1318,6 +1318,31 @@ export function isFailureStatus(value) {
1318
1318
  export function isPlannedStopStatus(value) {
1319
1319
  return normalizeEquipmentStatus(value) === 'planned-stop';
1320
1320
  }
1321
+ /**
1322
+ * 실제로 들어가고 나온 자재 한 줄 — ISA-95 `OpMaterialActualType` 의 부분집합.
1323
+ *
1324
+ * ── 왜 이름 있는 타입인가 (2026-08-31) ────────────────────────────────────
1325
+ * 같은 데이터가 네 곳에 각각 인라인으로 적혀 있었다(작업 상태 둘 · 커널 · ERP). ERP 쪽은 필드명까지
1326
+ * 달랐다(`materialDefinitionId` · `direction`). 같은 데이터를 여러 이름으로 부르면 옮기는 코드가
1327
+ * 생기고, 그 코드가 언젠가 어긋난다.
1328
+ *
1329
+ * 표준 매핑은 커널이 이미 적어 두었다 — `MaterialDefinitionID` → `definitionId`,
1330
+ * `MaterialUse` → `use`. 그 이름을 그대로 쓴다.
1331
+ */
1332
+ /**
1333
+ * **실적의 자재 쓰임** — 표준 `MaterialUse`. 들어갔나 나왔나 둘이다.
1334
+ *
1335
+ * 공정 **명세**의 쓰임(`OperationMaterial.use`)은 셋이다 — 거기에는 `consumable` 이 있다(쓰이지만
1336
+ * 제품에 남지 않는 것: 세척수·윤활유). **실적에는 그 낱말이 없다** — 일어난 일을 적는 자리이고,
1337
+ * 소모품이 실제로 들어간 것은 `consumed` 다.
1338
+ *
1339
+ * 둘을 한 어휘로 묶지 않는다. 묶으면 실적에 `consumable` 이 올 수 있게 되고, 그때 그것이
1340
+ * 「들어갔다」인지 「쓰였지만 안 남았다」인지 받는 쪽이 정해야 한다.
1341
+ *
1342
+ * 유입 게이트가 이 상수를 가리킨다(§`operational-ingest.ts` 의 `shapes`) — 낱말을 두 곳에 적으면
1343
+ * 한쪽만 늘어나는 날 게이트가 계약에 없는 낱말을 통과시킨다.
1344
+ */
1345
+ export const MATERIAL_ACTUAL_USE = ['consumed', 'produced'];
1321
1346
  // ── Command 채널 어휘 — 트윈의 "행위(act)" 면 (prescriptive/트랜잭션 프론트엔드) ──
1322
1347
  // 코어 공통: order.hold/resume(할당 보류). 도메인: order.release(즉시 투입) 등은 handleCommand 로.
1323
1348
  export const CMD = {
package/dist/epcis.d.ts CHANGED
@@ -385,6 +385,24 @@ export declare function gdtiUri(companyPrefix: string, docType: string, serial:
385
385
  * Link 를 **권한다**(SHOULD) — 이 길은 프리픽스가 없을 때의 정합 경로다.
386
386
  */
387
387
  export declare function objectUri(namespace: string, objId: string | number): string | undefined;
388
+ /**
389
+ * **선언된 이름공간 아래의 클래스 식별자** — SGTIN·LGTIN 을 쓰지 않는 길.
390
+ *
391
+ * 품목 등급·로트 등급처럼 「낱개가 아니라 종류」를 가리키는 자리다. 개체 쪽(`objectUri`)과 같은
392
+ * 사정으로 필요하다: GTIN 을 조립하려면 회사 프리픽스가 있어야 하고, 없는 현장이 그 길로 못 간다.
393
+ *
394
+ * · CBV 2.0 §8.3.4 `http(s)://[Subdomain.]Domain/⁎⁎/class/ClassID`
395
+ * · CBV 2.0 §8.3.3 `urn:URNNamespace:⁎⁎:class:ClassID`
396
+ *
397
+ * ── 왜 계약에 두나 (2026-09-04, plant 레인이 올림) ─────────────────────────
398
+ * 체프 커넥터가 이 URI 를 **직접 조립하고 있었다** — `productClassId` · `lotClassId` 라는 자기
399
+ * 이름으로. 그중 `bizTransactionId` 는 이 파일의 `bizTransactionUri` 와 **같은 것을 다르게**
400
+ * 만들고 있었다. `underNamespace` 는 처음부터 `'class'` 표지를 알고 있었고(그 함수 주석에
401
+ * §8.3.3·§8.3.4 까지 적혀 있다) **부를 문만 없었다.**
402
+ *
403
+ * 그래서 한 표준에 방언이 둘이 됐다. 문을 낸다.
404
+ */
405
+ export declare function classUri(namespace: string, classId: string | number): string | undefined;
388
406
  export declare function bizTransactionUri(namespace: string, transId: string | number): string | undefined;
389
407
  /** 모든 빌더가 공통으로 받는 표준 헤더 옵션(선택) — 방출부가 필요할 때 채운다. */
390
408
  export interface EpcisHeaderOptions {
package/dist/epcis.js CHANGED
@@ -263,6 +263,26 @@ export function gdtiUri(companyPrefix, docType, serial) {
263
263
  export function objectUri(namespace, objId) {
264
264
  return underNamespace(namespace, 'obj', objId);
265
265
  }
266
+ /**
267
+ * **선언된 이름공간 아래의 클래스 식별자** — SGTIN·LGTIN 을 쓰지 않는 길.
268
+ *
269
+ * 품목 등급·로트 등급처럼 「낱개가 아니라 종류」를 가리키는 자리다. 개체 쪽(`objectUri`)과 같은
270
+ * 사정으로 필요하다: GTIN 을 조립하려면 회사 프리픽스가 있어야 하고, 없는 현장이 그 길로 못 간다.
271
+ *
272
+ * · CBV 2.0 §8.3.4 `http(s)://[Subdomain.]Domain/⁎⁎/class/ClassID`
273
+ * · CBV 2.0 §8.3.3 `urn:URNNamespace:⁎⁎:class:ClassID`
274
+ *
275
+ * ── 왜 계약에 두나 (2026-09-04, plant 레인이 올림) ─────────────────────────
276
+ * 체프 커넥터가 이 URI 를 **직접 조립하고 있었다** — `productClassId` · `lotClassId` 라는 자기
277
+ * 이름으로. 그중 `bizTransactionId` 는 이 파일의 `bizTransactionUri` 와 **같은 것을 다르게**
278
+ * 만들고 있었다. `underNamespace` 는 처음부터 `'class'` 표지를 알고 있었고(그 함수 주석에
279
+ * §8.3.3·§8.3.4 까지 적혀 있다) **부를 문만 없었다.**
280
+ *
281
+ * 그래서 한 표준에 방언이 둘이 됐다. 문을 낸다.
282
+ */
283
+ export function classUri(namespace, classId) {
284
+ return underNamespace(namespace, 'class', classId);
285
+ }
266
286
  export function bizTransactionUri(namespace, transId) {
267
287
  return underNamespace(namespace, 'bt', transId);
268
288
  }
@@ -271,15 +291,44 @@ export function bizTransactionUri(namespace, transId) {
271
291
  *
272
292
  * `obj`(개체 §8.2.3·§8.2.4) · `class`(클래스 §8.3.3·§8.3.4) · `bt`(거래문서 §8.5.4·§8.5.5) 가 같은
273
293
  * 모양이다. 규칙을 세 곳에 적으면 한 곳만 고쳐지는 날이 온다.
294
+ *
295
+ * ── 구분자를 계약이 부호화한다 (2026-09-04) ────────────────────────────────
296
+ * 전에는 구분자가 들면 **아무것도 만들지 않았다.** 그러면 부르는 쪽이 자기 손으로 부호화해서
297
+ * 넘기게 되고 — 실제로 그랬다 — 부르는 쪽마다 부호화가 달라진다. 방금 `classUri` 가 없어서
298
+ * 방언이 생긴 것과 **같은 부류**다. 표지 규칙만 계약이 갖고 부호화는 소비처에 남기면 반쪽이다.
299
+ *
300
+ * 그래서 **원문을 받아 계약이 부호화한다.** 부르는 쪽은 미리 부호화하지 않는다 — 하면 두 번
301
+ * 부호화되어(`A%2FB` → `A%252FB`) 다른 개체가 된다.
302
+ *
303
+ * ── 저장된 식별자를 건드리지 않는다 ────────────────────────────────────────
304
+ * 이 함수가 내는 값은 **저널에 남는 정체성**이다(인티그레이션 레인 실측: 47만 건 넘음). 한 글자만
305
+ * 달라도 이전 사실과 새 사실이 다른 개체가 된다. 그래서 **구조를 깨는 글자만** 부호화한다.
306
+ *
307
+ * 부호화한다 % / : ? # 공백 URL 경로 성분이나 URN 성분 경계를 깬다(`%` 는 부호화의
308
+ * 부호이므로 먼저 해야 뜻이 하나로 정해진다)
309
+ * 그대로 둔다 그 밖의 모든 글자 영숫자 · `-` · `.` · `_` · `~` 를 포함해 전부
310
+ *
311
+ * 그래서 **위 여섯 글자가 없는 값은 바이트까지 그대로다** — 지금 저장된 값은 그 집합에 들었나만
312
+ * 확인하면 된다(그 확인은 값을 가진 쪽이 한다). 전에 통과하던 값 중 구분자가 든 것은 없었으므로
313
+ * (구분자가 들면 만들지 않았다) 이 변경으로 **바뀔 수 있는 것은 공백·`%`·`?`·`#` 이 든 값뿐**이다.
274
314
  */
315
+ const STRUCTURE_BREAKING = /[%/:?# ]/g;
316
+ const PERCENT_ENCODED = {
317
+ '%': '%25',
318
+ '/': '%2F',
319
+ ':': '%3A',
320
+ '?': '%3F',
321
+ '#': '%23',
322
+ ' ': '%20'
323
+ };
275
324
  function underNamespace(namespace, marker, id) {
276
325
  const ns = namespace?.trim();
277
326
  if (!ns)
278
327
  return undefined;
279
- const v = String(id);
280
- /* 표준이 요구하는 「성분 하나」가 깨진다 — 구분자가 든 값은 만들지 않는다. */
281
- if (!v || v.includes('/') || v.includes(':'))
328
+ const raw = String(id);
329
+ if (!raw)
282
330
  return undefined;
331
+ const v = raw.replace(STRUCTURE_BREAKING, c => PERCENT_ENCODED[c]);
283
332
  if (/^https?:\/\/[^/\s]+/.test(ns))
284
333
  return `${ns.replace(/\/+$/, '')}/${marker}/${v}`;
285
334
  /*
@@ -447,7 +496,30 @@ const CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
447
496
  * 합계만 본다 — 프리픽스가 몇 자리인지는 GS1 이 회사마다 다르게 배정하므로 우리가 알 수 없다.
448
497
  * 합계는 키 종류가 정한다: SGTIN 13(GTIN-14 의 표시자+13자리) · SSCC 17 · GRAI 12 · GDTI 12.
449
498
  */
450
- const GS1_KEY_DIGITS = { sgtin: 13, sscc: 17, grai: 12, gdti: 12 };
499
+ /*
500
+ * 키마다 「숫자 마디의 자릿수 합」 — 이 표에 없는 키는 아래 함수가 **아무 말도 하지 않는다.**
501
+ *
502
+ * ── lgtin 이 빠져 있었다 (2026-09-04, plant 레인 실측) ──────────────────────
503
+ * `urn:epc:class:lgtin:880.01.LOT-A` 는 합이 5 인데 그냥 지났고, 같은 값을 sgtin 으로 적으면
504
+ * 거절됐다. **하필 로트로 관리하는 자재가 쓰는 종류다** — 원자재·화학·식품이 다 여기다.
505
+ *
506
+ * 규칙은 이미 이 파일의 `lgtinClass` 주석에 적혀 있었다("두 숫자 마디의 자릿수 합은 13", EPC TDS
507
+ * 2.1.0 §6.4.1). 규칙을 알고, 검사도 있고, **표에 키만 없었다.**
508
+ *
509
+ * 셋째 마디(로트)는 숫자가 아니다 — 아래 함수가 앞의 두 마디만 세므로 그대로 맞다.
510
+ *
511
+ * ── sgln 도 같이 없었다 ────────────────────────────────────────────────────
512
+ * 로트를 찾다가 봤다. GLN 은 검사숫자를 뺀 두 마디 합이 12 라 `grai`·`gdti` 와 같은 규칙인데
513
+ * 표에 없었다.
514
+ *
515
+ * 값이 흐르고 있는지는 재봤다: **아직 안 흐른다.** 접수면에 자리가 있고(`RefLocation.gs1Id`)
516
+ * 매핑도 그것을 옮기는데, plant 마스터의 자리 표에는 그 컬럼 자체가 없다(`properties` 안에도
517
+ * 없다 — 0건). 그러니 이 키를 더해서 **지금 거절되는 값은 없다.** 오는 날에 맞아 있으려고 둔다.
518
+ *
519
+ * `giai`(개별 자산)는 일부러 없다 — 자산 참조 길이가 가변이어서 **합이 고정되지 않는다**(전체
520
+ * 30자리 이내라는 상한만 있다). 자릿수로 판정할 수 없는 키를 표에 넣으면 맞는 값을 거절한다.
521
+ */
522
+ const GS1_KEY_DIGITS = { sgtin: 13, lgtin: 13, sscc: 17, grai: 12, gdti: 12, sgln: 12 };
451
523
  /**
452
524
  * 그 GS1 키의 자리 수가 맞나 — 어긋나면 왜인지 말한다.
453
525
  *
@@ -19,6 +19,34 @@ export interface OperationalIngestOptions {
19
19
  /** 레코드에 시각이 없을 때 쓸 값 — 주지 않으면 그 레코드를 거부한다. */
20
20
  defaultEventTime?: string;
21
21
  }
22
+ /**
23
+ * **객체 안쪽의 모양** — `object`·`object[]` 자리가 계약의 타입을 실제로 보게 한다.
24
+ *
25
+ * ── 왜 필요한가 (2026-09-04 실측, 인티그레이션 레인이 찾음) ────────────────
26
+ * `materialActual: 'object[]'` 은 **객체 배열이면 지나갔다.** 그래서 틀린 이름이 저널에 앉았다.
27
+ *
28
+ * 계약의 이름 definitionId
29
+ * 온 것 materialDefinitionId
30
+ *
31
+ * 적히나 예 — 저널에 그 줄이 그대로 있다
32
+ * 읽히나 아니오 — 계약의 타입으로 읽으면 undefined 다
33
+ *
34
+ * 거절도 경고도 없었다. **같은 계약의 ERP 경로는 이것을 잡는다**(§`erp.ts` 의 자재 줄 검사).
35
+ * 한 계약 안에 검사가 두 벌이고 한쪽만 봤다.
36
+ *
37
+ * 이름을 맞춰 달라고 원본에 청하는 것으로는 다음에 또 조용해진다 — 다음 원본이 `use: 'out'` 같은
38
+ * 것을 보내면 같은 일이 난다(실제로 그런 시드가 있었다: 계약에 `out` 이라는 낱말이 없다).
39
+ */
40
+ export interface ObjectShape {
41
+ /** 반드시 있어야 하는 안쪽 필드 — 비어 있으면 그 줄을 거절한다. */
42
+ required?: readonly string[];
43
+ /** 안쪽 필드의 형 — 선언한 것만 본다. */
44
+ fields?: Readonly<Record<string, 'string' | 'number' | 'boolean'>>;
45
+ /** 낱말이 정해진 안쪽 필드 — 계약에 없는 낱말을 거절한다. */
46
+ enums?: Readonly<Record<string, readonly string[]>>;
47
+ /** 0 보다 커야 하는 수 — 「모른다」는 **비워서** 말한다(0 으로 말하지 않는다). */
48
+ positive?: readonly string[];
49
+ }
22
50
  /**
23
51
  * 이 레코드가 어느 운영 사실인가 — **라우팅 판정을 한 곳에 둔다**(소비처가 각자 짐작하지 않게).
24
52
  *
@@ -34,7 +34,59 @@
34
34
  * 모르는 필드는 **조용히 버리지 않고 거부한다.** `taskID` 처럼 한 글자 틀린 이름은 통과시키면 영원히
35
35
  * 보이지 않는 손실이 된다(이 문에는 아직 옛 발신자가 없어 호환 부담도 없다).
36
36
  */
37
- import { OP_EVENT, DISPOSITION_DECISION } from "./contract.js";
37
+ import { OP_EVENT, DISPOSITION_DECISION, MATERIAL_ACTUAL_USE } from "./contract.js";
38
+ /**
39
+ * 객체 한 줄이 선언된 안쪽 모양을 지키나 — **선언하지 않은 자리는 보지 않는다.**
40
+ *
41
+ * 순수하다. 그래서 「어떤 줄이 거절되나」를 값만 바꿔 가며 잴 수 있다.
42
+ *
43
+ * ── 「모른다」를 0 으로 말하지 않는다 ───────────────────────────────────────
44
+ * `positive` 에 든 수는 0 보다 커야 한다. 라벨에 수량이 없는 것이 정상인 현장이 있는데(한 통·한
45
+ * 팔레트), 그때 **비워서** 말한다 — 0 으로 실으면 받는 쪽에서 「0개」와 「모른다」가 같아지고,
46
+ * 소요량 집계가 조용히 틀린다.
47
+ *
48
+ * 없는 필드는 `required` 가 아니면 그냥 없는 것이다. 이 함수가 채워 주지 않는다.
49
+ */
50
+ function shapeErrors(where, row, shape) {
51
+ if (!shape)
52
+ return [];
53
+ const errors = [];
54
+ for (const f of shape.required ?? []) {
55
+ const v = row[f];
56
+ const empty = v === undefined || v === null || (typeof v === 'string' && !v.trim());
57
+ if (empty)
58
+ errors.push(`${where}.${f} 가 없다 — 계약이 요구하는 이름이다`);
59
+ }
60
+ for (const [f, t] of Object.entries(shape.fields ?? {})) {
61
+ const v = row[f];
62
+ if (v === undefined || v === null)
63
+ continue;
64
+ if (t === 'number' && (typeof v !== 'number' || !Number.isFinite(v))) {
65
+ errors.push(`${where}.${f} 가 수가 아니다: ${JSON.stringify(v)}`);
66
+ }
67
+ else if (t !== 'number' && typeof v !== t) {
68
+ errors.push(`${where}.${f} 가 ${t} 가 아니다: ${JSON.stringify(v)}`);
69
+ }
70
+ }
71
+ for (const [f, allowed] of Object.entries(shape.enums ?? {})) {
72
+ const v = row[f];
73
+ if (v === undefined || v === null)
74
+ continue;
75
+ if (!allowed.includes(String(v))) {
76
+ errors.push(`${where}.${f} 에 계약에 없는 낱말이 왔다: ${JSON.stringify(v)} (${allowed.join(' · ')})`);
77
+ }
78
+ }
79
+ for (const f of shape.positive ?? []) {
80
+ const v = row[f];
81
+ /* 없는 것은 「모른다」다 — 여기서 막지 않는다. 위 `required` 가 필요하면 거기서 막는다. */
82
+ if (v === undefined || v === null)
83
+ continue;
84
+ if (typeof v !== 'number' || !(v > 0)) {
85
+ errors.push(`${where}.${f} 가 0 이하다: ${JSON.stringify(v)} — 모르는 수량은 0 이 아니라 비워서 말한다`);
86
+ }
87
+ }
88
+ return errors;
89
+ }
38
90
  /**
39
91
  * 닫아 둔 낱말과 그 이유.
40
92
  * · 작업 상태 — 성과 폴드가 `completed`·`in-progress` 로 갈린다(`kpi-fold`).
@@ -72,6 +124,27 @@ const SPECS = {
72
124
  startedAtSimMs: 'number', outcome: 'string', priority: 'number', startTime: 'string', endTime: 'string',
73
125
  materialActual: 'object[]', recordTime: 'string'
74
126
  },
127
+ /*
128
+ * **자재 줄의 안쪽** — 계약의 `MaterialActual` 그대로다.
129
+ *
130
+ * `definitionId` 를 요구하는 이유: 이 이름이 없으면 그 줄이 무엇을 가리키는지 아무도 모른다.
131
+ * 실제로 `materialDefinitionId` 로 온 줄이 저널에 앉았고, 계약의 타입으로 읽으면 비어 있었다.
132
+ *
133
+ * `quantity` 를 `positive` 에 두는 이유: 「모른다」는 **비워서** 말한다. 0 으로 실으면 받는
134
+ * 쪽에서 「0개」와 구별되지 않고, 소요량 집계가 조용히 틀린다.
135
+ *
136
+ * `uom` 은 요구하지 않는다 — 계약에서 선택이다(§`MaterialActual`). ERP 경로는 요구하는데
137
+ * 그것은 **그 경로의 규칙**이다(정산에 단위 없는 수량을 쓸 수 없다). 커널이 그 규칙을 통째로
138
+ * 물려받으면 단위를 모르는 현장의 사실이 통째로 거절된다.
139
+ */
140
+ shapes: {
141
+ materialActual: {
142
+ required: ['definitionId', 'use'],
143
+ fields: { definitionId: 'string', lotId: 'string', quantity: 'number', uom: 'string' },
144
+ enums: { use: MATERIAL_ACTUAL_USE },
145
+ positive: ['quantity']
146
+ }
147
+ },
75
148
  enums: {
76
149
  status: TASK_STATUS,
77
150
  intent: ['transport', 'process', 'dwell'],
@@ -428,6 +501,7 @@ export function ingestOperationalRecords(records, opts) {
428
501
  errors.push(`${kind}.${name} 가 객체가 아니다: ${JSON.stringify(v)}`);
429
502
  break;
430
503
  }
504
+ errors.push(...shapeErrors(`${kind}.${name}`, v, spec.shapes?.[name]));
431
505
  data[name] = { ...v };
432
506
  break;
433
507
  }
@@ -436,6 +510,7 @@ export function ingestOperationalRecords(records, opts) {
436
510
  errors.push(`${kind}.${name} 가 객체 배열이 아니다: ${JSON.stringify(v)}`);
437
511
  break;
438
512
  }
513
+ v.forEach((x, i) => errors.push(...shapeErrors(`${kind}.${name}[${i}]`, x, spec.shapes?.[name])));
439
514
  data[name] = v.map(x => ({ ...x }));
440
515
  break;
441
516
  }
@@ -50,6 +50,7 @@ __export(index_exports, {
50
50
  GUARD_PRAGMA: () => GUARD_PRAGMA,
51
51
  ILMD_ATTR: () => ILMD_ATTR,
52
52
  LOCATION_SATURATION_NEAR: () => LOCATION_SATURATION_NEAR,
53
+ MATERIAL_ACTUAL_USE: () => MATERIAL_ACTUAL_USE,
53
54
  MATERIAL_PROPERTY: () => MATERIAL_PROPERTY,
54
55
  MES_BIZSTEP: () => MES_BIZSTEP,
55
56
  MES_COMMANDS: () => MES_COMMANDS,
@@ -95,6 +96,7 @@ __export(index_exports, {
95
96
  checkSequenceRun: () => checkSequenceRun,
96
97
  classClosure: () => classClosure,
97
98
  classIdentifierViolation: () => classIdentifierViolation,
99
+ classUri: () => classUri,
98
100
  commandFromSpec: () => commandFromSpec,
99
101
  commandSpecGaps: () => commandSpecGaps,
100
102
  commandTypeOf: () => commandTypeOf,
@@ -620,8 +622,8 @@ function dueStatusOf(x, nowIso) {
620
622
  if (!Number.isFinite(due) || !Number.isFinite(now)) return void 0;
621
623
  return now > due ? "late" : "on-time";
622
624
  }
623
- function subLotIdOf(classUri, location) {
624
- return `${classUri}@${location}`;
625
+ function subLotIdOf(classUri2, location) {
626
+ return `${classUri2}@${location}`;
625
627
  }
626
628
  function itemKeyOf(item) {
627
629
  return item.subLotId ?? item.epc;
@@ -1032,6 +1034,7 @@ function isFailureStatus(value) {
1032
1034
  function isPlannedStopStatus(value) {
1033
1035
  return normalizeEquipmentStatus(value) === "planned-stop";
1034
1036
  }
1037
+ var MATERIAL_ACTUAL_USE = ["consumed", "produced"];
1035
1038
  var CMD = {
1036
1039
  orderHold: "order.hold",
1037
1040
  orderResume: "order.resume",
@@ -2791,14 +2794,27 @@ function gdtiUri(companyPrefix, docType, serial) {
2791
2794
  function objectUri(namespace, objId) {
2792
2795
  return underNamespace(namespace, "obj", objId);
2793
2796
  }
2797
+ function classUri(namespace, classId) {
2798
+ return underNamespace(namespace, "class", classId);
2799
+ }
2794
2800
  function bizTransactionUri(namespace, transId) {
2795
2801
  return underNamespace(namespace, "bt", transId);
2796
2802
  }
2803
+ var STRUCTURE_BREAKING = /[%/:?# ]/g;
2804
+ var PERCENT_ENCODED = {
2805
+ "%": "%25",
2806
+ "/": "%2F",
2807
+ ":": "%3A",
2808
+ "?": "%3F",
2809
+ "#": "%23",
2810
+ " ": "%20"
2811
+ };
2797
2812
  function underNamespace(namespace, marker, id) {
2798
2813
  const ns = namespace?.trim();
2799
2814
  if (!ns) return void 0;
2800
- const v = String(id);
2801
- if (!v || v.includes("/") || v.includes(":")) return void 0;
2815
+ const raw = String(id);
2816
+ if (!raw) return void 0;
2817
+ const v = raw.replace(STRUCTURE_BREAKING, (c) => PERCENT_ENCODED[c]);
2802
2818
  if (/^https?:\/\/[^/\s]+/.test(ns)) return `${ns.replace(/\/+$/, "")}/${marker}/${v}`;
2803
2819
  if (/^urn:epc(global)?:/.test(ns)) return void 0;
2804
2820
  if (/^urn:[^:\s]+/.test(ns)) return `${ns.replace(/:+$/, "")}:${marker}:${v}`;
@@ -2877,7 +2893,7 @@ var EPC_CLASS_PREFIXES = ["urn:epc:idpat:", "urn:epc:class:"];
2877
2893
  var DL_CANONICAL = "https://id.gs1.org/";
2878
2894
  var CBV_URL_CLASS = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*class\/[^/\s]+$/;
2879
2895
  var CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
2880
- var GS1_KEY_DIGITS = { sgtin: 13, sscc: 17, grai: 12, gdti: 12 };
2896
+ var GS1_KEY_DIGITS = { sgtin: 13, lgtin: 13, sscc: 17, grai: 12, gdti: 12, sgln: 12 };
2881
2897
  function gs1KeyDigitViolation(uri) {
2882
2898
  if (!uri) return void 0;
2883
2899
  const m = /^urn:epc:(?:id|idpat|class):([a-z]+):([^:]+)$/.exec(uri);
@@ -3257,6 +3273,39 @@ function readEpochMs(raw) {
3257
3273
  }
3258
3274
 
3259
3275
  // src/operational-ingest.ts
3276
+ function shapeErrors(where, row, shape) {
3277
+ if (!shape) return [];
3278
+ const errors = [];
3279
+ for (const f of shape.required ?? []) {
3280
+ const v = row[f];
3281
+ const empty = v === void 0 || v === null || typeof v === "string" && !v.trim();
3282
+ if (empty) errors.push(`${where}.${f} \uAC00 \uC5C6\uB2E4 \u2014 \uACC4\uC57D\uC774 \uC694\uAD6C\uD558\uB294 \uC774\uB984\uC774\uB2E4`);
3283
+ }
3284
+ for (const [f, t] of Object.entries(shape.fields ?? {})) {
3285
+ const v = row[f];
3286
+ if (v === void 0 || v === null) continue;
3287
+ if (t === "number" && (typeof v !== "number" || !Number.isFinite(v))) {
3288
+ errors.push(`${where}.${f} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3289
+ } else if (t !== "number" && typeof v !== t) {
3290
+ errors.push(`${where}.${f} \uAC00 ${t} \uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3291
+ }
3292
+ }
3293
+ for (const [f, allowed] of Object.entries(shape.enums ?? {})) {
3294
+ const v = row[f];
3295
+ if (v === void 0 || v === null) continue;
3296
+ if (!allowed.includes(String(v))) {
3297
+ errors.push(`${where}.${f} \uC5D0 \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uB0B1\uB9D0\uC774 \uC654\uB2E4: ${JSON.stringify(v)} (${allowed.join(" \xB7 ")})`);
3298
+ }
3299
+ }
3300
+ for (const f of shape.positive ?? []) {
3301
+ const v = row[f];
3302
+ if (v === void 0 || v === null) continue;
3303
+ if (typeof v !== "number" || !(v > 0)) {
3304
+ errors.push(`${where}.${f} \uAC00 0 \uC774\uD558\uB2E4: ${JSON.stringify(v)} \u2014 \uBAA8\uB974\uB294 \uC218\uB7C9\uC740 0 \uC774 \uC544\uB2C8\uB77C \uBE44\uC6CC\uC11C \uB9D0\uD55C\uB2E4`);
3305
+ }
3306
+ }
3307
+ return errors;
3308
+ }
3260
3309
  var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
3261
3310
  var EQUIPMENT_STATUS2 = ["idle", "busy", "down", "setup", "planned-stop"];
3262
3311
  var PERSON_STATUS = ["idle", "busy"];
@@ -3293,6 +3342,27 @@ var SPECS = {
3293
3342
  materialActual: "object[]",
3294
3343
  recordTime: "string"
3295
3344
  },
3345
+ /*
3346
+ * **자재 줄의 안쪽** — 계약의 `MaterialActual` 그대로다.
3347
+ *
3348
+ * `definitionId` 를 요구하는 이유: 이 이름이 없으면 그 줄이 무엇을 가리키는지 아무도 모른다.
3349
+ * 실제로 `materialDefinitionId` 로 온 줄이 저널에 앉았고, 계약의 타입으로 읽으면 비어 있었다.
3350
+ *
3351
+ * `quantity` 를 `positive` 에 두는 이유: 「모른다」는 **비워서** 말한다. 0 으로 실으면 받는
3352
+ * 쪽에서 「0개」와 구별되지 않고, 소요량 집계가 조용히 틀린다.
3353
+ *
3354
+ * `uom` 은 요구하지 않는다 — 계약에서 선택이다(§`MaterialActual`). ERP 경로는 요구하는데
3355
+ * 그것은 **그 경로의 규칙**이다(정산에 단위 없는 수량을 쓸 수 없다). 커널이 그 규칙을 통째로
3356
+ * 물려받으면 단위를 모르는 현장의 사실이 통째로 거절된다.
3357
+ */
3358
+ shapes: {
3359
+ materialActual: {
3360
+ required: ["definitionId", "use"],
3361
+ fields: { definitionId: "string", lotId: "string", quantity: "number", uom: "string" },
3362
+ enums: { use: MATERIAL_ACTUAL_USE },
3363
+ positive: ["quantity"]
3364
+ }
3365
+ },
3296
3366
  enums: {
3297
3367
  status: TASK_STATUS,
3298
3368
  intent: ["transport", "process", "dwell"],
@@ -3675,6 +3745,7 @@ function ingestOperationalRecords(records, opts) {
3675
3745
  errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3676
3746
  break;
3677
3747
  }
3748
+ errors.push(...shapeErrors(`${kind}.${name}`, v, spec.shapes?.[name]));
3678
3749
  data[name] = { ...v };
3679
3750
  break;
3680
3751
  }
@@ -3683,6 +3754,9 @@ function ingestOperationalRecords(records, opts) {
3683
3754
  errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3684
3755
  break;
3685
3756
  }
3757
+ v.forEach(
3758
+ (x, i) => errors.push(...shapeErrors(`${kind}.${name}[${i}]`, x, spec.shapes?.[name]))
3759
+ );
3686
3760
  data[name] = v.map((x) => ({ ...x }));
3687
3761
  break;
3688
3762
  }
@@ -4233,6 +4307,7 @@ function commandSpecGaps(specs, command) {
4233
4307
  GUARD_PRAGMA,
4234
4308
  ILMD_ATTR,
4235
4309
  LOCATION_SATURATION_NEAR,
4310
+ MATERIAL_ACTUAL_USE,
4236
4311
  MATERIAL_PROPERTY,
4237
4312
  MES_BIZSTEP,
4238
4313
  MES_COMMANDS,
@@ -4278,6 +4353,7 @@ function commandSpecGaps(specs, command) {
4278
4353
  checkSequenceRun,
4279
4354
  classClosure,
4280
4355
  classIdentifierViolation,
4356
+ classUri,
4281
4357
  commandFromSpec,
4282
4358
  commandSpecGaps,
4283
4359
  commandTypeOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Operations domain contract — the standard vocabulary that producers and readers agree on (EPCIS 2.0/GS1, ISA-95, IEC 61850/ISO 50001). Types, guards, validation. No state, no engine.",
5
5
  "type": "module",
6
6
  "main": "./dist-cjs/index.cjs",