@operato/ops-contract 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3256,6 +3256,8 @@ export interface TwinModelDef {
3256
3256
  /** parallelism = 동시 처리 수(LocationState.parallelism 참조). capacity 는 저장 용량. */
3257
3257
  locations: (EffectivePeriod & {
3258
3258
  id: string;
3259
+ name?: string;
3260
+ gs1Id?: string;
3259
3261
  type: string;
3260
3262
  capacity: number;
3261
3263
  parallelism?: number;
@@ -3269,6 +3271,7 @@ export interface TwinModelDef {
3269
3271
  */
3270
3272
  equipment: (EffectivePeriod & {
3271
3273
  id: string;
3274
+ name?: string;
3272
3275
  kind: string;
3273
3276
  homeLocation: string;
3274
3277
  identity?: string;
@@ -133,6 +133,7 @@ export interface DurationVariability {
133
133
  export interface OperationDef extends EffectivePeriod {
134
134
  key: string;
135
135
  label: string;
136
+ name?: string;
136
137
  intent: OperationIntent;
137
138
  /** 오퍼레이션이 수행되는 자리 타입(LocationTypeDef.key). 커널이 locationByType 로 위치 해소. */
138
139
  locationType?: string;
@@ -60,6 +60,8 @@ const ASSET_STATUS = ['idle', 'in-use'];
60
60
  const SPECS = {
61
61
  task: {
62
62
  eventType: OP_EVENT.task,
63
+ match: ['taskId'],
64
+ matchOrder: 50,
63
65
  identity: 'taskId',
64
66
  /* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
65
67
  required: ['taskId', 'kind', 'status'],
@@ -79,6 +81,8 @@ const SPECS = {
79
81
  },
80
82
  equipment: {
81
83
  eventType: OP_EVENT.equipment,
84
+ match: ['moverId'],
85
+ matchOrder: 20,
82
86
  identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
83
87
  required: ['moverId', 'kind', 'status'], // vocabulary-guard: allow 위와 같은 이유
84
88
  fields: {
@@ -94,6 +98,8 @@ const SPECS = {
94
98
  },
95
99
  person: {
96
100
  eventType: OP_EVENT.person,
101
+ match: ['personId'],
102
+ matchOrder: 30,
97
103
  identity: 'personId',
98
104
  required: ['personId', 'status'],
99
105
  fields: {
@@ -104,6 +110,8 @@ const SPECS = {
104
110
  },
105
111
  asset: {
106
112
  eventType: OP_EVENT.asset,
113
+ match: ['assetId'],
114
+ matchOrder: 40,
107
115
  identity: 'assetId',
108
116
  required: ['assetId', 'status'],
109
117
  fields: {
@@ -114,6 +122,8 @@ const SPECS = {
114
122
  },
115
123
  order: {
116
124
  eventType: OP_EVENT.order,
125
+ match: ['orderId'],
126
+ matchOrder: 60,
117
127
  identity: 'orderId',
118
128
  /*
119
129
  * 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
@@ -129,6 +139,8 @@ const SPECS = {
129
139
  },
130
140
  quality: {
131
141
  eventType: OP_EVENT.quality,
142
+ match: ['moverId', 'good'],
143
+ matchOrder: 15,
132
144
  identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드
133
145
  /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
134
146
  required: ['moverId', 'good', 'goodCount', 'scrapCount'], // vocabulary-guard: allow
@@ -170,6 +182,8 @@ const SPECS = {
170
182
  */
171
183
  'equipment-period': {
172
184
  eventType: OP_EVENT.equipmentPeriod,
185
+ match: ['moverId', 'status', 'from', 'to'],
186
+ matchOrder: 10,
173
187
  identity: 'moverId', // vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
174
188
  required: ['moverId', 'status', 'from', 'to'], // vocabulary-guard: allow 위와 같은 이유
175
189
  fields: {
@@ -196,6 +210,8 @@ const SPECS = {
196
210
  */
197
211
  disposition: {
198
212
  eventType: OP_EVENT.disposition,
213
+ match: ['subjectId', 'decision'],
214
+ matchOrder: 80,
199
215
  identity: 'subjectId',
200
216
  /* 무엇을 어떻게 하기로 했나 — 둘 중 하나가 없으면 그 결정은 아무 데도 붙지 못한다. */
201
217
  required: ['subjectId', 'decision'],
@@ -207,6 +223,8 @@ const SPECS = {
207
223
  },
208
224
  test: {
209
225
  eventType: OP_EVENT.test,
226
+ match: ['testableObjectId'],
227
+ matchOrder: 70,
210
228
  identity: 'testableObjectId',
211
229
  /* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
212
230
  required: ['testableObjectId', 'specId'],
@@ -234,12 +252,16 @@ const SPECS = {
234
252
  */
235
253
  complete: {
236
254
  eventType: OP_EVENT.complete,
255
+ match: ['completeAxis'],
256
+ matchOrder: 100,
237
257
  identity: 'completeAxis',
238
258
  required: ['completeAxis', 'since'],
239
259
  fields: { completeAxis: 'string', since: 'string', recordTime: 'string' }
240
260
  },
241
261
  observation: {
242
262
  eventType: OP_EVENT.observation,
263
+ match: ['locationId', 'propertyId'],
264
+ matchOrder: 90,
243
265
  identity: 'locationId',
244
266
  /* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
245
267
  required: ['locationId', 'propertyId'],
@@ -268,68 +290,34 @@ export function operationalKindOf(record) {
268
290
  if (!record || typeof record !== 'object')
269
291
  return undefined;
270
292
  const r = record;
271
- if (r.epc !== undefined || r.meterId !== undefined || r.equipmentId !== undefined)
272
- return undefined;
273
- const has = (k) => typeof r[k] === 'string' && r[k].trim().length > 0;
274
- /* vocabulary-guard: allow 저널 와이어 필드로 가른다 */
275
- /*
276
- * 구간이 있으면 **마감된 상태 구간**이지 시점의 전이가 아니다. 전이보다 먼저 본다 — 뒤에 두면
277
- * 사람이 되돌아가 적은 구간이 「지금 이 상태다」로 읽혀 트윈의 상태가 과거로 끌린다.
278
- */
279
- if (has('moverId') && has('from') && has('to'))
280
- return 'equipment-period';
281
- if (has('moverId'))
282
- return r.good !== undefined ? 'quality' : 'equipment';
283
- if (has('personId'))
284
- return 'person';
285
- if (has('assetId'))
286
- return 'asset';
287
- if (has('taskId'))
288
- return 'task'; // 작업이 든 `orderId` 는 소속(참조)이다
289
- if (has('orderId'))
290
- return 'order';
291
- /*
292
- * ── ★ **채널을 열고 들어오는 길을 내지 않았다** (2026-08-24) ─────────────────
293
- * `OP_EVENT.test` 를 계약에 냈는데 이 라우팅이 `testableObjectId` 를 보지 않았다. 그래서 커넥터의
294
- * 시험 결과가 **어느 통도 아니어서 조용히 버려졌다** — 거부 목록에도 남지 않았다(운영 경로를 아예
295
- * 지나지 않으므로).
296
- *
297
- * 같은 부류가 하루에 세 번 났다: `ilmd`(매핑에 자리 없음) · 사건 시각(이름 어긋남) · 그리고 이것.
298
- * **계약에 자리를 만드는 것과 그 자리로 가는 길을 내는 것은 다른 일이다.** 앞의 것만 하면 보내는
299
- * 쪽에는 「실었다」로 보이고 화면에는 「없다」로 보인다.
300
- *
301
- * 순서상 뒤에 둔다 — 시험 결과가 작업·오더를 함께 가리킬 수 있고, 그때 그것은 **그 작업의 사실**이다.
302
- */
303
- if (has('testableObjectId'))
304
- return 'test';
305
- /*
306
- * **처분** — 같은 부류가 또 났다(2026-08-30). `OP_EVENT.disposition` 을 계약에 내고 여기로 오는 길을
307
- * 내지 않아, MES 가 보내면 어느 통도 아니어서 사라졌다. 위 주석이 적은 것이 **셋째**다.
308
- *
309
- * 시험 결과 뒤에 둔다 — 처분은 판정을 가리킬 수 있고(`specId`), 그때 정체성은 처분 대상이지
310
- * 시험 대상이 아니다. 앞에 두면 판정에 딸린 처분이 시험으로 읽힌다.
311
- */
312
- if (has('subjectId') && has('decision'))
313
- return 'disposition';
314
293
  /*
315
- * ── **채널을 열고 길을 내지 않았다** (2026-08-24) ──────────────────────
316
- * `OP_EVENT.observation`(`location.measured`)을 내고 상태(`LocationState.observations`)와 조회
317
- * (`observationAt`)까지 붙였는데 **이 라우팅이 `locationId` 를 보지 않았다.** 커넥터가 방의 온습도를
318
- * 실어 보내면 「어느 운영 사실인지 모른다」로 거부됐다.
319
- *
320
- * 같은 부류를 하루에 일곱 번 만났다. 다만 이번엔 **거부되고 이유가 남았다** — 시험 결과 때는 어느
321
- * 통도 아니어서 조용히 사라졌다. 그 차이가 이것을 5분 만에 찾게 했다(§`isOperationalRecord`).
322
- *
323
- * **둘을 함께 요구한다.** `locationId` 만으로는 자리를 말하는 다른 사실과 섞인다. 관측은 「어느
324
- * 자리의 **무엇**을 쟀나」이므로 속성 없이는 담을 곳이 없다 — 그때는 받지 않는 것이 옳다.
294
+ * EPCIS·에너지와 겹치지 않게 본다: `epc`·`meterId` 있으면 그쪽 어휘이고, `equipmentId` 는 설비
295
+ * **에너지** 상태의 이름이다(운영 설비는 `moverId`).
325
296
  */
326
- if (has('locationId') && has('propertyId'))
327
- return 'observation';
328
- /* 「이 목록이 전부다」 어느 목록인지를 스스로 말하므로 다른 사실과 섞이지 않는다. */
329
- if (has('completeAxis'))
330
- return 'complete';
297
+ if (r.epc !== undefined || r.meterId !== undefined || r.equipmentId !== undefined)
298
+ return undefined;
299
+ /* 문자열은 비어 있으면 없는 것으로 본다 공백만 실은 필드는 말한 것이 아니다. */
300
+ const present = (k) => {
301
+ const v = r[k];
302
+ if (v === undefined || v === null)
303
+ return false;
304
+ return typeof v === 'string' ? v.trim().length > 0 : true;
305
+ };
306
+ for (const kind of MATCH_ORDER) {
307
+ const spec = SPECS[kind];
308
+ const fields = spec.match ?? [spec.identity];
309
+ if (fields.every(present))
310
+ return kind;
311
+ }
331
312
  return undefined;
332
313
  }
314
+ /**
315
+ * 판정 순서 — 표의 `matchOrder` 에서 한 번만 만든다.
316
+ *
317
+ * 좁은 것이 먼저다. 그리고 자원(설비·사람·자산)을 작업보다, 작업을 오더보다 먼저 본다 — 정체 필드는
318
+ * 하나만 오지 않고, 아무거나 먼저 보면 **참조를 주체로 읽는다**(설비 델타가 든 `taskId` 는 소속이다).
319
+ */
320
+ const MATCH_ORDER = Object.keys(SPECS).sort((a, b) => (SPECS[a].matchOrder ?? 1000) - (SPECS[b].matchOrder ?? 1000));
333
321
  /** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
334
322
  export function isOperationalRecord(record) {
335
323
  return operationalKindOf(record) !== undefined;
@@ -3165,6 +3165,8 @@ var ASSET_STATUS = ["idle", "in-use"];
3165
3165
  var SPECS = {
3166
3166
  task: {
3167
3167
  eventType: OP_EVENT.task,
3168
+ match: ["taskId"],
3169
+ matchOrder: 50,
3168
3170
  identity: "taskId",
3169
3171
  /* 종류가 없으면 성과를 종류별로 모을 수 없고(선언된 시간·수율이 종류로 붙는다) 지어낼 수도 없다. */
3170
3172
  required: ["taskId", "kind", "status"],
@@ -3201,6 +3203,8 @@ var SPECS = {
3201
3203
  },
3202
3204
  equipment: {
3203
3205
  eventType: OP_EVENT.equipment,
3206
+ match: ["moverId"],
3207
+ matchOrder: 20,
3204
3208
  identity: "moverId",
3205
3209
  // vocabulary-guard: allow 저널 와이어 필드(델타의 이름이 계약이다)
3206
3210
  required: ["moverId", "kind", "status"],
@@ -3227,6 +3231,8 @@ var SPECS = {
3227
3231
  },
3228
3232
  person: {
3229
3233
  eventType: OP_EVENT.person,
3234
+ match: ["personId"],
3235
+ matchOrder: 30,
3230
3236
  identity: "personId",
3231
3237
  required: ["personId", "status"],
3232
3238
  fields: {
@@ -3244,6 +3250,8 @@ var SPECS = {
3244
3250
  },
3245
3251
  asset: {
3246
3252
  eventType: OP_EVENT.asset,
3253
+ match: ["assetId"],
3254
+ matchOrder: 40,
3247
3255
  identity: "assetId",
3248
3256
  required: ["assetId", "status"],
3249
3257
  fields: {
@@ -3261,6 +3269,8 @@ var SPECS = {
3261
3269
  },
3262
3270
  order: {
3263
3271
  eventType: OP_EVENT.order,
3272
+ match: ["orderId"],
3273
+ matchOrder: 60,
3264
3274
  identity: "orderId",
3265
3275
  /*
3266
3276
  * 요청량·이행량을 **함께** 받는다. 없으면 리듀서가 진척을 0 으로 적는데(`requested ? … : 0`),
@@ -3290,6 +3300,8 @@ var SPECS = {
3290
3300
  },
3291
3301
  quality: {
3292
3302
  eventType: OP_EVENT.quality,
3303
+ match: ["moverId", "good"],
3304
+ matchOrder: 15,
3293
3305
  identity: "moverId",
3294
3306
  // vocabulary-guard: allow 저널 와이어 필드
3295
3307
  /* 누적 카운터가 없으면 OEE 가 양품률을 못 센다 — 판정 하나만으로는 비율이 나오지 않는다. */
@@ -3334,6 +3346,8 @@ var SPECS = {
3334
3346
  */
3335
3347
  "equipment-period": {
3336
3348
  eventType: OP_EVENT.equipmentPeriod,
3349
+ match: ["moverId", "status", "from", "to"],
3350
+ matchOrder: 10,
3337
3351
  identity: "moverId",
3338
3352
  // vocabulary-guard: allow 저널 와이어 필드 — 전이와 같은 이름을 쓴다
3339
3353
  required: ["moverId", "status", "from", "to"],
@@ -3368,6 +3382,8 @@ var SPECS = {
3368
3382
  */
3369
3383
  disposition: {
3370
3384
  eventType: OP_EVENT.disposition,
3385
+ match: ["subjectId", "decision"],
3386
+ matchOrder: 80,
3371
3387
  identity: "subjectId",
3372
3388
  /* 무엇을 어떻게 하기로 했나 — 둘 중 하나가 없으면 그 결정은 아무 데도 붙지 못한다. */
3373
3389
  required: ["subjectId", "decision"],
@@ -3386,6 +3402,8 @@ var SPECS = {
3386
3402
  },
3387
3403
  test: {
3388
3404
  eventType: OP_EVENT.test,
3405
+ match: ["testableObjectId"],
3406
+ matchOrder: 70,
3389
3407
  identity: "testableObjectId",
3390
3408
  /* 무엇을 어느 기준으로 시험했나 — 둘 중 하나가 없으면 그 결과는 아무 데도 붙지 못한다. */
3391
3409
  required: ["testableObjectId", "specId"],
@@ -3419,12 +3437,16 @@ var SPECS = {
3419
3437
  */
3420
3438
  complete: {
3421
3439
  eventType: OP_EVENT.complete,
3440
+ match: ["completeAxis"],
3441
+ matchOrder: 100,
3422
3442
  identity: "completeAxis",
3423
3443
  required: ["completeAxis", "since"],
3424
3444
  fields: { completeAxis: "string", since: "string", recordTime: "string" }
3425
3445
  },
3426
3446
  observation: {
3427
3447
  eventType: OP_EVENT.observation,
3448
+ match: ["locationId", "propertyId"],
3449
+ matchOrder: 90,
3428
3450
  identity: "locationId",
3429
3451
  /* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
3430
3452
  required: ["locationId", "propertyId"],
@@ -3446,19 +3468,21 @@ function operationalKindOf(record) {
3446
3468
  if (!record || typeof record !== "object") return void 0;
3447
3469
  const r = record;
3448
3470
  if (r.epc !== void 0 || r.meterId !== void 0 || r.equipmentId !== void 0) return void 0;
3449
- const has = (k) => typeof r[k] === "string" && r[k].trim().length > 0;
3450
- if (has("moverId") && has("from") && has("to")) return "equipment-period";
3451
- if (has("moverId")) return r.good !== void 0 ? "quality" : "equipment";
3452
- if (has("personId")) return "person";
3453
- if (has("assetId")) return "asset";
3454
- if (has("taskId")) return "task";
3455
- if (has("orderId")) return "order";
3456
- if (has("testableObjectId")) return "test";
3457
- if (has("subjectId") && has("decision")) return "disposition";
3458
- if (has("locationId") && has("propertyId")) return "observation";
3459
- if (has("completeAxis")) return "complete";
3471
+ const present = (k) => {
3472
+ const v = r[k];
3473
+ if (v === void 0 || v === null) return false;
3474
+ return typeof v === "string" ? v.trim().length > 0 : true;
3475
+ };
3476
+ for (const kind of MATCH_ORDER) {
3477
+ const spec = SPECS[kind];
3478
+ const fields = spec.match ?? [spec.identity];
3479
+ if (fields.every(present)) return kind;
3480
+ }
3460
3481
  return void 0;
3461
3482
  }
3483
+ var MATCH_ORDER = Object.keys(SPECS).sort(
3484
+ (a, b) => (SPECS[a].matchOrder ?? 1e3) - (SPECS[b].matchOrder ?? 1e3)
3485
+ );
3462
3486
  function isOperationalRecord(record) {
3463
3487
  return operationalKindOf(record) !== void 0;
3464
3488
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/ops-contract",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",