@operato/twin-kernel 0.7.47 → 0.7.49

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.
@@ -1464,6 +1464,14 @@ export interface StateSnapshot {
1464
1464
  transformInputsAbsent?: number;
1465
1465
  /** 씨앗이 심지 못한 참조의 수 — 원본이 말했지만 그 물품이 스냅샷에 없었다. */
1466
1466
  seedDanglingRefs?: number;
1467
+ /**
1468
+ * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
1469
+ *
1470
+ * 같은 키끼리는 구체가 상회한다. 교차는 뜻으로는 상회일 수 있으나 판정에 등급 소속이 필요하고,
1471
+ * 잘못 겹치면 자재가 조용히 사라지거나 두 배가 된다. 그래서 **둘 다 요구하고 센다** — 이 값이
1472
+ * 크면 그 숫자가 다음 작업을 정한다.
1473
+ */
1474
+ materialSpecCrossKeyOverlaps?: number;
1467
1475
  };
1468
1476
  locations: LocationState[];
1469
1477
  items: ItemState[];
@@ -70,8 +70,14 @@ export interface TwinAxisInfo {
70
70
  * 이 칸이 없던 동안 소비처는 `id`·`key`·`gtin` 을 **짐작**했고, 그래서 화면이 실재하는 항목을
71
71
  * 「식별자 없음 — 참조할 수 없는 항목」으로 보였다. 값은 있는데 가리킬 수 없다고 말한 것이다.
72
72
  * 짐작을 없애고 선언이 답한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
73
+ *
74
+ * ── 여럿을 받는다 (2026-08-22) ────────────────────────────────────────────
75
+ * 정체성이 **한 칸으로 정해지지 않는 축**이 있다. 물품이 그렇다: 직렬 물품은 `epc` 가 유일하지만,
76
+ * 비직렬 로트가 자리마다 나뉘면 개체를 구별하는 것은 `subLotId` 다(같은 로트의 두 부분은 같은 `epc`
77
+ * 를 갖는다 — §`ItemState.subLotId`). 계약이 그 규칙을 말할 수 없으면 소비처가 다시 짐작한다.
78
+ * 그래서 **차례**를 받는다: 앞에서 값이 있는 첫 칸이 이긴다.
73
79
  */
74
- idField?: string;
80
+ idField?: string | string[];
75
81
  /**
76
82
  * 이 축을 **어디서 읽나.**
77
83
  *
@@ -97,6 +97,27 @@ export const TWIN_AXES = [
97
97
  standardClass: { isa95: 'OperationsRequest', epcis: 'TransactionEvent' }, systems: LOGISTICS },
98
98
  { axis: 'tasks', label: 'twin.axis.tasks', kind: 'instance', source: 'state', historical: true,
99
99
  standardClass: { isa95: 'SegmentResponse', epcis: 'TransformationEvent' }, systems: LOGISTICS },
100
+ /*
101
+ * ── 물품이 축이 아니었다 (2026-08-22) ──────────────────────────────────────
102
+ * 트윈에서 **수가 가장 많은 것**이 물품인데(실측: 엔티티 3,611 중 2,400 · hatio-us 는 2,805) 그것을
103
+ * 가리켜 걸어 들어갈 자리가 없었다. 지도와 집약 태그에는 이미 보이는데 「모델 살펴보기」에는 문이
104
+ * 없었다 — 사용자가 트윈에 가장 자주 묻는 것이 「내 물건이 어디 있나」이므로 그것은 접근 장벽이다.
105
+ *
106
+ * 성격은 `orders`·`tasks` 와 같다: 상태에 살고, 저널에 이력이 있고, 원본이 낸 것을 트윈이 관측한다.
107
+ * 그래서 같은 조합(`instance` · `state` · `historical`)이다.
108
+ *
109
+ * ── 표준 대응 ─────────────────────────────────────────────────────────────
110
+ * ISA-95 는 `MaterialLot` 이다 — 물품은 「무슨 품목인가」(정의)가 아니라 「그 품목의 이 덩어리」이고,
111
+ * 위치·수량·부분(`MaterialSubLot`)을 그 자리가 든다. EPCIS 는 `ObjectEvent` 다: 개체가 생기고
112
+ * 관측되고 사라지는 것을 그 사건이 말한다.
113
+ *
114
+ * ── 무엇이 이 항목을 가리키나 ─────────────────────────────────────────────
115
+ * `subLotId ?? epc` 다. 짐작에 맡기면 `gtin` 으로 떨어지고, 그러면 **같은 품목의 물품 전부가 한
116
+ * 식별자로 뭉친다** — 화면이 2,400개를 몇 개로 보인다.
117
+ */
118
+ { axis: 'items', label: 'twin.axis.items', kind: 'instance', source: 'state', historical: true,
119
+ idField: ['subLotId', 'epc'],
120
+ standardClass: { isa95: 'MaterialLot', epcis: 'ObjectEvent' }, systems: LOGISTICS },
100
121
  /*
101
122
  * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
102
123
  *
@@ -163,6 +184,24 @@ export const TWIN_RELATIONS = [
163
184
  { from: 'tasks', field: 'toNode', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.at', optional: true },
164
185
  { from: 'tasks', field: 'resourceRef', target: { kind: 'axis', axis: 'equipment' }, via: 'twin.rel.by', optional: true },
165
186
  { from: 'tasks', field: 'personnel[]', target: { kind: 'axis', axis: 'persons' }, via: 'twin.rel.crew', optional: true },
187
+ /*
188
+ * 물품의 관계 (2026-08-22) — 없으면 축이 **걸어 들어갈 수 없는 목록**이 된다.
189
+ *
190
+ * `location` 은 필수다 — 물품은 언제나 어딘가에 있다(그것이 물품의 뜻이다). 나머지는 선택이다:
191
+ * 물류단위에 담기지 않은 물품, 자산에 실리지 않은 팔레트가 정상이다.
192
+ *
193
+ * `parent` 는 **물품 축을 자기 자신으로** 가리킨다(팔레트에 담긴 상자 — EPCIS `AggregationEvent`).
194
+ * `carriedBy` 는 다른 축이다 — 반복사용 자산(GRAI)이 물류단위를 실어 나른다(§`FlowItem.carriedBy`).
195
+ *
196
+ * 관계 이름은 **소문자 한 낱말**이다(`twin.rel.<name>`) — 기존 열여덟 개가 그 규율이고 시험이 지킨다.
197
+ *
198
+ * 품목(`gtin`)은 오더와 **같은 규율**이다: 자재 키가 아니라 GS1 품목 참조이므로 축을 직접 가리키지
199
+ * 않고 `external` 로 둔다. 축을 가리키게 적으면 없는 필드를 가리키는 선언이 된다.
200
+ */
201
+ { from: 'items', field: 'location', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.at' },
202
+ { from: 'items', field: 'parent', target: { kind: 'axis', axis: 'items' }, via: 'twin.rel.parent', optional: true },
203
+ { from: 'items', field: 'carriedBy', target: { kind: 'axis', axis: 'assets' }, via: 'twin.rel.asset', optional: true },
204
+ { from: 'items', field: 'gtin', target: { kind: 'external', entity: 'gs1.itemRef' }, via: 'twin.rel.item', optional: true },
166
205
  /*
167
206
  * 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
168
207
  * 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
@@ -42,8 +42,17 @@ export interface MaterialDef {
42
42
  * (커널은 어느 제품에도 기대지 않는다 — 실 시스템은 이 결함이 실재한다는 **증인**이고, 커널의 모양을
43
43
  * 정하는 것은 표준과 원칙이다.)
44
44
  *
45
- * 선택 필드로 것은 자재를 선언만 하고 쓰지 않는 정의를 막지 않으려는 것이다. **레시피가 쓰는
46
- * 자재는 이것을 반드시 선언해야 한다** `validateDomainDefinition`그때 위반으로 잡는다.
45
+ * ── 요구가 아니라 **정책**이다 (2026-08-22) ────────────────────────────────
46
+ * 예전에는 레시피가 쓰는 자재에 이것을 요구했다. 그런데 재고로 위치를 말하는 시스템에는 선언이
47
+ * 없고(그것이 WMS 계열의 정상이다), 표준도 위치를 정의가 아니라 **로트**에 둔다. 실측: 첫 실 연동에서
48
+ * 원자재 986건 중 36건만 선언돼 있어 레시피 937/1,408 이 실리지 못했다.
49
+ *
50
+ * 그래서 요구를 거두었다. **있으면** 확보 범위를 그 타입의 자리로 좁히고(현장의 정책), **없으면**
51
+ * 재고가 있는 곳에서 찾는다(품목 색인 — §`ItemStore.ofGtin`).
52
+ *
53
+ * 다만 **입고를 만들려면 자리가 필요하다** — 시뮬레이션이 자재를 내려놓을 곳을 지어낼 수는 없다.
54
+ * 선언이 없는 자재는 입고가 만들어지지 않고, 커널이 그 사실을 한 번 알린다(조용히 빠지지 않는다).
55
+ * 미러에서는 문제가 아니다: 재고는 원본이 말한다.
47
56
  */
48
57
  locationType?: string;
49
58
  }
@@ -188,6 +197,15 @@ export interface OperationDef {
188
197
  * `use` 는 표준 `MaterialUse` 를 소문자로 쓴다(우리 어휘 규약). 지금 커널이 소비하는 것은
189
198
  * `consumed`(작업이 시작되려면 있어야 하고 시작 시 빠진다)뿐이고, 나머지는 **선언만 받아 둔다** —
190
199
  * 자리가 없으면 사실이 들어오지 못한다.
200
+ *
201
+ * ── 여기에 **품목별 BOM 을 넣지 않는다** (2026-08-22) ──────────────────────
202
+ * 이 자리는 ProcessSegment 쪽이다 — 트윈 전체에 한 벌이고 **어느 품목을 만드는 중인지 모른다**
203
+ * (`claimMaterials` 는 `operationSpecs.get(t.kind)` 로 찾는다). 그래서 여기 담기는 것은 **품목과
204
+ * 무관하게 그 자리가 늘 쓰는 것**이다: 포장 필름·세척수·윤활유 같은 `consumable`, 그 공정이 품목을
205
+ * 가리지 않고 먹는 부자재.
206
+ *
207
+ * 품목마다 다른 소요는 **레시피의 태그**가 든다(§`RecipePart.operation`). 실 데이터에서 이 둘을 섞으면
208
+ * 배합 로트 하나가 자재 631종 36톤을 먹는다 — 첫 실 연동이 그 크기를 재서 알려 주었다.
191
209
  */
192
210
  materialSpecification?: OpMaterialSpecification[];
193
211
  }
@@ -229,6 +247,29 @@ export interface RecipePart {
229
247
  /** MaterialDef.key. */
230
248
  material: string;
231
249
  qty: number;
250
+ /**
251
+ * **이 투입이 들어가는 공정** — `RouteDef.steps` 의 키. 없으면 오더 착수에 확보한다.
252
+ *
253
+ * ── 왜 여기인가 (2026-08-22) ──────────────────────────────────────────────
254
+ * ISA-95 에는 자재 명세가 붙는 자리가 **둘**이고 뜻이 다르다.
255
+ *
256
+ * · `ProcessSegment` — 그 자리가 **할 수 있는 일**(능력·소요시간·필요 자원). 품목에 매이지 않는다.
257
+ * · `OperationsSegment` — 그 **품목을 만드는 절차**의 한 단계(`OperationsDefinition` 소속). 품목에 매인다.
258
+ *
259
+ * `OperationDef` 는 ProcessSegment 쪽이다 — 트윈 전체에 한 벌이고 `locationType`·소요시간·설비 종류를
260
+ * 든다. 거기에 품목별 BOM 을 붙이면 **범주 오류**다. 실 데이터가 그 크기를 보였다: 「배합」에 붙이면
261
+ * 배합을 지나는 **모든** 로트가 자재 631종 36톤을 먹는다(실제로는 그 로트 한 품목분 수 kg).
262
+ *
263
+ * 품목별 공정 소요가 사는 자리는 OperationsSegment 이고, 이 커널에서 그 범위를 가진 것은 **레시피**다.
264
+ * 그래서 태그를 레시피 투입에 둔다.
265
+ *
266
+ * **`RecipeDef.steps[]` 로 두지 않는 이유**: 공정의 순서는 `RouteDef.steps` 가 이미 소유한다. 레시피에
267
+ * 단계 목록을 또 두면 두 목록이 어긋날 수 있고, 어긋났을 때 어느 쪽이 맞는지 말할 근거가 없다.
268
+ * 태그 하나면 **순서는 라우트가, 소요는 (레시피 × 공정)이** 말한다 — 한 사실에 한 자리다.
269
+ *
270
+ * 첫 실 연동(F&B MES)의 BOM 이 (만드는 품목, 공정) 단위이고, 경로가 둘 이상인 레시피가 306/471 이다.
271
+ */
272
+ operation?: string;
232
273
  }
233
274
  /** BOM/레시피 — ISA-95 Material Consumed/Produced · EPCIS TransformationEvent(input→output). */
234
275
  export interface RecipeDef {
@@ -84,8 +84,16 @@ export function validateDomainDefinition(def) {
84
84
  v.push(`recipe '${rc.key}' material '${p.material}' qty 부정`);
85
85
  /* 레시피가 쓰는 자재는 자기 자리를 말해야 한다 — 커널은 그것을 지어내지 않는다(MaterialDef.locationType). */
86
86
  const mat = matByKey.get(p.material);
87
- if (mat && !mat.locationType)
88
- v.push(`recipe '${rc.key}' material '${p.material}' locationType 미선언`);
87
+ /*
88
+ * ── 보관처는 **요구가 아니다** (2026-08-22) ────────────────────────────────
89
+ * 예전에는 레시피가 쓰는 자재에 `locationType` 을 요구했다. 그런데 **재고로 위치를 말하는
90
+ * 시스템**에는 그 선언이 없다 — 자재에 고정된 보관처를 두지 않는 것이 WMS 계열의 정상이고,
91
+ * 표준도 위치를 정의(`MaterialDefinition`)가 아니라 로트(`MaterialLot`)에 둔다.
92
+ *
93
+ * 실측이 그 비용을 보였다: 첫 실 연동에서 원자재 986건 중 보관처가 선언된 것이 36건이었고, 그
94
+ * 요구 때문에 레시피 **937/1,408 건이 아예 실리지 못했다.** 확보는 품목 색인으로 돌므로
95
+ * (§`ItemStore.ofGtin`) 선언은 있으면 좁히는 **정책**이고 없어도 정확하다.
96
+ */
89
97
  if (mat?.locationType && !locationKeys.has(mat.locationType))
90
98
  v.push(`recipe '${rc.key}' material '${p.material}' locationType '${mat.locationType}' 미정의`);
91
99
  }
package/dist/epcis.d.ts CHANGED
@@ -346,7 +346,13 @@ export declare function transformationEvent(p: {
346
346
  bizLocation?: string;
347
347
  bizTransactionList?: BizTransactionElement[];
348
348
  } & EpcisHeaderOptions): EpcisTransformationEvent;
349
- /** 위반 문구, 또는 통과면 `undefined`. */
349
+ /**
350
+ * 그 GS1 키의 자리 수가 맞나 — 어긋나면 왜인지 말한다.
351
+ *
352
+ * `urn:epc:{id,idpat,class}:<키>:<프리픽스>.<참조>[.<직렬>]` 모양만 본다. 그 밖의 형식(도메인 기반
353
+ * 식별자 등)은 이 규약의 대상이 아니므로 아무 말도 하지 않는다.
354
+ */
355
+ export declare function gs1KeyDigitViolation(uri: string | undefined): string | undefined;
350
356
  export declare function classIdentifierViolation(epcClass: string | undefined): string | undefined;
351
357
  export declare function validateEpcisEvent(e: EpcisEvent): string[];
352
358
  export {};
package/dist/epcis.js CHANGED
@@ -334,9 +334,55 @@ const CBV_URL_CLASS = /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)*class\/[^/\s]+$/;
334
334
  /** CBV §8.3.3 — URN 이름공간 소유자가 배정. `ObjClassid` 에 콜론 불가. */
335
335
  const CBV_URN_CLASS = /^urn:[^:\s]+:(?:[^:\s]+:)*class:[^:\s]+$/;
336
336
  /** 위반 문구, 또는 통과면 `undefined`. */
337
+ /**
338
+ * **GS1 키의 자리 수 규약** — TDS 가 정한 합계다(회사 프리픽스 + 참조 = 고정 자리).
339
+ *
340
+ * ── 왜 여기서 보는가 (2026-08-22) ──────────────────────────────────────────
341
+ * 검증기가 모양(`urn:epc:idpat:sgtin:`)만 보고 **자리 수를 보지 않았다.** 실측: 12자리·14자리 SGTIN 이
342
+ * 전부 통과했다. 프리픽스를 바꾸는 작업에서 품목참조 자리 수를 맞추지 않으면 **조용히 어긋난
343
+ * 식별자가 저널에 영구히 남고**, 어느 화면도 그것을 말해 주지 않는다.
344
+ *
345
+ * 합계만 본다 — 프리픽스가 몇 자리인지는 GS1 이 회사마다 다르게 배정하므로 우리가 알 수 없다.
346
+ * 합계는 키 종류가 정한다: SGTIN 13(GTIN-14 의 표시자+13자리) · SSCC 17 · GRAI 12 · GDTI 12.
347
+ */
348
+ const GS1_KEY_DIGITS = { sgtin: 13, sscc: 17, grai: 12, gdti: 12 };
349
+ /**
350
+ * 그 GS1 키의 자리 수가 맞나 — 어긋나면 왜인지 말한다.
351
+ *
352
+ * `urn:epc:{id,idpat,class}:<키>:<프리픽스>.<참조>[.<직렬>]` 모양만 본다. 그 밖의 형식(도메인 기반
353
+ * 식별자 등)은 이 규약의 대상이 아니므로 아무 말도 하지 않는다.
354
+ */
355
+ export function gs1KeyDigitViolation(uri) {
356
+ if (!uri)
357
+ return undefined;
358
+ const m = /^urn:epc:(?:id|idpat|class):([a-z]+):([^:]+)$/.exec(uri);
359
+ if (!m)
360
+ return undefined;
361
+ const want = GS1_KEY_DIGITS[m[1]];
362
+ if (!want)
363
+ return undefined;
364
+ const parts = m[2].split('.');
365
+ if (parts.length < 2)
366
+ return `${m[1]} 형식 오류: ${uri} — 회사 프리픽스와 참조가 '.' 로 갈려야 한다`;
367
+ const [prefix, ref] = parts;
368
+ /* 패턴의 `*` 는 자리 수를 말하지 않는다 — 참조 자리가 `*` 면 합계를 셀 수 없으므로 넘어간다. */
369
+ if (ref === '*')
370
+ return undefined;
371
+ if (!/^\d+$/.test(prefix) || !/^\d+$/.test(ref)) {
372
+ return `${m[1]} 형식 오류: ${uri} — 회사 프리픽스와 참조는 숫자다`;
373
+ }
374
+ const got = prefix.length + ref.length;
375
+ if (got === want)
376
+ return undefined;
377
+ return (`${m[1]} 자리 수 오류: ${uri} — 회사 프리픽스(${prefix.length}) + 참조(${ref.length}) = ${got} 이지만 ` +
378
+ `${want} 여야 한다(GS1 TDS). 프리픽스를 바꾸면 참조 자리 수를 함께 맞춰야 한다.`);
379
+ }
337
380
  export function classIdentifierViolation(epcClass) {
338
381
  if (!epcClass)
339
382
  return `quantity epcClass 부정: ${epcClass}`;
383
+ const digits = gs1KeyDigitViolation(epcClass);
384
+ if (digits)
385
+ return `quantity epcClass 부정: ${digits}`;
340
386
  if (EPC_CLASS_PREFIXES.some(p => epcClass.startsWith(p)))
341
387
  return undefined;
342
388
  if (epcClass.startsWith(DL_CANONICAL))
@@ -349,6 +395,24 @@ export function classIdentifierViolation(epcClass) {
349
395
  }
350
396
  export function validateEpcisEvent(e) {
351
397
  const v = [];
398
+ /*
399
+ * ── 개체 식별자의 자리 수도 본다 (2026-08-22) ──────────────────────────────
400
+ * 클래스만 보고 개체를 보지 않으면, 같은 어긋남이 `epcList` 를 타고 그대로 저널에 들어간다. 판정은
401
+ * 한 규칙이므로 실리는 모든 자리에 같이 댄다(§`gs1KeyDigitViolation`).
402
+ */
403
+ for (const [field, list] of [
404
+ ['epcList', e.epcList],
405
+ ['childEPCs', e.childEPCs],
406
+ ['inputEPCList', e.inputEPCList],
407
+ ['outputEPCList', e.outputEPCList],
408
+ ['parentID', e.parentID ? [e.parentID] : undefined]
409
+ ]) {
410
+ for (const epc of list ?? []) {
411
+ const bad = gs1KeyDigitViolation(epc);
412
+ if (bad)
413
+ v.push(`${field}: ${bad}`);
414
+ }
415
+ }
352
416
  if (e['@context'] !== EPCIS_CONTEXT)
353
417
  v.push('@context 누락/불일치');
354
418
  if (!['ObjectEvent', 'AggregationEvent', 'TransactionEvent', 'TransformationEvent'].includes(e.type))
@@ -2,7 +2,7 @@ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, Effectiv
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
5
- import type { OperationDef, IsoDuration } from './domain-definition.ts';
5
+ import type { OperationDef, IsoDuration, OpMaterialSpecification } from './domain-definition.ts';
6
6
  import { type CapacityAnalysis } from './capacity.ts';
7
7
  import { type CommittedDemand, type OperationsCapabilityReport } from './operations-capability.ts';
8
8
  export interface FlowLocation {
@@ -402,6 +402,22 @@ export declare function computeOee(c: OeeCounters, nowMs: number): OeeMetrics;
402
402
  export declare class ItemStore {
403
403
  private map;
404
404
  private byLocation;
405
+ /**
406
+ * 품목별 색인 — **보관처를 선언하지 않은 현장을 위해.**
407
+ *
408
+ * ── 왜 필요한가 (2026-08-22) ──────────────────────────────────────────────
409
+ * 자재를 확보할 때 예전에는 「그 자재의 보관처 타입」을 반드시 선언해야 했다(`MaterialDef.locationType`).
410
+ * 그런데 **재고로 위치를 말하는 시스템**에는 그 선언이 없다 — 자재에 고정된 보관처를 두지 않는 것이
411
+ * WMS 계열의 정상이다. 실측: 첫 실 연동에서 원자재 986건 중 보관처가 선언된 것이 **36건**이었고, 그
412
+ * 때문에 레시피 937/1,408 건이 아예 실리지 못했다.
413
+ *
414
+ * 선언이 없으면 **재고가 있는 곳에서 찾는다.** 그때 전 로케이션을 훑으면 규모 기준선(품목 100만)에서
415
+ * 감당되지 않으므로 품목 색인이 답한다.
416
+ *
417
+ * 색인을 밖에 따로 두지 않는다 — 갱신하는 자리가 흩어지면 한 자리라도 빠뜨렸을 때 **재고가 조용히
418
+ * 사라진다**(있는데 없다고 판정된다). 자리 색인과 같은 규율이다.
419
+ */
420
+ private byGtin;
405
421
  get size(): number;
406
422
  get(key: string): FlowItem | undefined;
407
423
  has(key: string): boolean;
@@ -430,6 +446,8 @@ export declare class ItemStore {
430
446
  clone(): ItemStore;
431
447
  /** 그 자리에 있는 물품들 — 색인이 답한다(전체 순회가 아니다). */
432
448
  at(location: string): FlowItem[];
449
+ /** 그 품목인 물품들 — 색인이 답한다(자리를 모를 때 쓴다). */
450
+ ofGtin(gtin: string): FlowItem[];
433
451
  /**
434
452
  * 색인이 맵과 어긋난 자리 — **시험이 쓰는 확인 통로**(전체를 다시 세므로 비싸다).
435
453
  *
@@ -438,7 +456,9 @@ export declare class ItemStore {
438
456
  */
439
457
  indexDrift(): string[];
440
458
  private index;
459
+ private indexLocation;
441
460
  private unindex;
461
+ private unindexLocation;
442
462
  }
443
463
  export declare abstract class FlowEngine implements TwinKernel {
444
464
  tenantId: string;
@@ -527,6 +547,14 @@ export declare abstract class FlowEngine implements TwinKernel {
527
547
  */
528
548
  private seedDanglingRefs;
529
549
  private transformInputsAbsent;
550
+ /**
551
+ * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
552
+ *
553
+ * 같은 키끼리는 구체가 상회한다(§`mergeMaterialNeeds`). 교차는 뜻으로는 상회일 수 있으나 판정에 등급
554
+ * 소속이 필요하고, 잘못 겹치면 자재가 조용히 사라지거나 두 배가 된다. 그래서 **둘 다 요구하고 센다** —
555
+ * 이 값이 크면 그 숫자가 다음 작업을 정한다.
556
+ */
557
+ private materialSpecCrossKeyOverlaps;
530
558
  private observedDirty;
531
559
  private observeMode;
532
560
  /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
@@ -1281,7 +1309,13 @@ export declare abstract class FlowEngine implements TwinKernel {
1281
1309
  protected requireObjectId(id: string | number): string;
1282
1310
  /** 거래 문서 식별자 — 선언된 이름공간 또는 선언된 GDTI 문서 타입. 둘 다 없으면 오류를 낸다. */
1283
1311
  protected requireBizTransactionId(id: string | number, docKind: string): string;
1284
- /** 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다. */
1312
+ /**
1313
+ * 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다.
1314
+ *
1315
+ * `what` 은 **관사까지 갖춘 구**를 받는다(`'an object'`). 예전에는 여기서 `a ${what}` 로 관사를
1316
+ * 붙였고, 화면에 「cannot name a object」·「cannot name a business transaction 'purchase'」가 그대로
1317
+ * 나왔다. 사람이 읽는 문장이므로 부르는 자리가 관사를 정한다.
1318
+ */
1285
1319
  private identityMissing;
1286
1320
  /** 선언된 이름공간 아래의 **거래 문서 식별자**(발주·주문·어포인트먼트) — 없으면 답하지 않는다. */
1287
1321
  protected declaredBizTransactionId(id: string | number): string | undefined;
@@ -1334,6 +1368,46 @@ export declare abstract class FlowEngine implements TwinKernel {
1334
1368
  private recordMaterialActual;
1335
1369
  /** 확보한 자재를 **작업 시작 시점에 소비**한다 — 수량이 0 이 되면 물품 자체가 사라진다. */
1336
1370
  private consumeMaterials;
1371
+ /**
1372
+ * **소비된 자재를 도메인이 계보로 가져가는가** — 가져가면 코어는 없애지 않고 예약만 한다.
1373
+ *
1374
+ * 공정이 먹은 자재는 **그 단계가 만드는 것의 입력**이다. 그것을 별도 사건으로 없애면 제품의 계보에서
1375
+ * 그 자재가 빠지고, 회수 범위를 되짚을 때 조용히 좁아진다 — 식품이라면 그것이 사고다.
1376
+ *
1377
+ * 기본은 「가져가지 않는다」다: 만드는 것이 없는 소비(소모품·유통가공)는 개체가 사라진 것이 맞다.
1378
+ */
1379
+ protected adoptConsumed(_t: FlowTask, _epcs: string[]): boolean;
1380
+ /** 이 트윈이 사는 동안 한 번만 알린 상회 — 같은 말을 틱마다 반복하지 않는다. */
1381
+ private announcedOverrides;
1382
+ /**
1383
+ * **구체가 일반을 이긴다 — 다만 상회하는 단위는 자재 한 줄이다.**
1384
+ *
1385
+ * ── 왜 합집합이 아닌가 (2026-08-22) ───────────────────────────────────────
1386
+ * 두 원천이 같은 자재를 말할 수 있다. 일반은 「이 공정이 늘 쓰는 것」이고(품목과 무관 — 포장 필름·
1387
+ * 세척수), 구체는 「이 품목을 이 공정에서 만들 때」다. 같은 자재를 둘이 말하면 **구체가 현장의 사실**
1388
+ * 이므로 이긴다. 합집합이면 요구가 더해져 재고가 거짓이 된다.
1389
+ *
1390
+ * ── 왜 명세 전체를 덮지 않는가 ────────────────────────────────────────────
1391
+ * 덮으면 반대로 틀린다. 레시피가 「무말랭이 90kg」만 말했다고 그 공정의 세척수 50L 이 사라지면, 품목과
1392
+ * 무관하게 늘 들어가는 것이 빠진다 — 그것이 일반 자리의 존재 이유다. 요구의 단위가 자재 한 줄이므로
1393
+ * 상회도 그 단위에서 일어난다.
1394
+ *
1395
+ * ── 등급 ↔ 품목이 교차하면 둘 다 요구한다 ─────────────────────────────────
1396
+ * 명세는 품목(`materialDefinition`)으로도 등급(`materialClass`)으로도 요구한다. 일반이 등급을, 구체가
1397
+ * 품목을 말하고 그 품목이 그 등급에 속하면 뜻으로는 상회지만, 그 판정에는 등급 소속이 필요하고 잘못
1398
+ * 겹치면 자재가 **조용히 사라지거나 두 배**가 된다. 그래서 **같은 키끼리만** 상회시키고, 교차하는
1399
+ * 경우는 세어 남기고 둘 다 요구한다 — 조용히 한쪽을 버리는 것이 가장 나쁘다.
1400
+ */
1401
+ private mergeMaterialNeeds;
1402
+ /**
1403
+ * **그 작업이 만드는 품목의, 그 공정 몫** — 품목 범위를 아는 것은 도메인이다.
1404
+ *
1405
+ * 코어는 레시피를 모른다(창고·야드에는 레시피가 없다). 그래서 시임으로 둔다 — MES 가 오더의
1406
+ * 레시피에서 그 공정에 태그된 투입을 돌려준다(§`RecipePart.operation`).
1407
+ *
1408
+ * 기본은 빈 목록이다: 품목 범위가 없는 트윈에서는 공정 명세만이 요구다.
1409
+ */
1410
+ protected recipeInputsAt(_t: FlowTask): OpMaterialSpecification[];
1337
1411
  /**
1338
1412
  * 이 사람이 **속한 등급들이 요구하는 시험**을 만족하나.
1339
1413
  *