@operato/twin-kernel 0.7.47 → 0.7.48
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.
- package/dist/contract.d.ts +8 -0
- package/dist/domain-definition.d.ts +43 -2
- package/dist/domain-definition.js +10 -2
- package/dist/epcis.d.ts +7 -1
- package/dist/epcis.js +64 -0
- package/dist/flow-engine.d.ts +76 -2
- package/dist/flow-engine.js +217 -19
- package/dist/mes-kernel.d.ts +55 -0
- package/dist/mes-kernel.js +191 -10
- package/dist-cjs/index.cjs +349 -26
- package/package.json +1 -1
package/dist/mes-kernel.js
CHANGED
|
@@ -75,6 +75,7 @@ export class MesKernel extends FlowEngine {
|
|
|
75
75
|
if (productionSpec?.definition?.operations) {
|
|
76
76
|
this.assertNoDoubleProduction(productionSpec.definition.operations);
|
|
77
77
|
}
|
|
78
|
+
this.assertRecipeOperationTags();
|
|
78
79
|
}
|
|
79
80
|
/**
|
|
80
81
|
* **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
|
|
@@ -115,6 +116,55 @@ export class MesKernel extends FlowEngine {
|
|
|
115
116
|
"materialSpecification use:'produced' (that would create the same output twice). Declare intermediate outputs freely; " +
|
|
116
117
|
'the final output belongs to the recipe.');
|
|
117
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* **레시피 투입의 공정 태그를 기동에서 검사한다** — 어긋나면 그 자재는 영원히 확보되지 않는다.
|
|
121
|
+
*
|
|
122
|
+
* ── 두 가지를 본다 (2026-08-22) ───────────────────────────────────────────
|
|
123
|
+
* ① **태그의 공정이 그 레시피의 라우트 단계에 있어야 한다.** 없으면 그 투입은 확보되는 시점이 오지
|
|
124
|
+
* 않고, 오더는 영원히 그 단계에서 멈춘다 — 화면에는 이유가 없다. 실 마스터에서 BOM 이 말하는
|
|
125
|
+
* 공정과 품목의 경로가 어긋나는 일이 실제로 있다(BOM 은 「조림」인데 경로에 조림이 없는 경우).
|
|
126
|
+
* ② **한 레시피 안에서** 같은 자재가 태그 있는 줄과 없는 줄에 동시에 있으면 거부한다. 태그 없는 줄은
|
|
127
|
+
* 오더 착수에 확보되고 태그 붙은 줄은 그 공정에서 확보되므로, 그 자재를 **두 번 먹는다.**
|
|
128
|
+
*
|
|
129
|
+
* 레시피와 **공정 명세**가 같은 자재를 말하는 것은 거부하지 않는다 — 그것은 상회이고 정상이다
|
|
130
|
+
* (구체가 일반을 이긴다, §`mergeMaterialNeeds`).
|
|
131
|
+
*
|
|
132
|
+
* 런타임에 조용히 어긋나는 것보다 기동이 실패하는 편이 낫다.
|
|
133
|
+
*/
|
|
134
|
+
assertRecipeOperationTags() {
|
|
135
|
+
const def = this.productionSpec?.definition;
|
|
136
|
+
if (!def?.recipes?.length)
|
|
137
|
+
return;
|
|
138
|
+
const stepsOf = new Map((def.routes ?? []).map(r => [r.key, new Set(r.steps ?? [])]));
|
|
139
|
+
const bad = [];
|
|
140
|
+
for (const rc of def.recipes) {
|
|
141
|
+
const tagged = rc.inputs.filter(i => i.operation);
|
|
142
|
+
if (tagged.length) {
|
|
143
|
+
const steps = rc.route ? stepsOf.get(rc.route) : undefined;
|
|
144
|
+
if (!steps) {
|
|
145
|
+
bad.push(`recipe '${rc.key}' tags inputs with an operation but declares no route — the tag has nothing to match`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
for (const line of tagged) {
|
|
149
|
+
if (!steps.has(line.operation)) {
|
|
150
|
+
bad.push(`recipe '${rc.key}' input '${line.material}' is tagged for operation '${line.operation}', which is not a ` +
|
|
151
|
+
`step of its route '${rc.route}' (steps: ${[...steps].join(' > ') || '(none)'}) — that material would ` +
|
|
152
|
+
'never be claimed and the order would wait forever with no reason on screen');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const withTag = new Set(tagged.map(i => i.material));
|
|
158
|
+
for (const line of rc.inputs) {
|
|
159
|
+
if (!line.operation && withTag.has(line.material)) {
|
|
160
|
+
bad.push(`recipe '${rc.key}' declares material '${line.material}' both with and without an operation tag — ` +
|
|
161
|
+
'the untagged line is claimed at order start and the tagged one at its step, so it would be consumed twice');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (bad.length)
|
|
166
|
+
throw new Error(bad.join('; '));
|
|
167
|
+
}
|
|
118
168
|
/**
|
|
119
169
|
* **레시피 모드에서는 MES 가 산출을 소유한다** — 코어는 비켜선다(§`producesOwnOutputs`).
|
|
120
170
|
*
|
|
@@ -293,11 +343,85 @@ export class MesKernel extends FlowEngine {
|
|
|
293
343
|
onTaskComplete(t) {
|
|
294
344
|
return this.onTaskCompleteDef(t);
|
|
295
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* **공정이 먹은 자재는 오더의 계보에 합류한다** — 그 단계가 만드는 것의 입력이 된다.
|
|
348
|
+
*
|
|
349
|
+
* ── 무엇이 빠져 있었나 (2026-08-22) ───────────────────────────────────────
|
|
350
|
+
* 공정별 자재(`OperationDef.materialSpecification` `use:'consumed'`, ISA-95
|
|
351
|
+
* `OperationsSegment.MaterialSpecification`)는 커널이 이미 확보하고 소비했다. 그런데 그 자재가
|
|
352
|
+
* **오더가 들고 있는 것에 들어가지 않았다.** 단계의 변환 입력은 `order.allocated` 뿐이라, 뒤 공정에서
|
|
353
|
+
* 먹은 자재가 제품의 계보에서 빠졌다 — 회수 범위를 되짚으면 그 자재가 조용히 없다.
|
|
354
|
+
*
|
|
355
|
+
* 레시피가 없는 모드(유통가공)는 산출을 코어가 만들므로 가져가지 않는다(§`producesOwnOutputs`).
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* **그 오더의 레시피에서, 이 공정에 태그된 투입** — 품목 범위를 아는 것은 여기다.
|
|
359
|
+
*
|
|
360
|
+
* 오더가 없는 작업에는 답하지 않는다(창고 입고 등 — 그때는 공정 명세만이 요구다).
|
|
361
|
+
*/
|
|
362
|
+
recipeInputsAt(t) {
|
|
363
|
+
if (!t.orderId)
|
|
364
|
+
return [];
|
|
365
|
+
const order = this.orders.get(t.orderId);
|
|
366
|
+
if (!order)
|
|
367
|
+
return [];
|
|
368
|
+
if (!this.productionSpec?.definition?.recipes?.length)
|
|
369
|
+
return [];
|
|
370
|
+
const rc = this.recipeDef(order);
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const line of rc.inputs) {
|
|
373
|
+
if (line.operation !== t.kind)
|
|
374
|
+
continue;
|
|
375
|
+
out.push({ use: 'consumed', materialDefinition: this.classOf(line.material), quantity: line.qty });
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
adoptConsumed(t, epcs) {
|
|
380
|
+
if (!epcs.length)
|
|
381
|
+
return false;
|
|
382
|
+
if (!this.producesOwnOutputs(t.kind))
|
|
383
|
+
return false;
|
|
384
|
+
const order = t.orderId ? this.orders.get(t.orderId) : undefined;
|
|
385
|
+
if (!order)
|
|
386
|
+
return false;
|
|
387
|
+
for (const epc of epcs)
|
|
388
|
+
order.allocated.push(epc);
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
296
391
|
// ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
|
|
297
392
|
/** 선언된 레시피 전부 — 오더가 자기 것을 고르고, 수령이 전부의 소요를 본다. */
|
|
298
393
|
recipesDef() {
|
|
299
394
|
return this.productionSpec.definition.recipes ?? [];
|
|
300
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* **소비되는 자재 전부** — 선언이 그것을 말하는 자리는 둘이고, 둘 다 본다.
|
|
398
|
+
*
|
|
399
|
+
* ── 왜 둘인가 (2026-08-22) ────────────────────────────────────────────────
|
|
400
|
+
* ① `RecipeDef.inputs` — 레시피가 쓰는 자재. 오더가 시작될 때 확보한다.
|
|
401
|
+
* ② `OperationDef.materialSpecification` `use:'consumed'` — **그 공정에서만** 들어가는 자재
|
|
402
|
+
* (ISA-95 `OperationsSegment.MaterialSpecification`). 같은 부품이라도 공정마다 소요가 다르고,
|
|
403
|
+
* 표준은 「몇 개」를 공정의 사실로 둔다.
|
|
404
|
+
*
|
|
405
|
+
* 예전에는 수령이 ①만 봤다. 그래서 ②에만 선언된 자재는 **한 번도 입고되지 않았고**, 그 공정은 영원히
|
|
406
|
+
* 기다렸다 — 화면에는 이유가 없었다. 첫 실 연동의 BOM 이 (품목, 공정) 단위라 이 자리가 바로 막혔다.
|
|
407
|
+
*
|
|
408
|
+
* `binding` 을 지나지 않는 이름은 여기서 세지 않는다 — 커널이 그 자재의 정체성을 만들 수 없으므로
|
|
409
|
+
* **입고를 만들 수 없다**(밖에서 들어온 물품은 `claimMaterials` 가 클래스 문자열로 알아본다).
|
|
410
|
+
*/
|
|
411
|
+
consumedMaterialKeys() {
|
|
412
|
+
const keys = new Set(this.recipesDef().flatMap(r => r.inputs.map(i => i.material)));
|
|
413
|
+
const binding = this.productionSpec?.binding ?? {};
|
|
414
|
+
for (const op of this.productionSpec?.definition?.operations ?? []) {
|
|
415
|
+
for (const m of op.materialSpecification ?? []) {
|
|
416
|
+
if (m.use !== 'consumed' || !m.materialDefinition)
|
|
417
|
+
continue;
|
|
418
|
+
const k = Object.keys(binding).find(key => this.classOf(key) === m.materialDefinition);
|
|
419
|
+
if (k)
|
|
420
|
+
keys.add(k);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return [...keys];
|
|
424
|
+
}
|
|
301
425
|
/**
|
|
302
426
|
* 이 오더의 레시피 — **오더가 들면 그것, 없으면 선언 수준의 기본**(§`FlowOrder.recipeKey`).
|
|
303
427
|
*
|
|
@@ -418,6 +542,12 @@ export class MesKernel extends FlowEngine {
|
|
|
418
542
|
* 레거시(선언 없는 내장 프로파일) 경로는 이 함수를 쓰지 않는다 — 그쪽에는 대조할 선언이 없으므로
|
|
419
543
|
* 커널 어휘 자체가 계약이다.
|
|
420
544
|
*/
|
|
545
|
+
/** 자리를 선언하지 않아 입고를 만들지 못한 자재 — 같은 말을 틱마다 반복하지 않는다. */
|
|
546
|
+
arrivalsWithoutPlace = new Set();
|
|
547
|
+
/** 그 자재가 선언한 보관처 타입 — **없으면 undefined**(정책이 없다는 사실이다). */
|
|
548
|
+
declaredLocationTypeOfMaterial(materialKey) {
|
|
549
|
+
return this.productionSpec.definition.materials?.find(m => m.key === materialKey)?.locationType;
|
|
550
|
+
}
|
|
421
551
|
locationTypeOfMaterial(materialKey) {
|
|
422
552
|
const type = this.productionSpec.definition.materials?.find(m => m.key === materialKey)?.locationType;
|
|
423
553
|
if (!type)
|
|
@@ -474,11 +604,27 @@ export class MesKernel extends FlowEngine {
|
|
|
474
604
|
* **선언된 레시피 **전부**의 소요를 본다** — 하나만 보면 다른 레시피의 자재가 영원히 안 들어오고,
|
|
475
605
|
* 그 레시피의 오더는 이유 없이 대기한다(자재 부족은 조용하다).
|
|
476
606
|
*/
|
|
477
|
-
const inputKey = this.
|
|
478
|
-
.flatMap(r => r.inputs.map(i => i.material))
|
|
479
|
-
.find(k => this.classOf(k) === gtin);
|
|
607
|
+
const inputKey = this.consumedMaterialKeys().find(k => this.classOf(k) === gtin);
|
|
480
608
|
if (!inputKey)
|
|
481
609
|
return;
|
|
610
|
+
/*
|
|
611
|
+
* ── 자리를 선언하지 않은 자재는 **입고를 만들 수 없다** (2026-08-22) ───────
|
|
612
|
+
* 보관처 선언은 확보에서는 정책이지만(없으면 품목 색인으로 찾는다), 입고는 자재를 **내려놓을 곳**이
|
|
613
|
+
* 필요하다. 커널은 그 자리를 지어내지 않는다.
|
|
614
|
+
*
|
|
615
|
+
* 그래서 만들지 않는다 — 다만 **조용히 빠지지 않는다.** 한 번 알린다: 그러지 않으면 「자극은 도는데
|
|
616
|
+
* 그 자재만 영원히 안 들어온다」가 되고 화면에 이유가 없다. 미러에서는 문제가 아니다(재고를 원본이
|
|
617
|
+
* 말한다) — 이 경로는 시뮬레이션의 입고 생성이다.
|
|
618
|
+
*/
|
|
619
|
+
if (!this.declaredLocationTypeOfMaterial(inputKey)) {
|
|
620
|
+
if (!this.arrivalsWithoutPlace.has(inputKey)) {
|
|
621
|
+
this.arrivalsWithoutPlace.add(inputKey);
|
|
622
|
+
console.warn(`[twin] material '${inputKey}' declares no locationType, so simulated arrivals cannot be created for it ` +
|
|
623
|
+
'(the kernel does not invent a place for material to sit). Stock for it must come from the source, or ' +
|
|
624
|
+
'declare `MaterialDef.locationType`. Allocation still works — it looks wherever the stock is.');
|
|
625
|
+
}
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
482
628
|
const store = this.locationOfMaterial(inputKey);
|
|
483
629
|
const epc = this.serialOf(inputKey, ++this.epcSeq);
|
|
484
630
|
this.items.set(epc, { epc, gtin, qty: 1, location: store.id, disposition: DISP.sellable });
|
|
@@ -570,18 +716,43 @@ export class MesKernel extends FlowEngine {
|
|
|
570
716
|
locsOfType.set(n.type, [n]);
|
|
571
717
|
}
|
|
572
718
|
for (const line of rc.inputs) {
|
|
719
|
+
/*
|
|
720
|
+
* **태그가 붙은 줄은 여기서 확보하지 않는다** (2026-08-22) — 그 공정에 이르렀을 때 확보한다
|
|
721
|
+
* (§`RecipePart.operation` · `recipeInputsAt`). 앞에서 전량 확보하면 뒤 공정의 자재 부족이 오더
|
|
722
|
+
* 착수 시점에 나타나거나(실제로는 나중 일) 그 사이 공정의 재공이 서지 않는다.
|
|
723
|
+
*/
|
|
724
|
+
if (line.operation)
|
|
725
|
+
continue;
|
|
573
726
|
const g = this.classOf(line.material);
|
|
574
|
-
/* 자리 필터는 남는다 — 완제품도 `sellable` 이라, 반제품→완제품 체인에서 이것이 없으면 산출물을 자재로 소비한다. */
|
|
575
|
-
const fromType = this.locationTypeOfMaterial(line.material);
|
|
576
727
|
/*
|
|
728
|
+
* ── 보관처 선언은 **정책이고, 없어도 돈다** (2026-08-22) ───────────────────
|
|
729
|
+
* 예전에는 `MaterialDef.locationType` 을 반드시 선언해야 했다. 그런데 **재고로 위치를 말하는
|
|
730
|
+
* 시스템**에는 그 선언이 없다 — 자재에 고정된 보관처를 두지 않는 것이 WMS 계열의 정상이고,
|
|
731
|
+
* 표준도 위치를 정의(`MaterialDefinition`)가 아니라 로트(`MaterialLot`)에 둔다. 실측: 첫 실
|
|
732
|
+
* 연동에서 원자재 986건 중 보관처가 선언된 것이 36건이었고, 레시피 937/1,408 이 실리지 못했다.
|
|
733
|
+
*
|
|
734
|
+
* 선언이 있으면 그것을 **현장의 정책**으로 읽어 그 타입의 자리만 본다(지금 거동 그대로).
|
|
735
|
+
* 없으면 **재고가 있는 곳에서 찾는다** — 품목 색인이 답하므로 전 로케이션을 훑지 않는다.
|
|
736
|
+
*
|
|
737
|
+
* 산출물을 자재로 먹지 않는 것은 **자리가 아니라 처분**이 막는다: 방금 만든 것과 변환을 기다리는
|
|
738
|
+
* 것은 `in_progress` 이고, 여기서 보는 것은 `sellable` 뿐이다.
|
|
739
|
+
*
|
|
577
740
|
* 후보를 배열로 모은다. 지연 순회(생성기)로 바꿔 보았으나 **더 느렸다** — 물품 16,000 규모에서
|
|
578
741
|
* 1562ms → 2723ms 였다. 항목마다 생성기 규약을 지나는 비용이 배열을 만드는 비용보다 크다.
|
|
579
|
-
* 측정이 그렇게 답했으므로 배열을 유지한다(§StockRequest.available 은 지연도 받는다).
|
|
580
742
|
*/
|
|
743
|
+
const fromType = this.declaredLocationTypeOfMaterial(line.material);
|
|
581
744
|
const available = [];
|
|
582
|
-
|
|
583
|
-
for (const
|
|
584
|
-
|
|
745
|
+
if (fromType) {
|
|
746
|
+
for (const n of locsOfType.get(fromType) ?? []) {
|
|
747
|
+
for (const i of this.items.at(n.id)) {
|
|
748
|
+
if (i.gtin === g && i.disposition === DISP.sellable)
|
|
749
|
+
available.push({ epc: i.epc, location: i.location, qty: 1 });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
for (const i of this.items.ofGtin(g)) {
|
|
755
|
+
if (i.disposition === DISP.sellable)
|
|
585
756
|
available.push({ epc: i.epc, location: i.location, qty: 1 });
|
|
586
757
|
}
|
|
587
758
|
}
|
|
@@ -666,7 +837,17 @@ export class MesKernel extends FlowEngine {
|
|
|
666
837
|
this.emitOrder(order);
|
|
667
838
|
return;
|
|
668
839
|
}
|
|
669
|
-
|
|
840
|
+
/*
|
|
841
|
+
* ── 산출물이 놓이는 자리 (2026-08-22) ─────────────────────────────────────
|
|
842
|
+
* 선언이 있으면 그것이 현장의 정책이다(완제품창고로 보낸다). **없으면 만들어진 자리에 그대로 둔다** —
|
|
843
|
+
* 그것이 지어낸 값이 아니라 사실이다: 그 물건은 방금 이 공정에서 생겼고, 변환의 `readPoint` 가 바로
|
|
844
|
+
* 이 자리다. 창고로 옮기는 것은 **다른 일**(이동)이고, 모델이 그것을 선언해야 일어난다.
|
|
845
|
+
*
|
|
846
|
+
* 예전에는 선언이 없으면 오류를 냈다. 그래서 보관처를 선언하지 않는 원본(재고로 위치를 말하는
|
|
847
|
+
* 시스템)은 레시피를 실을 수 없었다.
|
|
848
|
+
*/
|
|
849
|
+
const declaredFg = this.declaredLocationTypeOfMaterial(rc.outputs[0].material);
|
|
850
|
+
const fgStore = declaredFg ? this.locationOfMaterial(rc.outputs[0].material) : loc;
|
|
670
851
|
/*
|
|
671
852
|
* **완제품은 확보분 전량을 소비한다** — 하나만 소비하면 계보가 거짓이 되고 나머지가 영구 잠긴다.
|
|
672
853
|
*
|