@operato/ops-contract 0.9.3 → 0.9.5

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.
@@ -3172,6 +3172,38 @@ export declare const EQUIPMENT_STATUS: readonly ["busy", "setup", "down", "idle"
3172
3172
  export type EquipmentStatus = (typeof EQUIPMENT_STATUS)[number];
3173
3173
  /** 선언된 상태인가. */
3174
3174
  export declare function isEquipmentStatus(value: unknown): value is EquipmentStatus;
3175
+ /**
3176
+ * **ISA-95 의 네 자원** — 「무엇이 자원인가」를 한 자리에서 말한다.
3177
+ *
3178
+ * ── 왜 필요한가 (MES 레인이 물음, 2026-09-05) ──────────────────────────────
3179
+ * 계약이 네 자원을 여러 곳에서 다루는데, **넷이라고 말하는 값이 없었다.** 그래서 소비처가 저마다
3180
+ * 손으로 든다 — 자원 하나에 걸린 것을 한자리에 보이는 화면(360)이 종류마다 무엇을 물을지 정하려면
3181
+ * 그 목록이 필요하고, 지금은 화면마다 적게 된다.
3182
+ *
3183
+ * 이미 갈라지기 시작했다: `CapacityAxis`(§`capacity.ts`)가 넷을 손으로 들고 있다.
3184
+ * `EQUIPMENT_STATUS` 가 정확히 같은 자리였다 — 계약에 있는데 소비처가 따로 선언하고 있었다.
3185
+ *
3186
+ * ── 왜 넷만인가 ────────────────────────────────────────────────────────────
3187
+ * 표준이 넷이라고 말한다(ISA-95 Part 1 §5: Personnel · Equipment · Material · Physical Asset).
3188
+ * 자리·공정·로트를 여기 섞고 싶은 힘이 있는데 — 화면이 그것들도 보여 주므로 — **섞으면 이 상수가
3189
+ * ISA-95 를 말하지 않게 된다.** 그러면 다음 사람이 이것을 근거로 쓸 수 없다.
3190
+ *
3191
+ * 자리 자원을 **담는 곳**이다. 자원이 아니다
3192
+ * 로트 자재의 **개체**다. 자재 종류와 다른 층이다
3193
+ * 공정 자원을 **쓰는 일**이다
3194
+ *
3195
+ * 화면이 그 셋도 필요하면 다른 이름으로 든다. 이 목록은 「무엇이 자원인가」에만 답한다.
3196
+ *
3197
+ * ── `CapacityAxis` 와 다르다 ───────────────────────────────────────────────
3198
+ * 그것은 **제약이 걸린 축**이고(무엇을 늘려야 하는가), 자리가 들어간다 — 자리가 모자라도 못 돌기
3199
+ * 때문이다. 자재는 없다(늘린다고 지금 나오는 것이 아니다). 둘은 겹치지만 같지 않다.
3200
+ *
3201
+ * 합치면 한 값이 두 물음에 답하게 되고, 그러면 어느 쪽 뜻으로 읽어야 하는지 알 수 없다.
3202
+ */
3203
+ export declare const RESOURCE_KIND: readonly ["equipment", "personnel", "material", "asset"];
3204
+ export type ResourceKind = (typeof RESOURCE_KIND)[number];
3205
+ /** 선언된 자원 종류인가. */
3206
+ export declare function isResourceKind(value: unknown): value is ResourceKind;
3175
3207
  /**
3176
3208
  * 원본의 낱말을 우리 상태로 옮긴다 — **모르면 `undefined` 다.**
3177
3209
  *
@@ -3269,11 +3301,26 @@ export interface QualityDelta {
3269
3301
  * 표준 매핑은 커널이 이미 적어 두었다 — `MaterialDefinitionID` → `definitionId`,
3270
3302
  * `MaterialUse` → `use`. 그 이름을 그대로 쓴다.
3271
3303
  */
3304
+ /**
3305
+ * **실적의 자재 쓰임** — 표준 `MaterialUse`. 들어갔나 나왔나 둘이다.
3306
+ *
3307
+ * 공정 **명세**의 쓰임(`OperationMaterial.use`)은 셋이다 — 거기에는 `consumable` 이 있다(쓰이지만
3308
+ * 제품에 남지 않는 것: 세척수·윤활유). **실적에는 그 낱말이 없다** — 일어난 일을 적는 자리이고,
3309
+ * 소모품이 실제로 들어간 것은 `consumed` 다.
3310
+ *
3311
+ * 둘을 한 어휘로 묶지 않는다. 묶으면 실적에 `consumable` 이 올 수 있게 되고, 그때 그것이
3312
+ * 「들어갔다」인지 「쓰였지만 안 남았다」인지 받는 쪽이 정해야 한다.
3313
+ *
3314
+ * 유입 게이트가 이 상수를 가리킨다(§`operational-ingest.ts` 의 `shapes`) — 낱말을 두 곳에 적으면
3315
+ * 한쪽만 늘어나는 날 게이트가 계약에 없는 낱말을 통과시킨다.
3316
+ */
3317
+ export declare const MATERIAL_ACTUAL_USE: readonly ["consumed", "produced"];
3318
+ export type MaterialActualUse = (typeof MATERIAL_ACTUAL_USE)[number];
3272
3319
  export interface MaterialActual {
3273
3320
  /** 표준 `MaterialDefinitionID`. */
3274
3321
  definitionId: string;
3275
- /** 표준 `MaterialUse`. */
3276
- use: 'consumed' | 'produced';
3322
+ /** 표준 `MaterialUse` — 실적은 둘이다(§`MATERIAL_ACTUAL_USE`). */
3323
+ use: MaterialActualUse;
3277
3324
  quantity: number;
3278
3325
  uom?: string;
3279
3326
  /**
package/dist/contract.js CHANGED
@@ -1276,6 +1276,39 @@ export const EQUIPMENT_STATUS = ['busy', 'setup', 'down', 'idle', 'planned-stop'
1276
1276
  export function isEquipmentStatus(value) {
1277
1277
  return typeof value === 'string' && EQUIPMENT_STATUS.includes(value);
1278
1278
  }
1279
+ /**
1280
+ * **ISA-95 의 네 자원** — 「무엇이 자원인가」를 한 자리에서 말한다.
1281
+ *
1282
+ * ── 왜 필요한가 (MES 레인이 물음, 2026-09-05) ──────────────────────────────
1283
+ * 계약이 네 자원을 여러 곳에서 다루는데, **넷이라고 말하는 값이 없었다.** 그래서 소비처가 저마다
1284
+ * 손으로 든다 — 자원 하나에 걸린 것을 한자리에 보이는 화면(360)이 종류마다 무엇을 물을지 정하려면
1285
+ * 그 목록이 필요하고, 지금은 화면마다 적게 된다.
1286
+ *
1287
+ * 이미 갈라지기 시작했다: `CapacityAxis`(§`capacity.ts`)가 넷을 손으로 들고 있다.
1288
+ * `EQUIPMENT_STATUS` 가 정확히 같은 자리였다 — 계약에 있는데 소비처가 따로 선언하고 있었다.
1289
+ *
1290
+ * ── 왜 넷만인가 ────────────────────────────────────────────────────────────
1291
+ * 표준이 넷이라고 말한다(ISA-95 Part 1 §5: Personnel · Equipment · Material · Physical Asset).
1292
+ * 자리·공정·로트를 여기 섞고 싶은 힘이 있는데 — 화면이 그것들도 보여 주므로 — **섞으면 이 상수가
1293
+ * ISA-95 를 말하지 않게 된다.** 그러면 다음 사람이 이것을 근거로 쓸 수 없다.
1294
+ *
1295
+ * 자리 자원을 **담는 곳**이다. 자원이 아니다
1296
+ * 로트 자재의 **개체**다. 자재 종류와 다른 층이다
1297
+ * 공정 자원을 **쓰는 일**이다
1298
+ *
1299
+ * 화면이 그 셋도 필요하면 다른 이름으로 든다. 이 목록은 「무엇이 자원인가」에만 답한다.
1300
+ *
1301
+ * ── `CapacityAxis` 와 다르다 ───────────────────────────────────────────────
1302
+ * 그것은 **제약이 걸린 축**이고(무엇을 늘려야 하는가), 자리가 들어간다 — 자리가 모자라도 못 돌기
1303
+ * 때문이다. 자재는 없다(늘린다고 지금 나오는 것이 아니다). 둘은 겹치지만 같지 않다.
1304
+ *
1305
+ * 합치면 한 값이 두 물음에 답하게 되고, 그러면 어느 쪽 뜻으로 읽어야 하는지 알 수 없다.
1306
+ */
1307
+ export const RESOURCE_KIND = ['equipment', 'personnel', 'material', 'asset'];
1308
+ /** 선언된 자원 종류인가. */
1309
+ export function isResourceKind(value) {
1310
+ return typeof value === 'string' && RESOURCE_KIND.includes(value);
1311
+ }
1279
1312
  /**
1280
1313
  * 원본의 낱말을 우리 상태로 옮긴다 — **모르면 `undefined` 다.**
1281
1314
  *
@@ -1318,6 +1351,31 @@ export function isFailureStatus(value) {
1318
1351
  export function isPlannedStopStatus(value) {
1319
1352
  return normalizeEquipmentStatus(value) === 'planned-stop';
1320
1353
  }
1354
+ /**
1355
+ * 실제로 들어가고 나온 자재 한 줄 — ISA-95 `OpMaterialActualType` 의 부분집합.
1356
+ *
1357
+ * ── 왜 이름 있는 타입인가 (2026-08-31) ────────────────────────────────────
1358
+ * 같은 데이터가 네 곳에 각각 인라인으로 적혀 있었다(작업 상태 둘 · 커널 · ERP). ERP 쪽은 필드명까지
1359
+ * 달랐다(`materialDefinitionId` · `direction`). 같은 데이터를 여러 이름으로 부르면 옮기는 코드가
1360
+ * 생기고, 그 코드가 언젠가 어긋난다.
1361
+ *
1362
+ * 표준 매핑은 커널이 이미 적어 두었다 — `MaterialDefinitionID` → `definitionId`,
1363
+ * `MaterialUse` → `use`. 그 이름을 그대로 쓴다.
1364
+ */
1365
+ /**
1366
+ * **실적의 자재 쓰임** — 표준 `MaterialUse`. 들어갔나 나왔나 둘이다.
1367
+ *
1368
+ * 공정 **명세**의 쓰임(`OperationMaterial.use`)은 셋이다 — 거기에는 `consumable` 이 있다(쓰이지만
1369
+ * 제품에 남지 않는 것: 세척수·윤활유). **실적에는 그 낱말이 없다** — 일어난 일을 적는 자리이고,
1370
+ * 소모품이 실제로 들어간 것은 `consumed` 다.
1371
+ *
1372
+ * 둘을 한 어휘로 묶지 않는다. 묶으면 실적에 `consumable` 이 올 수 있게 되고, 그때 그것이
1373
+ * 「들어갔다」인지 「쓰였지만 안 남았다」인지 받는 쪽이 정해야 한다.
1374
+ *
1375
+ * 유입 게이트가 이 상수를 가리킨다(§`operational-ingest.ts` 의 `shapes`) — 낱말을 두 곳에 적으면
1376
+ * 한쪽만 늘어나는 날 게이트가 계약에 없는 낱말을 통과시킨다.
1377
+ */
1378
+ export const MATERIAL_ACTUAL_USE = ['consumed', 'produced'];
1321
1379
  // ── Command 채널 어휘 — 트윈의 "행위(act)" 면 (prescriptive/트랜잭션 프론트엔드) ──
1322
1380
  // 코어 공통: order.hold/resume(할당 보류). 도메인: order.release(즉시 투입) 등은 handleCommand 로.
1323
1381
  export const CMD = {
@@ -170,6 +170,25 @@ export type TwinRelationTarget = {
170
170
  kind: 'external';
171
171
  entity: string;
172
172
  };
173
+ /**
174
+ * **그 관계가 가리키는 것이 언제의 일인가** — 화면의 판단이 여기서 갈린다.
175
+ *
176
+ * ── 왜 필요한가 (MES 레인이 실측으로 올림, 2026-09-05) ─────────────────────
177
+ * 설비 하나에 걸린 것을 한자리에 보이면, 들어오는 관계라도 뜻이 갈린다.
178
+ *
179
+ * past 이미 일어났다 — 세워도 안 바뀐다 실적 · 상태 구간
180
+ * future 아직이다 — 세우면 이것이 걸린다 정비 계획 · 지시
181
+ * standing 시간이 없는 사실 등급 · 붙박인 자리 · 종류
182
+ *
183
+ * 한 묶음에 담으면 **「걸린 것이 열」인데 아홉이 지난 일인 경우와 아홉이 앞의 일인 경우가 같아
184
+ * 보인다.** 「이 설비를 세워도 되나」의 답이 정반대인 두 상황이다.
185
+ *
186
+ * ── 왜 `via` 로 가르면 안 되나 ──────────────────────────────────────────────
187
+ * `via` 는 i18n 키다. **「이것이 지난 일인가」를 말하지 않는다.** 화면이 키 이름을 보고 짐작하게
188
+ * 되고, 축이 하나 늘 때 한 화면만 따라간다. 읽는 곳은 제품마다여도 **무엇을 아는지는 한 벌**이어야
189
+ * 한다.
190
+ */
191
+ export type RelationTense = 'past' | 'future' | 'standing';
173
192
  export interface TwinRelationInfo {
174
193
  /** 출발 축. */
175
194
  from: string;
@@ -183,7 +202,27 @@ export interface TwinRelationInfo {
183
202
  via: string;
184
203
  /** 없을 수 있는 참조인가. `false` 인데 비면 **끊어진 참조**로 보고한다. */
185
204
  optional?: boolean;
205
+ /**
206
+ * 언제의 일인가. **없으면 「선언이 말하지 않았다」** — `standing` 이 아니다.
207
+ *
208
+ * 기본값을 두고 싶은 힘이 있는데 두면 거짓이 된다. 예를 들어 `tasks.resourceRef → equipment` 는
209
+ * **그 작업이 끝났는지에 따라** 과거이기도 미래이기도 하다. 선언이 답할 수 없는 자리이고, 거기
210
+ * `standing` 을 씌우면 「시간이 없는 사실」이라고 잘못 말하게 된다.
211
+ *
212
+ * 그러니 말할 수 있는 관계만 말한다. 안 말한 것을 화면이 시간으로 묶으려 하면 안 된다.
213
+ */
214
+ tense?: RelationTense;
186
215
  }
216
+ /**
217
+ * 그 관계가 언제의 일인가 — **모르면 `undefined`.**
218
+ *
219
+ * 소비처가 저마다 `?? 'standing'` 을 적지 않게 한 자리에 둔다. 그리고 그 기본값을 **주지 않는
220
+ * 것**이 이 함수의 요점이다 — 선언이 말하지 않은 것을 「시간이 없는 사실」로 바꾸면 화면이 모르는
221
+ * 것을 아는 척한다.
222
+ *
223
+ * 시간으로 묶는 화면은 `undefined` 인 것을 **묶지 않고 그대로 둔다.**
224
+ */
225
+ export declare function tenseOf(r: Pick<TwinRelationInfo, 'tense'> | undefined): RelationTense | undefined;
187
226
  /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
188
227
  export declare const TWIN_RELATIONS: TwinRelationInfo[];
189
228
  export type TwinObservationKind =
@@ -149,6 +149,18 @@ export const TWIN_AXES = [
149
149
  { axis: 'demandWindows', path: 'energy.closed', idField: 'startMs', label: 'twin.axis.demandWindows', kind: 'instance',
150
150
  source: 'state', historical: true, standardClass: {}, systems: ['ems'] }
151
151
  ];
152
+ /**
153
+ * 그 관계가 언제의 일인가 — **모르면 `undefined`.**
154
+ *
155
+ * 소비처가 저마다 `?? 'standing'` 을 적지 않게 한 자리에 둔다. 그리고 그 기본값을 **주지 않는
156
+ * 것**이 이 함수의 요점이다 — 선언이 말하지 않은 것을 「시간이 없는 사실」로 바꾸면 화면이 모르는
157
+ * 것을 아는 척한다.
158
+ *
159
+ * 시간으로 묶는 화면은 `undefined` 인 것을 **묶지 않고 그대로 둔다.**
160
+ */
161
+ export function tenseOf(r) {
162
+ return r?.tense;
163
+ }
152
164
  /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
153
165
  export const TWIN_RELATIONS = [
154
166
  /* 자리가 속한 구역 — **board 밖**(호스트의 `TwinArea`). 해소는 호스트가 한다. */
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
  }
@@ -42,3 +42,38 @@ export interface Mtbf {
42
42
  * 사라지고, 그러면 이 수가 실제보다 좋아진다 — 자료가 나쁠수록 수가 좋아지는 쪽은 두지 않는다.
43
43
  */
44
44
  export declare function computeMtbf(input: MtbfInput): Mtbf;
45
+ export interface MttrInput {
46
+ /** 어느 설비의. */
47
+ equipmentId: string;
48
+ /** 그 설비의 상태 구간들. 순서는 상관없다 — 여기서 시각으로 세운다. */
49
+ periods: readonly ReliabilityPeriod[];
50
+ }
51
+ /** 낼 수 없는 이유 — 「0」이나 「무한」으로 답하지 않는다. */
52
+ export type MttrMissing =
53
+ /** 고장이 없다 — 나눌 수가 0이다. 고칠 일이 없었던 것이지 복구가 빠른 것이 아니다. */
54
+ 'no-failure'
55
+ /** 모르는 상태 낱말이 있다 — 조용히 빼면 고장이 사라진다. */
56
+ | 'unknown-status'
57
+ /** 길이가 없거나 시각을 읽을 수 없는 구간이 있다. */
58
+ | 'invalid-period';
59
+ export interface Mttr {
60
+ equipmentId: string;
61
+ /** 평균 복구 시간(ms). **낼 수 없으면 없다.** */
62
+ mttrMs?: number;
63
+ /** 분자 — `down` 구간 길이의 합. */
64
+ downMs: number;
65
+ /** 분모 — **고장으로 들어간 횟수**(이어진 `down` 은 한 번). MTBF 와 같은 수다. */
66
+ failures: number;
67
+ /** 받은 `down` 구간의 수 — `failures` 와 다르면 이어진 것이 있었다. */
68
+ downPeriods: number;
69
+ /** 옮길 수 없던 상태 낱말들 — 무엇이 왔는지 말한다. */
70
+ unknownStatuses: string[];
71
+ missing: MttrMissing[];
72
+ }
73
+ /**
74
+ * 이 설비의 MTTR.
75
+ *
76
+ * **모르는 상태를 만나면 답하지 않는다.** MTBF 와 같은 이유다 — 그 구간이 고장이었다면 빼는 순간
77
+ * 복구 시간이 짧아 보인다. 자료가 나쁠수록 수가 좋아지는 쪽은 두지 않는다.
78
+ */
79
+ export declare function computeMttr(input: MttrInput): Mttr;
@@ -1,5 +1,8 @@
1
1
  /*
2
- * **MTBF** — ISO 22400-2. 고장 사이에 얼마나 돌았나.
2
+ * **MTBF · MTTR** — ISO 22400-2. 고장 사이에 얼마나 돌았나 · 고치는 데 얼마나 걸렸나.
3
+ *
4
+ * 표준이 둘을 **짝으로** 정의한다. 한쪽만 두면 소비처가 나머지를 자기 식으로 짓고, 그러면 같은
5
+ * 설비가 화면마다 다른 수를 갖는다 — 이 파일이 애초에 존재하는 이유가 그것이다.
3
6
  *
4
7
  * ── 왜 구간을 받나 (집계 값이 아니라) ────────────────────────────────────────
5
8
  * `computeMtbf(가동시간, 고장횟수)` 로 두면 **「고장 횟수」를 부르는 쪽이 센다.** 그러면 세는 방법이
@@ -42,15 +45,22 @@ const at = (t) => (typeof t === 'string' ? Date.parse(t) : NaN);
42
45
  * **모르는 상태를 만나면 답하지 않는다.** 빼고 계산하면 그 구간이 고장이었을 때 고장이 조용히
43
46
  * 사라지고, 그러면 이 수가 실제보다 좋아진다 — 자료가 나쁠수록 수가 좋아지는 쪽은 두지 않는다.
44
47
  */
45
- export function computeMtbf(input) {
46
- const { equipmentId } = input;
47
- const missing = [];
48
+ /**
49
+ * 구간을 훑어 두 산출식이 쓸 것을 다 센다.
50
+ *
51
+ * **두 함수가 따로 걸으면 안 된다** — 「이어진 `down` 은 한 번」 규칙이 두 곳에 살게 되고, 한 곳만
52
+ * 고쳐지는 날 같은 설비의 MTBF 와 MTTR 이 서로 어긋난다. 그 둘은 같은 고장 횟수로 나눈 값이므로
53
+ * 어긋나면 둘 중 하나가 반드시 틀렸는데, 어느 쪽인지 아무도 모른다.
54
+ */
55
+ function scanPeriods(periods) {
48
56
  const unknown = new Set();
49
57
  /* 시각으로 세운다 — 받은 순서를 믿지 않는다. 이어짐 판정이 순서에 걸린다. */
50
- const sorted = [...(input.periods ?? [])]
58
+ const sorted = [...(periods ?? [])]
51
59
  .map(p => ({ status: normalizeEquipmentStatus(p?.status), said: String(p?.status ?? ''), from: at(p?.from), to: at(p?.to) }))
52
60
  .sort((a, b) => a.from - b.from);
53
61
  let operatingMs = 0;
62
+ /** 고친 시간의 합 — MTTR 의 분자. */
63
+ let downMs = 0;
54
64
  let failures = 0;
55
65
  let downPeriods = 0;
56
66
  let invalid = false;
@@ -74,6 +84,7 @@ export function computeMtbf(input) {
74
84
  if (p.status === 'busy')
75
85
  operatingMs += p.to - p.from;
76
86
  if (p.status === 'down') {
87
+ downMs += p.to - p.from;
77
88
  downPeriods++;
78
89
  if (!wasDown)
79
90
  failures++;
@@ -83,23 +94,61 @@ export function computeMtbf(input) {
83
94
  wasDown = false;
84
95
  }
85
96
  }
86
- if (invalid)
97
+ return { operatingMs, downMs, failures, downPeriods, unknownStatuses: [...unknown], invalid };
98
+ }
99
+ /**
100
+ * 이 설비의 MTBF.
101
+ *
102
+ * **모르는 상태를 만나면 답하지 않는다.** 빼고 계산하면 그 구간이 고장이었을 때 고장이 조용히
103
+ * 사라지고, 그러면 이 수가 실제보다 좋아진다 — 자료가 나쁠수록 수가 좋아지는 쪽은 두지 않는다.
104
+ */
105
+ export function computeMtbf(input) {
106
+ const s = scanPeriods(input.periods);
107
+ const missing = [];
108
+ if (s.invalid)
87
109
  missing.push('invalid-period');
88
- if (unknown.size)
110
+ if (s.unknownStatuses.length)
89
111
  missing.push('unknown-status');
90
- if (operatingMs <= 0)
112
+ if (s.operatingMs <= 0)
91
113
  missing.push('no-operating-time');
92
- if (failures === 0)
114
+ if (s.failures === 0)
115
+ missing.push('no-failure');
116
+ const out = {
117
+ equipmentId: input.equipmentId,
118
+ operatingMs: s.operatingMs,
119
+ failures: s.failures,
120
+ downPeriods: s.downPeriods,
121
+ unknownStatuses: s.unknownStatuses,
122
+ missing
123
+ };
124
+ if (missing.length)
125
+ return out;
126
+ return { ...out, mtbfMs: s.operatingMs / s.failures };
127
+ }
128
+ /**
129
+ * 이 설비의 MTTR.
130
+ *
131
+ * **모르는 상태를 만나면 답하지 않는다.** MTBF 와 같은 이유다 — 그 구간이 고장이었다면 빼는 순간
132
+ * 복구 시간이 짧아 보인다. 자료가 나쁠수록 수가 좋아지는 쪽은 두지 않는다.
133
+ */
134
+ export function computeMttr(input) {
135
+ const s = scanPeriods(input.periods);
136
+ const missing = [];
137
+ if (s.invalid)
138
+ missing.push('invalid-period');
139
+ if (s.unknownStatuses.length)
140
+ missing.push('unknown-status');
141
+ if (s.failures === 0)
93
142
  missing.push('no-failure');
94
143
  const out = {
95
- equipmentId,
96
- operatingMs,
97
- failures,
98
- downPeriods,
99
- unknownStatuses: [...unknown],
144
+ equipmentId: input.equipmentId,
145
+ downMs: s.downMs,
146
+ failures: s.failures,
147
+ downPeriods: s.downPeriods,
148
+ unknownStatuses: s.unknownStatuses,
100
149
  missing
101
150
  };
102
151
  if (missing.length)
103
152
  return out;
104
- return { ...out, mtbfMs: operatingMs / failures };
153
+ return { ...out, mttrMs: s.downMs / s.failures };
105
154
  }
@@ -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,
@@ -61,6 +62,7 @@ __export(index_exports, {
61
62
  OP_PARAM: () => OP_PARAM,
62
63
  ORDER_TERMINAL_STATUS: () => ORDER_TERMINAL_STATUS,
63
64
  PRIORITY_UNSET: () => PRIORITY_UNSET,
65
+ RESOURCE_KIND: () => RESOURCE_KIND,
64
66
  RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
65
67
  SCHEDULE_STATUS: () => SCHEDULE_STATUS,
66
68
  TWIN_AXES: () => TWIN_AXES,
@@ -95,12 +97,14 @@ __export(index_exports, {
95
97
  checkSequenceRun: () => checkSequenceRun,
96
98
  classClosure: () => classClosure,
97
99
  classIdentifierViolation: () => classIdentifierViolation,
100
+ classUri: () => classUri,
98
101
  commandFromSpec: () => commandFromSpec,
99
102
  commandSpecGaps: () => commandSpecGaps,
100
103
  commandTypeOf: () => commandTypeOf,
101
104
  commandsOf: () => commandsOf,
102
105
  computeFirstPassYield: () => computeFirstPassYield,
103
106
  computeMtbf: () => computeMtbf,
107
+ computeMttr: () => computeMttr,
104
108
  computeOee: () => computeOee,
105
109
  conversionFactorOf: () => conversionFactorOf,
106
110
  criterionSaysNothing: () => criterionSaysNothing,
@@ -149,6 +153,7 @@ __export(index_exports, {
149
153
  isOperationalRecord: () => isOperationalRecord,
150
154
  isOrderTerminal: () => isOrderTerminal,
151
155
  isPlannedStopStatus: () => isPlannedStopStatus,
156
+ isResourceKind: () => isResourceKind,
152
157
  isTransformationRecord: () => isTransformationRecord,
153
158
  isoDurationHours: () => isoDurationHours,
154
159
  itemKeyOf: () => itemKeyOf,
@@ -201,6 +206,7 @@ __export(index_exports, {
201
206
  ssccUri: () => ssccUri,
202
207
  stateFieldsOf: () => stateFieldsOf,
203
208
  subLotIdOf: () => subLotIdOf,
209
+ tenseOf: () => tenseOf,
204
210
  testEvidenceGaps: () => testEvidenceGaps,
205
211
  testPassedAt: () => testPassedAt,
206
212
  transactionEvent: () => transactionEvent,
@@ -620,8 +626,8 @@ function dueStatusOf(x, nowIso) {
620
626
  if (!Number.isFinite(due) || !Number.isFinite(now)) return void 0;
621
627
  return now > due ? "late" : "on-time";
622
628
  }
623
- function subLotIdOf(classUri, location) {
624
- return `${classUri}@${location}`;
629
+ function subLotIdOf(classUri2, location) {
630
+ return `${classUri2}@${location}`;
625
631
  }
626
632
  function itemKeyOf(item) {
627
633
  return item.subLotId ?? item.epc;
@@ -1022,6 +1028,10 @@ var EQUIPMENT_STATUS = ["busy", "setup", "down", "idle", "planned-stop"];
1022
1028
  function isEquipmentStatus(value) {
1023
1029
  return typeof value === "string" && EQUIPMENT_STATUS.includes(value);
1024
1030
  }
1031
+ var RESOURCE_KIND = ["equipment", "personnel", "material", "asset"];
1032
+ function isResourceKind(value) {
1033
+ return typeof value === "string" && RESOURCE_KIND.includes(value);
1034
+ }
1025
1035
  function normalizeEquipmentStatus(value) {
1026
1036
  const said = typeof value === "string" ? value.trim().toLowerCase() : "";
1027
1037
  return isEquipmentStatus(said) ? said : void 0;
@@ -1032,6 +1042,7 @@ function isFailureStatus(value) {
1032
1042
  function isPlannedStopStatus(value) {
1033
1043
  return normalizeEquipmentStatus(value) === "planned-stop";
1034
1044
  }
1045
+ var MATERIAL_ACTUAL_USE = ["consumed", "produced"];
1035
1046
  var CMD = {
1036
1047
  orderHold: "order.hold",
1037
1048
  orderResume: "order.resume",
@@ -1857,6 +1868,9 @@ var TWIN_AXES = [
1857
1868
  systems: ["ems"]
1858
1869
  }
1859
1870
  ];
1871
+ function tenseOf(r) {
1872
+ return r?.tense;
1873
+ }
1860
1874
  var TWIN_RELATIONS = [
1861
1875
  /* 자리가 속한 구역 — **board 밖**(호스트의 `TwinArea`). 해소는 호스트가 한다. */
1862
1876
  { from: "locations", field: "parentId", target: { kind: "external", entity: "space.area" }, via: "twin.rel.area", optional: true },
@@ -2791,14 +2805,27 @@ function gdtiUri(companyPrefix, docType, serial) {
2791
2805
  function objectUri(namespace, objId) {
2792
2806
  return underNamespace(namespace, "obj", objId);
2793
2807
  }
2808
+ function classUri(namespace, classId) {
2809
+ return underNamespace(namespace, "class", classId);
2810
+ }
2794
2811
  function bizTransactionUri(namespace, transId) {
2795
2812
  return underNamespace(namespace, "bt", transId);
2796
2813
  }
2814
+ var STRUCTURE_BREAKING = /[%/:?# ]/g;
2815
+ var PERCENT_ENCODED = {
2816
+ "%": "%25",
2817
+ "/": "%2F",
2818
+ ":": "%3A",
2819
+ "?": "%3F",
2820
+ "#": "%23",
2821
+ " ": "%20"
2822
+ };
2797
2823
  function underNamespace(namespace, marker, id) {
2798
2824
  const ns = namespace?.trim();
2799
2825
  if (!ns) return void 0;
2800
- const v = String(id);
2801
- if (!v || v.includes("/") || v.includes(":")) return void 0;
2826
+ const raw = String(id);
2827
+ if (!raw) return void 0;
2828
+ const v = raw.replace(STRUCTURE_BREAKING, (c) => PERCENT_ENCODED[c]);
2802
2829
  if (/^https?:\/\/[^/\s]+/.test(ns)) return `${ns.replace(/\/+$/, "")}/${marker}/${v}`;
2803
2830
  if (/^urn:epc(global)?:/.test(ns)) return void 0;
2804
2831
  if (/^urn:[^:\s]+/.test(ns)) return `${ns.replace(/:+$/, "")}:${marker}:${v}`;
@@ -2877,7 +2904,7 @@ var EPC_CLASS_PREFIXES = ["urn:epc:idpat:", "urn:epc:class:"];
2877
2904
  var DL_CANONICAL = "https://id.gs1.org/";
2878
2905
  var CBV_URL_CLASS = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*class\/[^/\s]+$/;
2879
2906
  var CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
2880
- var GS1_KEY_DIGITS = { sgtin: 13, sscc: 17, grai: 12, gdti: 12 };
2907
+ var GS1_KEY_DIGITS = { sgtin: 13, lgtin: 13, sscc: 17, grai: 12, gdti: 12, sgln: 12 };
2881
2908
  function gs1KeyDigitViolation(uri) {
2882
2909
  if (!uri) return void 0;
2883
2910
  const m = /^urn:epc:(?:id|idpat|class):([a-z]+):([^:]+)$/.exec(uri);
@@ -3257,6 +3284,39 @@ function readEpochMs(raw) {
3257
3284
  }
3258
3285
 
3259
3286
  // src/operational-ingest.ts
3287
+ function shapeErrors(where, row, shape) {
3288
+ if (!shape) return [];
3289
+ const errors = [];
3290
+ for (const f of shape.required ?? []) {
3291
+ const v = row[f];
3292
+ const empty = v === void 0 || v === null || typeof v === "string" && !v.trim();
3293
+ if (empty) errors.push(`${where}.${f} \uAC00 \uC5C6\uB2E4 \u2014 \uACC4\uC57D\uC774 \uC694\uAD6C\uD558\uB294 \uC774\uB984\uC774\uB2E4`);
3294
+ }
3295
+ for (const [f, t] of Object.entries(shape.fields ?? {})) {
3296
+ const v = row[f];
3297
+ if (v === void 0 || v === null) continue;
3298
+ if (t === "number" && (typeof v !== "number" || !Number.isFinite(v))) {
3299
+ errors.push(`${where}.${f} \uAC00 \uC218\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3300
+ } else if (t !== "number" && typeof v !== t) {
3301
+ errors.push(`${where}.${f} \uAC00 ${t} \uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3302
+ }
3303
+ }
3304
+ for (const [f, allowed] of Object.entries(shape.enums ?? {})) {
3305
+ const v = row[f];
3306
+ if (v === void 0 || v === null) continue;
3307
+ if (!allowed.includes(String(v))) {
3308
+ errors.push(`${where}.${f} \uC5D0 \uACC4\uC57D\uC5D0 \uC5C6\uB294 \uB0B1\uB9D0\uC774 \uC654\uB2E4: ${JSON.stringify(v)} (${allowed.join(" \xB7 ")})`);
3309
+ }
3310
+ }
3311
+ for (const f of shape.positive ?? []) {
3312
+ const v = row[f];
3313
+ if (v === void 0 || v === null) continue;
3314
+ if (typeof v !== "number" || !(v > 0)) {
3315
+ 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`);
3316
+ }
3317
+ }
3318
+ return errors;
3319
+ }
3260
3320
  var TASK_STATUS = ["created", "assigned", "in-progress", "completed"];
3261
3321
  var EQUIPMENT_STATUS2 = ["idle", "busy", "down", "setup", "planned-stop"];
3262
3322
  var PERSON_STATUS = ["idle", "busy"];
@@ -3293,6 +3353,27 @@ var SPECS = {
3293
3353
  materialActual: "object[]",
3294
3354
  recordTime: "string"
3295
3355
  },
3356
+ /*
3357
+ * **자재 줄의 안쪽** — 계약의 `MaterialActual` 그대로다.
3358
+ *
3359
+ * `definitionId` 를 요구하는 이유: 이 이름이 없으면 그 줄이 무엇을 가리키는지 아무도 모른다.
3360
+ * 실제로 `materialDefinitionId` 로 온 줄이 저널에 앉았고, 계약의 타입으로 읽으면 비어 있었다.
3361
+ *
3362
+ * `quantity` 를 `positive` 에 두는 이유: 「모른다」는 **비워서** 말한다. 0 으로 실으면 받는
3363
+ * 쪽에서 「0개」와 구별되지 않고, 소요량 집계가 조용히 틀린다.
3364
+ *
3365
+ * `uom` 은 요구하지 않는다 — 계약에서 선택이다(§`MaterialActual`). ERP 경로는 요구하는데
3366
+ * 그것은 **그 경로의 규칙**이다(정산에 단위 없는 수량을 쓸 수 없다). 커널이 그 규칙을 통째로
3367
+ * 물려받으면 단위를 모르는 현장의 사실이 통째로 거절된다.
3368
+ */
3369
+ shapes: {
3370
+ materialActual: {
3371
+ required: ["definitionId", "use"],
3372
+ fields: { definitionId: "string", lotId: "string", quantity: "number", uom: "string" },
3373
+ enums: { use: MATERIAL_ACTUAL_USE },
3374
+ positive: ["quantity"]
3375
+ }
3376
+ },
3296
3377
  enums: {
3297
3378
  status: TASK_STATUS,
3298
3379
  intent: ["transport", "process", "dwell"],
@@ -3675,6 +3756,7 @@ function ingestOperationalRecords(records, opts) {
3675
3756
  errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4\uAC00 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3676
3757
  break;
3677
3758
  }
3759
+ errors.push(...shapeErrors(`${kind}.${name}`, v, spec.shapes?.[name]));
3678
3760
  data[name] = { ...v };
3679
3761
  break;
3680
3762
  }
@@ -3683,6 +3765,9 @@ function ingestOperationalRecords(records, opts) {
3683
3765
  errors.push(`${kind}.${name} \uAC00 \uAC1D\uCCB4 \uBC30\uC5F4\uC774 \uC544\uB2C8\uB2E4: ${JSON.stringify(v)}`);
3684
3766
  break;
3685
3767
  }
3768
+ v.forEach(
3769
+ (x, i) => errors.push(...shapeErrors(`${kind}.${name}[${i}]`, x, spec.shapes?.[name]))
3770
+ );
3686
3771
  data[name] = v.map((x) => ({ ...x }));
3687
3772
  break;
3688
3773
  }
@@ -3996,12 +4081,11 @@ function rolledThroughputYield(steps) {
3996
4081
 
3997
4082
  // src/reliability.ts
3998
4083
  var at = (t) => typeof t === "string" ? Date.parse(t) : NaN;
3999
- function computeMtbf(input) {
4000
- const { equipmentId } = input;
4001
- const missing = [];
4084
+ function scanPeriods(periods) {
4002
4085
  const unknown = /* @__PURE__ */ new Set();
4003
- const sorted = [...input.periods ?? []].map((p) => ({ status: normalizeEquipmentStatus(p?.status), said: String(p?.status ?? ""), from: at(p?.from), to: at(p?.to) })).sort((a, b) => a.from - b.from);
4086
+ const sorted = [...periods ?? []].map((p) => ({ status: normalizeEquipmentStatus(p?.status), said: String(p?.status ?? ""), from: at(p?.from), to: at(p?.to) })).sort((a, b) => a.from - b.from);
4004
4087
  let operatingMs = 0;
4088
+ let downMs = 0;
4005
4089
  let failures = 0;
4006
4090
  let downPeriods = 0;
4007
4091
  let invalid = false;
@@ -4018,6 +4102,7 @@ function computeMtbf(input) {
4018
4102
  }
4019
4103
  if (p.status === "busy") operatingMs += p.to - p.from;
4020
4104
  if (p.status === "down") {
4105
+ downMs += p.to - p.from;
4021
4106
  downPeriods++;
4022
4107
  if (!wasDown) failures++;
4023
4108
  wasDown = true;
@@ -4025,20 +4110,42 @@ function computeMtbf(input) {
4025
4110
  wasDown = false;
4026
4111
  }
4027
4112
  }
4028
- if (invalid) missing.push("invalid-period");
4029
- if (unknown.size) missing.push("unknown-status");
4030
- if (operatingMs <= 0) missing.push("no-operating-time");
4031
- if (failures === 0) missing.push("no-failure");
4113
+ return { operatingMs, downMs, failures, downPeriods, unknownStatuses: [...unknown], invalid };
4114
+ }
4115
+ function computeMtbf(input) {
4116
+ const s = scanPeriods(input.periods);
4117
+ const missing = [];
4118
+ if (s.invalid) missing.push("invalid-period");
4119
+ if (s.unknownStatuses.length) missing.push("unknown-status");
4120
+ if (s.operatingMs <= 0) missing.push("no-operating-time");
4121
+ if (s.failures === 0) missing.push("no-failure");
4122
+ const out = {
4123
+ equipmentId: input.equipmentId,
4124
+ operatingMs: s.operatingMs,
4125
+ failures: s.failures,
4126
+ downPeriods: s.downPeriods,
4127
+ unknownStatuses: s.unknownStatuses,
4128
+ missing
4129
+ };
4130
+ if (missing.length) return out;
4131
+ return { ...out, mtbfMs: s.operatingMs / s.failures };
4132
+ }
4133
+ function computeMttr(input) {
4134
+ const s = scanPeriods(input.periods);
4135
+ const missing = [];
4136
+ if (s.invalid) missing.push("invalid-period");
4137
+ if (s.unknownStatuses.length) missing.push("unknown-status");
4138
+ if (s.failures === 0) missing.push("no-failure");
4032
4139
  const out = {
4033
- equipmentId,
4034
- operatingMs,
4035
- failures,
4036
- downPeriods,
4037
- unknownStatuses: [...unknown],
4140
+ equipmentId: input.equipmentId,
4141
+ downMs: s.downMs,
4142
+ failures: s.failures,
4143
+ downPeriods: s.downPeriods,
4144
+ unknownStatuses: s.unknownStatuses,
4038
4145
  missing
4039
4146
  };
4040
4147
  if (missing.length) return out;
4041
- return { ...out, mtbfMs: operatingMs / failures };
4148
+ return { ...out, mttrMs: s.downMs / s.failures };
4042
4149
  }
4043
4150
 
4044
4151
  // src/erp.ts
@@ -4233,6 +4340,7 @@ function commandSpecGaps(specs, command) {
4233
4340
  GUARD_PRAGMA,
4234
4341
  ILMD_ATTR,
4235
4342
  LOCATION_SATURATION_NEAR,
4343
+ MATERIAL_ACTUAL_USE,
4236
4344
  MATERIAL_PROPERTY,
4237
4345
  MES_BIZSTEP,
4238
4346
  MES_COMMANDS,
@@ -4244,6 +4352,7 @@ function commandSpecGaps(specs, command) {
4244
4352
  OP_PARAM,
4245
4353
  ORDER_TERMINAL_STATUS,
4246
4354
  PRIORITY_UNSET,
4355
+ RESOURCE_KIND,
4247
4356
  RETIRED_VOCABULARY,
4248
4357
  SCHEDULE_STATUS,
4249
4358
  TWIN_AXES,
@@ -4278,12 +4387,14 @@ function commandSpecGaps(specs, command) {
4278
4387
  checkSequenceRun,
4279
4388
  classClosure,
4280
4389
  classIdentifierViolation,
4390
+ classUri,
4281
4391
  commandFromSpec,
4282
4392
  commandSpecGaps,
4283
4393
  commandTypeOf,
4284
4394
  commandsOf,
4285
4395
  computeFirstPassYield,
4286
4396
  computeMtbf,
4397
+ computeMttr,
4287
4398
  computeOee,
4288
4399
  conversionFactorOf,
4289
4400
  criterionSaysNothing,
@@ -4332,6 +4443,7 @@ function commandSpecGaps(specs, command) {
4332
4443
  isOperationalRecord,
4333
4444
  isOrderTerminal,
4334
4445
  isPlannedStopStatus,
4446
+ isResourceKind,
4335
4447
  isTransformationRecord,
4336
4448
  isoDurationHours,
4337
4449
  itemKeyOf,
@@ -4384,6 +4496,7 @@ function commandSpecGaps(specs, command) {
4384
4496
  ssccUri,
4385
4497
  stateFieldsOf,
4386
4498
  subLotIdOf,
4499
+ tenseOf,
4387
4500
  testEvidenceGaps,
4388
4501
  testPassedAt,
4389
4502
  transactionEvent,
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.5",
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",