@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.
@@ -301,6 +301,22 @@ function energyFieldsOf(m) {
301
301
  export class ItemStore {
302
302
  map = new Map();
303
303
  byLocation = new Map();
304
+ /**
305
+ * 품목별 색인 — **보관처를 선언하지 않은 현장을 위해.**
306
+ *
307
+ * ── 왜 필요한가 (2026-08-22) ──────────────────────────────────────────────
308
+ * 자재를 확보할 때 예전에는 「그 자재의 보관처 타입」을 반드시 선언해야 했다(`MaterialDef.locationType`).
309
+ * 그런데 **재고로 위치를 말하는 시스템**에는 그 선언이 없다 — 자재에 고정된 보관처를 두지 않는 것이
310
+ * WMS 계열의 정상이다. 실측: 첫 실 연동에서 원자재 986건 중 보관처가 선언된 것이 **36건**이었고, 그
311
+ * 때문에 레시피 937/1,408 건이 아예 실리지 못했다.
312
+ *
313
+ * 선언이 없으면 **재고가 있는 곳에서 찾는다.** 그때 전 로케이션을 훑으면 규모 기준선(품목 100만)에서
314
+ * 감당되지 않으므로 품목 색인이 답한다.
315
+ *
316
+ * 색인을 밖에 따로 두지 않는다 — 갱신하는 자리가 흩어지면 한 자리라도 빠뜨렸을 때 **재고가 조용히
317
+ * 사라진다**(있는데 없다고 판정된다). 자리 색인과 같은 규율이다.
318
+ */
319
+ byGtin = new Map();
304
320
  get size() {
305
321
  return this.map.size;
306
322
  }
@@ -328,20 +344,21 @@ export class ItemStore {
328
344
  set(key, item) {
329
345
  const prev = this.map.get(key);
330
346
  if (prev)
331
- this.unindex(key, prev.location);
347
+ this.unindex(key, prev.location, prev.gtin);
332
348
  this.map.set(key, item);
333
- this.index(key, item.location);
349
+ this.index(key, item.location, item.gtin);
334
350
  return this;
335
351
  }
336
352
  delete(key) {
337
353
  const prev = this.map.get(key);
338
354
  if (prev)
339
- this.unindex(key, prev.location);
355
+ this.unindex(key, prev.location, prev.gtin);
340
356
  return this.map.delete(key);
341
357
  }
342
358
  clear() {
343
359
  this.map.clear();
344
360
  this.byLocation.clear();
361
+ this.byGtin.clear();
345
362
  }
346
363
  /**
347
364
  * 물품을 다른 자리로 옮긴다 — **색인이 함께 움직이는 유일한 통로.**
@@ -354,9 +371,10 @@ export class ItemStore {
354
371
  if (!it)
355
372
  throw new Error(`relocate: 물품 '${key}' 이 상태에 없습니다 — 없는 것을 옮길 수 없습니다`);
356
373
  if (it.location !== to) {
357
- this.unindex(key, it.location);
374
+ /* 자리 색인만 움직인다 — 옮겨도 품목은 그대로다. */
375
+ this.unindexLocation(key, it.location);
358
376
  it.location = to;
359
- this.index(key, to);
377
+ this.indexLocation(key, to);
360
378
  }
361
379
  return it;
362
380
  }
@@ -370,7 +388,20 @@ export class ItemStore {
370
388
  clone() {
371
389
  const out = new ItemStore();
372
390
  for (const [k, it] of this.map)
373
- out.set(k, structuredClone(it));
391
+ out.map.set(k, structuredClone(it));
392
+ /*
393
+ * ── 색인은 **베껴 온다** (2026-08-22) ─────────────────────────────────────
394
+ * 예전에는 항목마다 `set()` 을 불러 색인을 다시 쌓았다. 색인이 하나일 때는 그 비용이 묻혔는데,
395
+ * 품목 색인이 생기면서 항목마다 Map 조회 둘 + Set 삽입 둘이 됐다 — 그리고 `fork()` 가 이 함수를
396
+ * 쓴다. 예측은 회차마다 사본을 뜨므로 그 비용이 곧바로 예측 비용이다.
397
+ *
398
+ * 사본은 **원본과 같은 배치**이므로 색인을 다시 계산할 이유가 없다. 그룹마다 Set 하나를 만들어
399
+ * 베낀다. 어긋날 위험은 `indexDrift()` 가 지킨다(시나리오를 돌린 뒤 그 값으로 확인한다).
400
+ */
401
+ for (const [loc, keys] of this.byLocation)
402
+ out.byLocation.set(loc, new Set(keys));
403
+ for (const [g, keys] of this.byGtin)
404
+ out.byGtin.set(g, new Set(keys));
374
405
  return out;
375
406
  }
376
407
  /*
@@ -391,6 +422,19 @@ export class ItemStore {
391
422
  }
392
423
  return out;
393
424
  }
425
+ /** 그 품목인 물품들 — 색인이 답한다(자리를 모를 때 쓴다). */
426
+ ofGtin(gtin) {
427
+ const keys = this.byGtin.get(gtin);
428
+ if (!keys)
429
+ return [];
430
+ const out = [];
431
+ for (const k of keys) {
432
+ const it = this.map.get(k);
433
+ if (it)
434
+ out.push(it);
435
+ }
436
+ return out;
437
+ }
394
438
  /**
395
439
  * 색인이 맵과 어긋난 자리 — **시험이 쓰는 확인 통로**(전체를 다시 세므로 비싸다).
396
440
  *
@@ -412,14 +456,47 @@ export class ItemStore {
412
456
  drift.push(`${k} 은 '${it.location}' 에 있는데 '${loc}' 색인에 있다`);
413
457
  }
414
458
  }
459
+ /* 품목 색인도 같은 규율로 본다 — 어긋나면 자재가 있는데 없다고 판정된다. */
460
+ for (const [key, it] of this.map) {
461
+ if (it.gtin && !this.byGtin.get(it.gtin)?.has(key))
462
+ drift.push(`${key} 이 품목 '${it.gtin}' 색인에 없다`);
463
+ }
464
+ for (const [g, keys] of this.byGtin) {
465
+ for (const k of keys) {
466
+ const it = this.map.get(k);
467
+ if (!it)
468
+ drift.push(`${k} 이 지워졌는데 품목 '${g}' 색인에 남아 있다`);
469
+ else if (it.gtin !== g)
470
+ drift.push(`${k} 은 품목 '${it.gtin}' 인데 '${g}' 색인에 있다`);
471
+ }
472
+ }
415
473
  return drift;
416
474
  }
417
- index(key, location) {
475
+ index(key, location, gtin) {
476
+ this.indexLocation(key, location);
477
+ if (gtin) {
478
+ const set = this.byGtin.get(gtin) ?? new Set();
479
+ set.add(key);
480
+ this.byGtin.set(gtin, set);
481
+ }
482
+ }
483
+ indexLocation(key, location) {
418
484
  const set = this.byLocation.get(location) ?? new Set();
419
485
  set.add(key);
420
486
  this.byLocation.set(location, set);
421
487
  }
422
- unindex(key, location) {
488
+ unindex(key, location, gtin) {
489
+ this.unindexLocation(key, location);
490
+ if (gtin) {
491
+ const set = this.byGtin.get(gtin);
492
+ if (!set)
493
+ return;
494
+ set.delete(key);
495
+ if (!set.size)
496
+ this.byGtin.delete(gtin);
497
+ }
498
+ }
499
+ unindexLocation(key, location) {
423
500
  const set = this.byLocation.get(location);
424
501
  if (!set)
425
502
  return;
@@ -510,6 +587,14 @@ export class FlowEngine {
510
587
  */
511
588
  seedDanglingRefs = 0;
512
589
  transformInputsAbsent = 0;
590
+ /**
591
+ * 일반 요구(공정)와 구체 요구(레시피 × 공정)가 **등급 ↔ 품목으로 교차**한 횟수.
592
+ *
593
+ * 같은 키끼리는 구체가 상회한다(§`mergeMaterialNeeds`). 교차는 뜻으로는 상회일 수 있으나 판정에 등급
594
+ * 소속이 필요하고, 잘못 겹치면 자재가 조용히 사라지거나 두 배가 된다. 그래서 **둘 다 요구하고 센다** —
595
+ * 이 값이 크면 그 숫자가 다음 작업을 정한다.
596
+ */
597
+ materialSpecCrossKeyOverlaps = 0;
513
598
  observedDirty = false;
514
599
  observeMode = false;
515
600
  /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
@@ -1231,10 +1316,11 @@ export class FlowEngine {
1231
1316
  nowTime: this.now(), // 트윈의 "지금" — 관측 모드면 마지막으로 들은 시각(§nowMs)
1232
1317
  identityGrounding: this.identityGroundingView(),
1233
1318
  /* 원본과 어긋난 사실 — 0 이면 싣지 않는다(어긋난 적 없는 트윈에 빈 칸을 만들지 않는다). */
1234
- ...(this.transformInputsAbsent || this.seedDanglingRefs
1319
+ ...(this.transformInputsAbsent || this.seedDanglingRefs || this.materialSpecCrossKeyOverlaps
1235
1320
  ? { conformance: {
1236
1321
  ...(this.transformInputsAbsent ? { transformInputsAbsent: this.transformInputsAbsent } : {}),
1237
- ...(this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {})
1322
+ ...(this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {}),
1323
+ ...(this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {})
1238
1324
  } }
1239
1325
  : {}),
1240
1326
  /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
@@ -2496,7 +2582,14 @@ export class FlowEngine {
2496
2582
  * 작업이 같은 부품을 또 잡는다). 산출(`produced`)은 여기서 다루지 않는다(완료 시점의 일이다).
2497
2583
  */
2498
2584
  claimMaterials(t) {
2499
- const need = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter(m => m.use === 'consumed');
2585
+ /*
2586
+ * 두 원천을 합쳐 본다 — 뜻이 다르고 자리도 다르다(§`OperationDef.materialSpecification`).
2587
+ * ① 공정 명세 — 품목과 무관하게 그 자리가 늘 쓰는 것(포장 필름·세척수). 트윈 전체에 한 벌이다.
2588
+ * ② 그 작업이 만드는 **품목의** 그 공정 몫 — 도메인이 답한다(§`recipeInputsAt`).
2589
+ * 오더가 없는 작업(창고 입고 등)에는 ②가 없다 — 그때는 ①만 적용된다.
2590
+ */
2591
+ const general = (this.operationSpecs.get(t.kind)?.materialSpecification ?? []).filter(m => m.use === 'consumed');
2592
+ const need = this.mergeMaterialNeeds(general, this.recipeInputsAt(t), t.kind);
2500
2593
  if (!need.length)
2501
2594
  return [];
2502
2595
  const at = t.toNode;
@@ -2512,6 +2605,13 @@ export class FlowEngine {
2512
2605
  break;
2513
2606
  if (!this.materialMatches(it, req))
2514
2607
  continue;
2608
+ /*
2609
+ * 변환 중인 것은 잡지 않는다 — 어떤 공정이 이미 먹어 그 단계의 산출로 바뀔 물품이다
2610
+ * (§`adoptConsumed`). **예약은 건너뛰지 않는다**: 오더가 예약한 부품을 그 공정이 먹는 흐름이
2611
+ * 정상이다(유통가공 키팅이 그렇게 돈다 — 예약을 막으면 세트가 만들어지지 않는다).
2612
+ */
2613
+ if (it.disposition === DISP.in_progress)
2614
+ continue;
2515
2615
  const already = takenSoFar.get(it.epc) ?? 0;
2516
2616
  const avail = Math.max(0, (it.qty ?? 1) - already);
2517
2617
  if (avail <= 0)
@@ -2616,7 +2716,7 @@ export class FlowEngine {
2616
2716
  const uri = this.declaredObjectId(id);
2617
2717
  if (uri)
2618
2718
  return uri;
2619
- throw new Error(this.identityMissing('object'));
2719
+ throw new Error(this.identityMissing('an object'));
2620
2720
  }
2621
2721
  /** 거래 문서 식별자 — 선언된 이름공간 또는 선언된 GDTI 문서 타입. 둘 다 없으면 오류를 낸다. */
2622
2722
  requireBizTransactionId(id, docKind) {
@@ -2628,11 +2728,17 @@ export class FlowEngine {
2628
2728
  const prefix = decl?.companyPrefix;
2629
2729
  if (docType && prefix)
2630
2730
  return gdtiUri(prefix, docType, Number(id) || 0);
2631
- throw new Error(this.identityMissing(`business transaction '${docKind}'`));
2731
+ throw new Error(this.identityMissing(`the business transaction '${docKind}'`));
2632
2732
  }
2633
- /** 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다. */
2733
+ /**
2734
+ * 같은 문장을 두 곳에 적지 않는다 — 고치는 자리가 하나여야 한다.
2735
+ *
2736
+ * `what` 은 **관사까지 갖춘 구**를 받는다(`'an object'`). 예전에는 여기서 `a ${what}` 로 관사를
2737
+ * 붙였고, 화면에 「cannot name a object」·「cannot name a business transaction 'purchase'」가 그대로
2738
+ * 나왔다. 사람이 읽는 문장이므로 부르는 자리가 관사를 정한다.
2739
+ */
2634
2740
  identityMissing(what) {
2635
- return (`this twin has no identity declaration, so the kernel cannot name a ${what}. ` +
2741
+ return (`this twin has no identity declaration, so the kernel cannot name ${what}. ` +
2636
2742
  'Declare `identity.namespaces` on the model (CBV 2.0 §8.2.4 `.../obj/<id>` · §8.5.5 `.../bt/<id>` — ' +
2637
2743
  'assigned by the owner of that internet domain), or `identity.documentTypes` with `identity.companyPrefix` ' +
2638
2744
  'for GS1 keys. The kernel does not invent a company prefix: GS1 assigns it to a company, and a value the ' +
@@ -2813,12 +2919,117 @@ export class FlowEngine {
2813
2919
  this.recordMaterialActual(t, it.definitionId ?? it.gtin, 'consumed', take, it.uom);
2814
2920
  whole.push(epc);
2815
2921
  }
2816
- if (whole.length) {
2817
- this.transform(whole, [], {
2818
- bizStep: CBV_BIZSTEP.consuming, disposition: DISP.in_progress, transformationId: t.id,
2819
- readPoint: this.items.get(whole[0])?.location ?? t.toNode
2820
- });
2922
+ if (!whole.length)
2923
+ return;
2924
+ /*
2925
+ * ── 전량 소비는 **변환이 아니다** (2026-08-22) ─────────────────────────────
2926
+ * 예전에는 `transform(whole, [])` 였다. 그것은 출력이 빈 `TransformationEvent` 이고 **우리 검증기가
2927
+ * 거부한다**(「output 비어있음」). 실측: 공정 하나가 자재를 전량 먹는 경로에서 한 실행에 9건이
2928
+ * 무효였다. 변환은 양쪽을 요구한다(EPCIS 2.0 §7.4.5) — 만드는 것이 없으면 변환이 아니다.
2929
+ *
2930
+ * 그래서 두 갈래로 나눈다.
2931
+ * ① **도메인이 계보로 가져가면** 물품을 남겨 두고 예약만 한다. 그 단계가 만드는 것의 변환 입력이
2932
+ * 되어야 회수 범위가 온전하다 — 별도 사건으로 없애면 제품의 계보에서 그 자재가 빠진다.
2933
+ * ② 아무것도 만들지 않는 소비는 개체가 **사라진** 것이다 — `ObjectEvent` `DELETE`(이미 출하·출차가
2934
+ * 쓰는 원시다). 소비 사실은 `recordMaterialActual` 이 이미 남겼다.
2935
+ */
2936
+ if (this.adoptConsumed(t, whole)) {
2937
+ /*
2938
+ * 다른 작업이 같은 물품을 또 잡지 못하게 한다 — 소비 시점은 시작인데 변환은 완료 시점이라 그
2939
+ * 사이가 열려 있다. **변환 중**으로 표시한다(상태와 사건을 한 번에 — §`observeDisposition`).
2940
+ */
2941
+ this.observeDisposition(whole, DISP.in_progress, CBV_BIZSTEP.consuming);
2942
+ return;
2943
+ }
2944
+ const at = this.items.get(whole[0])?.location ?? t.toNode;
2945
+ /* 자리의 점유를 함께 줄인다 — 물품만 지우면 그 자리가 영원히 찬 것으로 남는다(§`transform`). */
2946
+ for (const epc of whole) {
2947
+ const it = this.items.get(epc);
2948
+ if (!it)
2949
+ continue;
2950
+ const n = this.locations.get(it.location);
2951
+ if (n)
2952
+ n.occupancy--;
2953
+ this.items.delete(epc);
2821
2954
  }
2955
+ this.emit(objectEvent({
2956
+ eventTime: this.now(), action: 'DELETE', bizStep: CBV_BIZSTEP.consuming, disposition: DISP.in_progress,
2957
+ epcList: whole.slice(), readPoint: at, bizLocation: at
2958
+ }));
2959
+ }
2960
+ /**
2961
+ * **소비된 자재를 도메인이 계보로 가져가는가** — 가져가면 코어는 없애지 않고 예약만 한다.
2962
+ *
2963
+ * 공정이 먹은 자재는 **그 단계가 만드는 것의 입력**이다. 그것을 별도 사건으로 없애면 제품의 계보에서
2964
+ * 그 자재가 빠지고, 회수 범위를 되짚을 때 조용히 좁아진다 — 식품이라면 그것이 사고다.
2965
+ *
2966
+ * 기본은 「가져가지 않는다」다: 만드는 것이 없는 소비(소모품·유통가공)는 개체가 사라진 것이 맞다.
2967
+ */
2968
+ adoptConsumed(_t, _epcs) {
2969
+ return false;
2970
+ }
2971
+ /** 이 트윈이 사는 동안 한 번만 알린 상회 — 같은 말을 틱마다 반복하지 않는다. */
2972
+ announcedOverrides = new Set();
2973
+ /**
2974
+ * **구체가 일반을 이긴다 — 다만 상회하는 단위는 자재 한 줄이다.**
2975
+ *
2976
+ * ── 왜 합집합이 아닌가 (2026-08-22) ───────────────────────────────────────
2977
+ * 두 원천이 같은 자재를 말할 수 있다. 일반은 「이 공정이 늘 쓰는 것」이고(품목과 무관 — 포장 필름·
2978
+ * 세척수), 구체는 「이 품목을 이 공정에서 만들 때」다. 같은 자재를 둘이 말하면 **구체가 현장의 사실**
2979
+ * 이므로 이긴다. 합집합이면 요구가 더해져 재고가 거짓이 된다.
2980
+ *
2981
+ * ── 왜 명세 전체를 덮지 않는가 ────────────────────────────────────────────
2982
+ * 덮으면 반대로 틀린다. 레시피가 「무말랭이 90kg」만 말했다고 그 공정의 세척수 50L 이 사라지면, 품목과
2983
+ * 무관하게 늘 들어가는 것이 빠진다 — 그것이 일반 자리의 존재 이유다. 요구의 단위가 자재 한 줄이므로
2984
+ * 상회도 그 단위에서 일어난다.
2985
+ *
2986
+ * ── 등급 ↔ 품목이 교차하면 둘 다 요구한다 ─────────────────────────────────
2987
+ * 명세는 품목(`materialDefinition`)으로도 등급(`materialClass`)으로도 요구한다. 일반이 등급을, 구체가
2988
+ * 품목을 말하고 그 품목이 그 등급에 속하면 뜻으로는 상회지만, 그 판정에는 등급 소속이 필요하고 잘못
2989
+ * 겹치면 자재가 **조용히 사라지거나 두 배**가 된다. 그래서 **같은 키끼리만** 상회시키고, 교차하는
2990
+ * 경우는 세어 남기고 둘 다 요구한다 — 조용히 한쪽을 버리는 것이 가장 나쁘다.
2991
+ */
2992
+ mergeMaterialNeeds(general, specific, opKey) {
2993
+ if (!specific.length)
2994
+ return general;
2995
+ if (!general.length)
2996
+ return specific;
2997
+ const keyOf = (m) => m.materialDefinition ? `def:${m.materialDefinition}` : m.materialClass ? `cls:${m.materialClass}` : '';
2998
+ const beaten = new Set(specific.map(keyOf).filter(Boolean));
2999
+ const out = [...specific];
3000
+ for (const g of general) {
3001
+ const k = keyOf(g);
3002
+ if (k && beaten.has(k)) {
3003
+ const note = `${opKey}|${k}`;
3004
+ if (!this.announcedOverrides.has(note)) {
3005
+ this.announcedOverrides.add(note);
3006
+ console.warn(`[twin] operation '${opKey}': the recipe's own requirement for ${k} overrides this operation's general ` +
3007
+ 'requirement (the specific declaration wins). The operation\'s other lines still apply.');
3008
+ }
3009
+ continue;
3010
+ }
3011
+ /* 등급 ↔ 품목이 교차하는지 센다 — 뜻으로는 상회일 수 있으나 조용히 버리지 않는다. */
3012
+ const crosses = g.materialClass
3013
+ ? specific.some(sp => !!sp.materialDefinition)
3014
+ : g.materialDefinition
3015
+ ? specific.some(sp => !!sp.materialClass)
3016
+ : false;
3017
+ if (crosses)
3018
+ this.materialSpecCrossKeyOverlaps++;
3019
+ out.push(g);
3020
+ }
3021
+ return out;
3022
+ }
3023
+ /**
3024
+ * **그 작업이 만드는 품목의, 그 공정 몫** — 품목 범위를 아는 것은 도메인이다.
3025
+ *
3026
+ * 코어는 레시피를 모른다(창고·야드에는 레시피가 없다). 그래서 시임으로 둔다 — MES 가 오더의
3027
+ * 레시피에서 그 공정에 태그된 투입을 돌려준다(§`RecipePart.operation`).
3028
+ *
3029
+ * 기본은 빈 목록이다: 품목 범위가 없는 트윈에서는 공정 명세만이 요구다.
3030
+ */
3031
+ recipeInputsAt(_t) {
3032
+ return [];
2822
3033
  }
2823
3034
  /**
2824
3035
  * 이 사람이 **속한 등급들이 요구하는 시험**을 만족하나.
@@ -2,6 +2,7 @@ import type { GeneratorSpec, Command, CommandAck, ProductionSpec, TwinModelDef,
2
2
  import type { AllocationPolicy } from './allocation-policy.ts';
3
3
  import { FlowEngine } from './flow-engine.ts';
4
4
  import type { FlowOrder, FlowTask } from './flow-engine.ts';
5
+ import { type OpMaterialSpecification } from './domain-definition.ts';
5
6
  export declare class MesKernel extends FlowEngine {
6
7
  private wipSeq;
7
8
  private prodSeq;
@@ -21,6 +22,22 @@ export declare class MesKernel extends FlowEngine {
21
22
  * 모든 계산이 거짓이 된다).
22
23
  */
23
24
  private assertNoDoubleProduction;
25
+ /**
26
+ * **레시피 투입의 공정 태그를 기동에서 검사한다** — 어긋나면 그 자재는 영원히 확보되지 않는다.
27
+ *
28
+ * ── 두 가지를 본다 (2026-08-22) ───────────────────────────────────────────
29
+ * ① **태그의 공정이 그 레시피의 라우트 단계에 있어야 한다.** 없으면 그 투입은 확보되는 시점이 오지
30
+ * 않고, 오더는 영원히 그 단계에서 멈춘다 — 화면에는 이유가 없다. 실 마스터에서 BOM 이 말하는
31
+ * 공정과 품목의 경로가 어긋나는 일이 실제로 있다(BOM 은 「조림」인데 경로에 조림이 없는 경우).
32
+ * ② **한 레시피 안에서** 같은 자재가 태그 있는 줄과 없는 줄에 동시에 있으면 거부한다. 태그 없는 줄은
33
+ * 오더 착수에 확보되고 태그 붙은 줄은 그 공정에서 확보되므로, 그 자재를 **두 번 먹는다.**
34
+ *
35
+ * 레시피와 **공정 명세**가 같은 자재를 말하는 것은 거부하지 않는다 — 그것은 상회이고 정상이다
36
+ * (구체가 일반을 이긴다, §`mergeMaterialNeeds`).
37
+ *
38
+ * 런타임에 조용히 어긋나는 것보다 기동이 실패하는 편이 낫다.
39
+ */
40
+ private assertRecipeOperationTags;
24
41
  /**
25
42
  * **레시피 모드에서는 MES 가 산출을 소유한다** — 코어는 비켜선다(§`producesOwnOutputs`).
26
43
  *
@@ -97,8 +114,42 @@ export declare class MesKernel extends FlowEngine {
97
114
  */
98
115
  protected canComplete(t: FlowTask): boolean;
99
116
  protected onTaskComplete(t: FlowTask): void;
117
+ /**
118
+ * **공정이 먹은 자재는 오더의 계보에 합류한다** — 그 단계가 만드는 것의 입력이 된다.
119
+ *
120
+ * ── 무엇이 빠져 있었나 (2026-08-22) ───────────────────────────────────────
121
+ * 공정별 자재(`OperationDef.materialSpecification` `use:'consumed'`, ISA-95
122
+ * `OperationsSegment.MaterialSpecification`)는 커널이 이미 확보하고 소비했다. 그런데 그 자재가
123
+ * **오더가 들고 있는 것에 들어가지 않았다.** 단계의 변환 입력은 `order.allocated` 뿐이라, 뒤 공정에서
124
+ * 먹은 자재가 제품의 계보에서 빠졌다 — 회수 범위를 되짚으면 그 자재가 조용히 없다.
125
+ *
126
+ * 레시피가 없는 모드(유통가공)는 산출을 코어가 만들므로 가져가지 않는다(§`producesOwnOutputs`).
127
+ */
128
+ /**
129
+ * **그 오더의 레시피에서, 이 공정에 태그된 투입** — 품목 범위를 아는 것은 여기다.
130
+ *
131
+ * 오더가 없는 작업에는 답하지 않는다(창고 입고 등 — 그때는 공정 명세만이 요구다).
132
+ */
133
+ protected recipeInputsAt(t: FlowTask): OpMaterialSpecification[];
134
+ protected adoptConsumed(t: FlowTask, epcs: string[]): boolean;
100
135
  /** 선언된 레시피 전부 — 오더가 자기 것을 고르고, 수령이 전부의 소요를 본다. */
101
136
  private recipesDef;
137
+ /**
138
+ * **소비되는 자재 전부** — 선언이 그것을 말하는 자리는 둘이고, 둘 다 본다.
139
+ *
140
+ * ── 왜 둘인가 (2026-08-22) ────────────────────────────────────────────────
141
+ * ① `RecipeDef.inputs` — 레시피가 쓰는 자재. 오더가 시작될 때 확보한다.
142
+ * ② `OperationDef.materialSpecification` `use:'consumed'` — **그 공정에서만** 들어가는 자재
143
+ * (ISA-95 `OperationsSegment.MaterialSpecification`). 같은 부품이라도 공정마다 소요가 다르고,
144
+ * 표준은 「몇 개」를 공정의 사실로 둔다.
145
+ *
146
+ * 예전에는 수령이 ①만 봤다. 그래서 ②에만 선언된 자재는 **한 번도 입고되지 않았고**, 그 공정은 영원히
147
+ * 기다렸다 — 화면에는 이유가 없었다. 첫 실 연동의 BOM 이 (품목, 공정) 단위라 이 자리가 바로 막혔다.
148
+ *
149
+ * `binding` 을 지나지 않는 이름은 여기서 세지 않는다 — 커널이 그 자재의 정체성을 만들 수 없으므로
150
+ * **입고를 만들 수 없다**(밖에서 들어온 물품은 `claimMaterials` 가 클래스 문자열로 알아본다).
151
+ */
152
+ private consumedMaterialKeys;
102
153
  /**
103
154
  * 이 오더의 레시피 — **오더가 들면 그것, 없으면 선언 수준의 기본**(§`FlowOrder.recipeKey`).
104
155
  *
@@ -164,6 +215,10 @@ export declare class MesKernel extends FlowEngine {
164
215
  * 레거시(선언 없는 내장 프로파일) 경로는 이 함수를 쓰지 않는다 — 그쪽에는 대조할 선언이 없으므로
165
216
  * 커널 어휘 자체가 계약이다.
166
217
  */
218
+ /** 자리를 선언하지 않아 입고를 만들지 못한 자재 — 같은 말을 틱마다 반복하지 않는다. */
219
+ private arrivalsWithoutPlace;
220
+ /** 그 자재가 선언한 보관처 타입 — **없으면 undefined**(정책이 없다는 사실이다). */
221
+ private declaredLocationTypeOfMaterial;
167
222
  private locationTypeOfMaterial;
168
223
  /** 그 자리 타입의 자리 — 이 트윈에 없으면 말한다(선언과 모델이 어긋난 사실이다). */
169
224
  private locationOfMaterial;