@operato/twin-kernel 0.7.38 → 0.7.40
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 +200 -8
- package/dist/contract.js +42 -1
- package/dist/domain-definition.d.ts +17 -0
- package/dist/domain-definition.js +7 -0
- package/dist/ems-kernel.js +51 -1
- package/dist/epcis.d.ts +22 -0
- package/dist/epcis.js +91 -2
- package/dist/face2-adapter.d.ts +21 -1
- package/dist/face2-adapter.js +12 -1
- package/dist/flow-engine.d.ts +156 -8
- package/dist/flow-engine.js +324 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/kernel.d.ts +15 -0
- package/dist/kernel.js +50 -16
- package/dist/mes-kernel.d.ts +117 -27
- package/dist/mes-kernel.js +331 -168
- package/dist/observed-reducer.js +7 -4
- package/dist/yms-kernel.js +12 -5
- package/dist-cjs/index.cjs +642 -183
- package/package.json +1 -1
package/dist/mes-kernel.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* MES Kernel — 제조: BOM 조립 + 다단계 라우팅(cut→weld) + 수율 + 셋업/체인지오버 + OEE. mechanics 는 FlowEngine base.
|
|
3
|
-
* 부품 수령
|
|
3
|
+
* 부품 수령 → 작업지시(오더가 레시피를 든다) → 레시피 입력 확보 → 라우트 단계 진행 → 완제품(수율) → 저장.
|
|
4
4
|
*
|
|
5
5
|
* 도메인 깊이:
|
|
6
6
|
* ① BOM = 제품별 다품목 레시피(P1=2A+1B / P2=1A+2B — 상이 → 체인지오버 유발).
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* ③ 셋업/체인지오버 = work-center 가 제품 전환 시 셋업(changeoverKey=제품 gtin) → OEE Availability.
|
|
9
9
|
* ④ OEE = base 가 설비(설비)별 계측(가동/셋업/기아/품질). cut=cutter·weld=welder 이종 자원.
|
|
10
10
|
*/
|
|
11
|
+
import { identityGroundingOf } from "./contract.js";
|
|
11
12
|
import { firstFitPolicy } from "./allocation-policy.js";
|
|
12
13
|
import { FlowEngine } from "./flow-engine.js";
|
|
13
|
-
import { DISP, objectEvent, transactionEvent, gdtiUri, sgtinClass } from "./epcis.js";
|
|
14
|
+
import { DISP, objectEvent, transactionEvent, gdtiUri, sgtinClass, bizTransactionUri } from "./epcis.js";
|
|
14
15
|
import { MES_BIZSTEP, BTT_PRODORDER, sgtinUri } from "./mes-profile.js";
|
|
15
16
|
import { OP_PARAM } from "./domain-definition.js";
|
|
16
17
|
/* 아래 셋은 **기본값**이다 — 명세(OperationDef.duration·parameters)가 있으면 그 값이 이긴다.
|
|
@@ -20,35 +21,7 @@ const DEFAULT_CYCLE_MS = 40_000;
|
|
|
20
21
|
const DEFAULT_SETUP_MS = 15_000; // 체인지오버(제품 전환) 셋업 — OEE 가용성 손실
|
|
21
22
|
// MES 도메인 커맨드(Tier 2, 무방언 — MES 어휘는 MES 커널 소유). handleCommand 로 처리.
|
|
22
23
|
const MES_CMD = { changeover: 'mes.changeover' };
|
|
23
|
-
const CP = '0614141';
|
|
24
|
-
const WIP_ITEMREF = '066666';
|
|
25
|
-
const WIP_GTIN = sgtinClass(CP, WIP_ITEMREF);
|
|
26
24
|
const DEFAULT_YIELD = 0.8; // 양품률 기본값 — 현장 값은 OperationDef.parameters(OP_PARAM.yield) 로 온다
|
|
27
|
-
// 부품(공용) — BOM 라인의 품목 클래스.
|
|
28
|
-
const PART_A = { itemRef: '055551', gtin: sgtinClass(CP, '055551') };
|
|
29
|
-
const PART_B = { itemRef: '055552', gtin: sgtinClass(CP, '055552') };
|
|
30
|
-
// 제품 2종 — BOM 상이(제품 전환 시 cut/weld 체인지오버 셋업 발생).
|
|
31
|
-
const PRODUCTS = [
|
|
32
|
-
{ key: 'P1', ref: '077777', gtin: sgtinClass(CP, '077777'), bom: [{ part: PART_A, qty: 2 }, { part: PART_B, qty: 1 }] },
|
|
33
|
-
{ key: 'P2', ref: '077778', gtin: sgtinClass(CP, '077778'), bom: [{ part: PART_A, qty: 1 }, { part: PART_B, qty: 2 }] }
|
|
34
|
-
];
|
|
35
|
-
/** 편의 — 시나리오 skuMix 로 쓸 부품 클래스. */
|
|
36
|
-
export const MES_PART_GTINS = { partA: PART_A.gtin, partB: PART_B.gtin };
|
|
37
|
-
/** 편의 — 완제품 클래스(제품 2종). */
|
|
38
|
-
export const MES_PRODUCT_GTINS = { p1: PRODUCTS[0].gtin, p2: PRODUCTS[1].gtin };
|
|
39
|
-
/** 레거시 MES 제품 목록(gtin+라벨) — mes.changeover 대상 제품 소싱용. 정의-구동 전환 시 recipe 출력으로 교체. */
|
|
40
|
-
export const MES_PRODUCTS = PRODUCTS.map(p => ({ gtin: p.gtin, label: p.key }));
|
|
41
|
-
/*
|
|
42
|
-
* 트레일러 제조 라우트 — 순차 스테이션. 각 스테이션 완료가 다음 WIP 로 변환 + 다음 스테이션 태스크 발행.
|
|
43
|
-
* 첫 스테이션(cut)은 BOM 부품 N→WIP, 중간은 WIP→WIP, 마지막(assembly)은 WIP→완성차(+수율/OEE).
|
|
44
|
-
* 각 스테이션 = 이종 자원(resource) + 제품 전환 시 체인지오버 셋업.
|
|
45
|
-
*/
|
|
46
|
-
const ROUTE = [
|
|
47
|
-
{ kind: 'cut', locationType: 'cut-station', resource: 'cutter' },
|
|
48
|
-
{ kind: 'weld', locationType: 'weld-station', resource: 'welder' },
|
|
49
|
-
{ kind: 'paint', locationType: 'paint-booth', resource: 'painter' },
|
|
50
|
-
{ kind: 'assembly', locationType: 'assembly-line', resource: 'assembler' }
|
|
51
|
-
];
|
|
52
25
|
/*
|
|
53
26
|
* 정의-구동 모드 스펙은 **계약의 `ProductionSpec`** 이다(`contract.ts`).
|
|
54
27
|
*
|
|
@@ -69,7 +42,6 @@ export class MesKernel extends FlowEngine {
|
|
|
69
42
|
/* 정의가 있으면 그 안의 오퍼레이션 명세(소요·변동·모수)를 커널이 소비한다 — 없으면 기본값 경로. */
|
|
70
43
|
if (productionSpec?.definition?.operations) {
|
|
71
44
|
this.loadOperations(productionSpec.definition.operations);
|
|
72
|
-
this.assertNoDoubleProduction(productionSpec.definition.operations);
|
|
73
45
|
}
|
|
74
46
|
/*
|
|
75
47
|
* **직렬 생산에는 바인딩이 있어야 한다.** 레시피 경로는 자재 키를 실제 GS1 식별자로 옮겨야
|
|
@@ -81,6 +53,10 @@ export class MesKernel extends FlowEngine {
|
|
|
81
53
|
throw new Error('recipe-driven production needs `binding` and `companyPrefix` — the definition does not know GTINs, ' +
|
|
82
54
|
'so material keys cannot be resolved to GS1 identifiers');
|
|
83
55
|
}
|
|
56
|
+
/* 겹침 검사는 **바인딩 검증 뒤**다 — 검사 자체가 자재 키를 식별자로 옮겨 견주기 때문이다. */
|
|
57
|
+
if (productionSpec?.definition?.operations) {
|
|
58
|
+
this.assertNoDoubleProduction(productionSpec.definition.operations);
|
|
59
|
+
}
|
|
84
60
|
}
|
|
85
61
|
/**
|
|
86
62
|
* **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
|
|
@@ -95,13 +71,78 @@ export class MesKernel extends FlowEngine {
|
|
|
95
71
|
* 모든 계산이 거짓이 된다).
|
|
96
72
|
*/
|
|
97
73
|
assertNoDoubleProduction(ops) {
|
|
98
|
-
const
|
|
74
|
+
const finals = new Set((this.productionSpec?.definition?.recipes ?? []).flatMap(r => (r.outputs ?? []).map(o => this.classOf(o.material))));
|
|
75
|
+
const bad = ops
|
|
76
|
+
.filter(o => (o.materialSpecification ?? []).some(m => m.use === 'produced' && m.materialDefinition && finals.has(m.materialDefinition)))
|
|
77
|
+
.map(o => o.key);
|
|
99
78
|
if (!bad.length)
|
|
100
79
|
return;
|
|
101
|
-
throw new Error(`MES recipe already produces
|
|
102
|
-
'(that would create the same output twice).
|
|
80
|
+
throw new Error(`MES recipe already produces its output — operations [${bad.join(', ')}] must not also declare that same material as ` +
|
|
81
|
+
"materialSpecification use:'produced' (that would create the same output twice). Declare intermediate outputs freely; " +
|
|
82
|
+
'the final output belongs to the recipe.');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* **레시피 모드에서는 MES 가 산출을 소유한다** — 코어는 비켜선다(§`producesOwnOutputs`).
|
|
86
|
+
*
|
|
87
|
+
* MES 의 산출은 개체 정체성(직렬번호) + 수율 + 오더 계보를 갖는다. 코어의 일반 산출은 비직렬
|
|
88
|
+
* 클래스+수량이라 그 셋을 표현하지 못한다. 둘이 같은 선언을 보고 각자 만들면 재고가 두 배가 된다.
|
|
89
|
+
*
|
|
90
|
+
* 레시피가 없으면(유통가공 등) 소유를 주장하지 않는다 — 그때는 코어의 산출이 맞다.
|
|
91
|
+
*/
|
|
92
|
+
producesOwnOutputs(_opKey) {
|
|
93
|
+
return !!this.productionSpec?.definition?.recipes?.length;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 이 트윈의 정체성 근거 — 선언에서 파생한다(§`identityGroundingOf`).
|
|
97
|
+
*
|
|
98
|
+
* 선언이 근거를 정한다: 이름공간을 선언했으면 `declared`, 발급을 주장했으면 `issued`(주장이지 사실이
|
|
99
|
+
* 아니다). 선언이 없는 MES 트윈은 이제 만들 수 없으므로(`loadTwinModel` 이 거절한다) `fabricated` 로
|
|
100
|
+
* 판정될 일이 없다.
|
|
101
|
+
*/
|
|
102
|
+
identityGroundingView() {
|
|
103
|
+
return identityGroundingOf(this.productionSpec);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 선언이 말하는 자리 타입이 **이 트윈에 있나** — 로드 시점에 한 번, 모아서 말한다 (2026-08-20).
|
|
107
|
+
*
|
|
108
|
+
* ── 왜 로드 시점인가 ───────────────────────────────────────────────────────
|
|
109
|
+
* 이것은 **선언의 흠**이다: 그 트윈의 생산 선언과 그 트윈의 모델이 서로 어긋났다. 틱에서 발견하면
|
|
110
|
+
* 그 트윈은 「도는데 아무 일도 안 일어나는」 모습이 된다 — 실제로 `allocateDef` 가 첫 공정의 자리를
|
|
111
|
+
* 못 찾으면 조용히 `return` 했고, 오더는 영원히 배정되지 않은 채 화면에는 「running」이라 적혔다.
|
|
112
|
+
*
|
|
113
|
+
* ── 좁히지 않는다 ─────────────────────────────────────────────────────────
|
|
114
|
+
* 「이 트윈이 실제로 지나는 라우트만 본다」는 예외를 한 번 검토했다가 버렸다. 근거로 삼은 「여러
|
|
115
|
+
* 트윈이 한 선언을 공유한다」가 **이 코드에 없는 이야기**였다: 호스트는 트윈마다 자기 모델에서
|
|
116
|
+
* 선언을 만든다(`productionSpecOf(model)`). 없는 사정을 상상해 검사에 구멍을 내지 않는다.
|
|
117
|
+
*
|
|
118
|
+
* 모아서 한 번에 말한다 — 하나씩 실패하면 사람이 같은 기동을 열 번 반복한다.
|
|
119
|
+
*/
|
|
120
|
+
loadTwinModel(def) {
|
|
121
|
+
super.loadTwinModel(def);
|
|
122
|
+
/*
|
|
123
|
+
* 선언이 없으면 이 커널은 무엇을 만들지 알 수 없다. 예전에는 내장 상수(부품·제품·라우트)로 동작했고
|
|
124
|
+
* 그 상수가 모델에 없는 품목을 만들어 냈다. 그 경로를 제거했으므로 선언은 이제 필수다.
|
|
125
|
+
*/
|
|
126
|
+
if (!this.productionSpec) {
|
|
127
|
+
throw new Error('MES twin requires a production declaration (ProductionSpec): materials, routes and recipes. ' +
|
|
128
|
+
'The kernel no longer carries a built-in demo product — what is made is decided by the model.');
|
|
129
|
+
}
|
|
130
|
+
const have = new Set([...this.locations.values()].map(l => l.type));
|
|
131
|
+
const missing = [];
|
|
132
|
+
for (const m of this.productionSpec.definition.materials ?? []) {
|
|
133
|
+
if (m.locationType && !have.has(m.locationType))
|
|
134
|
+
missing.push(`material '${m.key}' → '${m.locationType}'`);
|
|
135
|
+
}
|
|
136
|
+
for (const op of this.productionSpec.definition.operations ?? []) {
|
|
137
|
+
if (op.locationType && !have.has(op.locationType))
|
|
138
|
+
missing.push(`operation '${op.key}' → '${op.locationType}'`);
|
|
139
|
+
}
|
|
140
|
+
if (missing.length) {
|
|
141
|
+
throw new Error(`production declaration names location types this twin does not have: ${missing.join(' · ')}. ` +
|
|
142
|
+
`This twin has [${[...have].sort().join(', ')}]. Declare those locations in the model, or fix the declared ` +
|
|
143
|
+
'locationType — the kernel does not invent a place for material to sit.');
|
|
144
|
+
}
|
|
103
145
|
}
|
|
104
|
-
productOf(gtin) { return PRODUCTS.find(p => p.gtin === gtin); }
|
|
105
146
|
/**
|
|
106
147
|
* MES 도메인 커맨드(Tier 2) — mes.changeover: 설비를 제품 gtin 으로 강제 전환.
|
|
107
148
|
* 자동 체인지오버(task.changeoverKey 상이 시 셋업)의 수동 버전 — 운영자가 사전 전환(툴링 교체) 지시.
|
|
@@ -127,125 +168,54 @@ export class MesKernel extends FlowEngine {
|
|
|
127
168
|
}
|
|
128
169
|
return super.handleCommand(cmd);
|
|
129
170
|
}
|
|
130
|
-
/** 부품 수령
|
|
171
|
+
/** 부품 수령 — 도착한 품목이 선언된 입력 자재면 그 자재가 선언한 자리에 생성. */
|
|
131
172
|
onArrival(spec) {
|
|
132
|
-
|
|
133
|
-
return this.onArrivalDef(spec);
|
|
134
|
-
const rawStore = this.locationByType('raw-store');
|
|
135
|
-
if (!rawStore)
|
|
136
|
-
return;
|
|
137
|
-
const gtin = this.pickGtin(spec.content.skuMix);
|
|
138
|
-
const part = [PART_A, PART_B].find(p => p.gtin === gtin);
|
|
139
|
-
if (!part)
|
|
140
|
-
return; // BOM 부품 아님
|
|
141
|
-
const epc = sgtinUri(CP, part.itemRef, ++this.epcSeq);
|
|
142
|
-
this.items.set(epc, { epc, gtin: part.gtin, qty: 1, location: rawStore.id, disposition: DISP.sellable });
|
|
143
|
-
rawStore.occupancy++;
|
|
144
|
-
this.emit(objectEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.receiving, disposition: DISP.sellable, epcList: [epc], quantityList: [{ epcClass: part.gtin, quantity: 1 }], readPoint: rawStore.id, bizLocation: rawStore.id }));
|
|
173
|
+
return this.onArrivalDef(spec);
|
|
145
174
|
}
|
|
146
|
-
/** 작업지시 —
|
|
175
|
+
/** 작업지시 — 오더가 자기 레시피를 든다(선언된 레시피 중에서). */
|
|
147
176
|
onOrder(_spec) {
|
|
148
|
-
|
|
149
|
-
return this.onOrderDef(_spec);
|
|
150
|
-
const product = PRODUCTS[this.orderSeq % PRODUCTS.length];
|
|
151
|
-
const id = `order-${++this.orderSeq}`;
|
|
152
|
-
const wo = gdtiUri(CP, '403', ++this.soSeq);
|
|
153
|
-
const order = { id, kind: 'workorder', status: 'created', gtin: product.gtin, requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
|
|
154
|
-
this.orders.set(id, order);
|
|
155
|
-
this.emitOrder(order);
|
|
177
|
+
return this.onOrderDef(_spec);
|
|
156
178
|
}
|
|
157
|
-
/** 할당 —
|
|
179
|
+
/** 할당 — 레시피 입력 전량 확보 후 첫 라우트 단계 태스크(부족하면 대기). */
|
|
158
180
|
allocate(o) {
|
|
159
|
-
|
|
160
|
-
return this.allocateDef(o);
|
|
161
|
-
const s0 = ROUTE[0];
|
|
162
|
-
const first = this.locationByType(s0.locationType);
|
|
163
|
-
const product = this.productOf(o.gtin);
|
|
164
|
-
if (!first || !product)
|
|
165
|
-
return;
|
|
166
|
-
// BOM 전 라인 확보 확인(부족하면 아무것도 예약 안 하고 대기)
|
|
167
|
-
const picks = [];
|
|
168
|
-
for (const line of product.bom) {
|
|
169
|
-
const available = [...this.items.values()]
|
|
170
|
-
.filter(i => i.gtin === line.part.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'raw-store')
|
|
171
|
-
.map(i => ({ epc: i.epc, location: i.location, qty: 1 }));
|
|
172
|
-
const chosen = this.policy.selectStock({ gtin: line.part.gtin, qty: line.qty, available });
|
|
173
|
-
if (chosen.length < line.qty)
|
|
174
|
-
return; // 부품 부족 → 대기
|
|
175
|
-
picks.push(...chosen);
|
|
176
|
-
}
|
|
177
|
-
for (const epc of picks)
|
|
178
|
-
o.allocated.push(epc);
|
|
179
|
-
this.reserve(picks, MES_BIZSTEP.producing);
|
|
180
|
-
this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
|
|
181
|
-
this.emitStation(o, s0, o.allocated[0], product.gtin);
|
|
182
|
-
o.status = 'op-' + s0.kind;
|
|
183
|
-
this.emitOrder(o);
|
|
184
|
-
}
|
|
185
|
-
/** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
|
|
186
|
-
emitStation(o, stage, itemEpc, changeoverKey) {
|
|
187
|
-
const loc = this.locationByType(stage.locationType);
|
|
188
|
-
const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: 'created', itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: loc.id, toNode: loc.id, resourceKind: stage.resource }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: stage.resource, changeoverKey, setupMs: this.paramDuration(stage.kind, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: 'process' };
|
|
189
|
-
this.tasks.set(task.id, task);
|
|
190
|
-
this.emitTask(task);
|
|
181
|
+
return this.allocateDef(o);
|
|
191
182
|
}
|
|
192
|
-
/** op 완료 = 변환. 라우트 인덱스로 분기: 첫=BOM 소비→WIP, 중간=WIP→WIP, 마지막(조립)=WIP→완성차(수율→OEE 품질). */
|
|
193
183
|
/**
|
|
194
184
|
* MES 는 완료 시점에 **오더와 제품 정의**를 딛고 선다 — 둘 중 하나만 없어도 끝맺을 수 없다.
|
|
195
185
|
*
|
|
196
186
|
* 씨앗이 이미 이행된 오더를 주입하지 않으므로(남은 수량 0) 그 오더에 딸린 작업만 남을 수 있고,
|
|
197
|
-
* 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서
|
|
187
|
+
* 관측이 오더 연결을 담지 못한 작업도 있다. 그때 예전에는 `order.gtin` 에서 오류가 났고 **그 예외
|
|
198
188
|
* 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
|
|
199
189
|
*/
|
|
200
190
|
canComplete(t) {
|
|
201
|
-
|
|
202
|
-
return !!(t.orderId && this.orders.get(t.orderId));
|
|
203
|
-
const order = t.orderId ? this.orders.get(t.orderId) : undefined;
|
|
204
|
-
return !!order && !!this.productOf(order.gtin);
|
|
191
|
+
return !!(t.orderId && this.orders.get(t.orderId));
|
|
205
192
|
}
|
|
206
193
|
onTaskComplete(t) {
|
|
207
|
-
|
|
208
|
-
return this.onTaskCompleteDef(t);
|
|
209
|
-
/* 여기 도달했다면 `canComplete` 가 이미 확인했다 — 그래도 단정(`!`)은 쓰지 않는다. */
|
|
210
|
-
const order = this.orders.get(t.orderId);
|
|
211
|
-
const product = order && this.productOf(order.gtin);
|
|
212
|
-
if (!order || !product)
|
|
213
|
-
throw new Error(`task ${t.id}: order/product vanished between the core check and the domain hook`);
|
|
214
|
-
const i = ROUTE.findIndex(s => s.kind === t.kind);
|
|
215
|
-
const loc = this.locations.get(t.toNode);
|
|
216
|
-
const isLast = i === ROUTE.length - 1;
|
|
217
|
-
if (!isLast) {
|
|
218
|
-
// 중간 스테이션 — 입력(첫=BOM 전부 / 이후=이전 WIP) → 다음 WIP, 다음 스테이션 태스크.
|
|
219
|
-
const inputs = order.allocated.slice();
|
|
220
|
-
const wip = sgtinUri(CP, WIP_ITEMREF, ++this.wipSeq);
|
|
221
|
-
this.transform(inputs, [{ epc: wip, gtin: WIP_GTIN, qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep: MES_BIZSTEP.producing, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
|
|
222
|
-
order.allocated = [wip];
|
|
223
|
-
const next = ROUTE[i + 1];
|
|
224
|
-
this.emitStation(order, next, wip, product.gtin);
|
|
225
|
-
order.status = 'op-' + next.kind;
|
|
226
|
-
this.emitOrder(order);
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
// 마지막 스테이션(조립) → 완성차 (transform 1→1, disposition 으로 수율 loss → OEE 품질)
|
|
230
|
-
const fgStore = this.locationByType('fg-store');
|
|
231
|
-
const wip = order.allocated[0];
|
|
232
|
-
const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
|
|
233
|
-
/* 판정을 **작업에 적는다** — 그 자리에서 적어야 저널에 종류와 함께 남고, 이력에서 수율을 배울 수 있다. */
|
|
234
|
-
t.outcome = good ? 'good' : 'scrap';
|
|
235
|
-
this.recordOutput(t.resource, good); // OEE 품질(마지막 자원별 양품/불량)
|
|
236
|
-
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
237
|
-
const outputEpc = sgtinUri(CP, product.ref, ++this.prodSeq);
|
|
238
|
-
this.transform([wip], [{ epc: outputEpc, gtin: product.gtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep: MES_BIZSTEP.producing, disposition: disp, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
|
|
239
|
-
this.emit(objectEvent({ eventTime: this.now(), action: 'OBSERVE', bizStep: MES_BIZSTEP.storing, disposition: disp, epcList: [outputEpc], quantityList: [{ epcClass: product.gtin, quantity: 1 }], readPoint: fgStore.id, bizLocation: fgStore.id }));
|
|
240
|
-
order.allocated = [];
|
|
241
|
-
order.fulfilled = 1;
|
|
242
|
-
order.status = good ? 'produced' : 'scrapped';
|
|
243
|
-
this.emitOrder(order);
|
|
194
|
+
return this.onTaskCompleteDef(t);
|
|
244
195
|
}
|
|
245
196
|
// ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
|
|
246
|
-
|
|
197
|
+
/** 선언된 레시피 전부 — 오더가 자기 것을 고르고, 수령이 전부의 소요를 본다. */
|
|
198
|
+
recipesDef() {
|
|
199
|
+
return this.productionSpec.definition.recipes ?? [];
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 이 오더의 레시피 — **오더가 들면 그것, 없으면 선언 수준의 기본**(§`FlowOrder.recipeKey`).
|
|
203
|
+
*
|
|
204
|
+
* 오더가 든 키가 선언에 없으면 **말한다**: 조용히 첫 레시피로 떨어지면 그 오더는 다른 물건을 만들고,
|
|
205
|
+
* 그 뒤 계보·재고·수율이 전부 엉뚱한 품목에 붙는다.
|
|
206
|
+
*/
|
|
207
|
+
recipeDef(order) {
|
|
247
208
|
const d = this.productionSpec.definition;
|
|
248
|
-
|
|
209
|
+
const key = order?.recipeKey ?? this.productionSpec.recipeKey;
|
|
210
|
+
if (key) {
|
|
211
|
+
const found = d.recipes?.find(r => r.key === key);
|
|
212
|
+
if (!found) {
|
|
213
|
+
throw new Error(`recipe '${key}' is not declared. Declared: [${(d.recipes ?? []).map(r => r.key).join(', ') || '(none)'}]. ` +
|
|
214
|
+
`The kernel does not substitute another recipe — that would make this order produce a different item.`);
|
|
215
|
+
}
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
return d.recipes[0];
|
|
249
219
|
}
|
|
250
220
|
/** 자재 키 → 구체 gtin 클래스(idpat). 구체 식별은 바인딩+prefix 로 인스턴스가 주입. */
|
|
251
221
|
classOf(materialKey) {
|
|
@@ -254,61 +224,205 @@ export class MesKernel extends FlowEngine {
|
|
|
254
224
|
serialOf(materialKey, serial) {
|
|
255
225
|
return sgtinUri(this.productionSpec.companyPrefix, this.productionSpec.binding[materialKey], serial);
|
|
256
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* 이 공정이 **만든다고 선언한 품목** — 표준 `OpMaterialSpecification` `MaterialUse='produced'`.
|
|
229
|
+
*
|
|
230
|
+
* ── 왜 이 함수가 생겼나 (2026-08-20) ──────────────────────────────────────
|
|
231
|
+
* 예전에는 라우트의 중간 단계마다 커널이 `sgtinUri(prefix, 'WIP', ++seq)` 로 **품목을 지어냈다.**
|
|
232
|
+
* 그러면 배합물·가열완료물·소분물이 전부 같은 상품코드가 된다 — 저널에 남는 것은 「무엇인지 아무도
|
|
233
|
+
* 답할 수 없는 물건」이고, 그것이 영구 기록이 된다. 게다가 그 조립은 `binding` 을 **건너뛴다**:
|
|
234
|
+
* 다른 모든 품목은 `classOf`/`serialOf` 로 인스턴스가 주입한 품번을 지나는데, `'WIP'` 만 커널이
|
|
235
|
+
* itemRef 자리에 리터럴을 넣었다.
|
|
236
|
+
*
|
|
237
|
+
* ── 규율 ──────────────────────────────────────────────────────────────────
|
|
238
|
+
* 만드는 것은 **선언이 정한다**. 선언이 없으면 만들지 않는다(§`produceMaterials` 와 같은 규율 —
|
|
239
|
+
* 「무엇을 만드는지 말하지 않으면 만들지 않는다」). 있으면 그 품목으로 만든다. 어느 쪽도 지어내지 않는다.
|
|
240
|
+
*
|
|
241
|
+
* 둘 이상을 선언하면 **거부한다**: 어느 것이 이 단계의 산출인지는 모델이 말할 일이고, 커널이 첫 줄을
|
|
242
|
+
* 고르면 나머지는 조용히 사라진다.
|
|
243
|
+
*/
|
|
244
|
+
producedMaterialKeyOf(opKey) {
|
|
245
|
+
const made = (this.operationSpecs.get(opKey)?.materialSpecification ?? []).filter(m => m.use === 'produced');
|
|
246
|
+
if (!made.length)
|
|
247
|
+
return undefined;
|
|
248
|
+
if (made.length > 1) {
|
|
249
|
+
throw new Error(`operation '${opKey}' declares ${made.length} produced materials — the kernel does not choose one. ` +
|
|
250
|
+
`Declare exactly one 'produced' material for this step, or split the step.`);
|
|
251
|
+
}
|
|
252
|
+
const declared = made[0].materialDefinition;
|
|
253
|
+
if (!declared) {
|
|
254
|
+
/* 등급만으로는 품목이 정해지지 않는다 — 만들지 않는 것이 지어내는 것보다 낫다. */
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
return this.materialKeyOfClass(declared, opKey);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* 표준 GTIN 클래스 → **모델의 자재 키.**
|
|
261
|
+
*
|
|
262
|
+
* 공정 명세는 표준 식별자(`MaterialDefinitionID` = GTIN idpat)로 품목을 가리키고, MES 프로파일의
|
|
263
|
+
* 나머지는 자재 키(`MaterialDef.key` + `binding`)로 가리킨다. **둘은 다른 층이고 `binding` 이 통로다.**
|
|
264
|
+
* 그래서 선언된 식별자가 이 트윈의 `binding` 을 지나 나오는 것인지 되짚어 확인한다.
|
|
265
|
+
*
|
|
266
|
+
* 없으면 **모델 위반이므로 멈춘다** — 커널이 바인딩 없는 품목의 정체성을 만들어 낼 자리가 없다.
|
|
267
|
+
*/
|
|
268
|
+
materialKeyOfClass(gtinClass, opKey) {
|
|
269
|
+
const binding = this.productionSpec.binding;
|
|
270
|
+
const key = Object.keys(binding).find(k => this.classOf(k) === gtinClass);
|
|
271
|
+
if (!key) {
|
|
272
|
+
throw new Error(`operation '${opKey}' declares produced material '${gtinClass}', which no declared material binds to. ` +
|
|
273
|
+
`Known: ${Object.keys(binding).map(k => `${k}=${this.classOf(k)}`).join(', ') || '(none)'}. ` +
|
|
274
|
+
`Declare that material in the model with a binding — the kernel does not invent an identity for it.`);
|
|
275
|
+
}
|
|
276
|
+
return key;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* 이 자재가 놓이는 **자리 타입** — 선언이 권위다(`MaterialDef.locationType`).
|
|
280
|
+
*
|
|
281
|
+
* 예전에는 이 자리에 `'raw-store'`·`'fg-store'` 가 박혀 있었다. 그래서 현장은 자기 창고를 그 이름으로
|
|
282
|
+
* **개명해야** 트윈이 굴러갔다 — 커널이 현장의 낱말을 정하는 셈이었다. 선언이 없으면 **지어내지 않고
|
|
283
|
+
* 말한다**: 예전 거동(조용한 return)에서는 아무도 왜 자재가 들어오지 않는지 알 수 없었다.
|
|
284
|
+
*
|
|
285
|
+
* 레거시(선언 없는 내장 프로파일) 경로는 이 함수를 쓰지 않는다 — 그쪽에는 대조할 선언이 없으므로
|
|
286
|
+
* 커널 어휘 자체가 계약이다.
|
|
287
|
+
*/
|
|
288
|
+
locationTypeOfMaterial(materialKey) {
|
|
289
|
+
const type = this.productionSpec.definition.materials?.find(m => m.key === materialKey)?.locationType;
|
|
290
|
+
if (!type)
|
|
291
|
+
throw new Error(`material '${materialKey}': MaterialDef.locationType 미선언 — 자재가 놓이는 자리를 커널이 지어낼 수 없다`);
|
|
292
|
+
return type;
|
|
293
|
+
}
|
|
294
|
+
/** 그 자리 타입의 자리 — 이 트윈에 없으면 말한다(선언과 모델이 어긋난 사실이다). */
|
|
295
|
+
locationOfMaterial(materialKey) {
|
|
296
|
+
const type = this.locationTypeOfMaterial(materialKey);
|
|
297
|
+
const loc = this.locationByType(type);
|
|
298
|
+
if (!loc)
|
|
299
|
+
throw new Error(`material '${materialKey}': locationType '${type}' 인 자리가 이 트윈에 없다`);
|
|
300
|
+
return loc;
|
|
301
|
+
}
|
|
257
302
|
/**
|
|
258
303
|
* 라우트를 용량 계산에 알려 준다 — 수율을 거슬러 올릴 때 순서가 곧 계산이다.
|
|
259
304
|
*
|
|
260
305
|
* 순서를 모르면 선언 순서대로 세는데, 그러면 하류에서 잃는 몫이 엉뚱한 공정에 얹힌다(조용히
|
|
261
306
|
* 틀린다). 생산 정의를 가진 커널만 이 답을 안다.
|
|
262
307
|
*/
|
|
308
|
+
/*
|
|
309
|
+
* ── 레시피가 여럿이면 「그 라우트」가 하나가 아니다 (2026-08-20) ────────────
|
|
310
|
+
* 용량은 트윈 수준의 질문이다(「이 공장이 하루 몇 대」). 레시피마다 라우트가 다르면 그 답이 제품
|
|
311
|
+
* 구성에 따라 달라지는데, **하나를 골라 답하면 능력 숫자가 조용히 거짓이 된다.** 그래서 갈릴 때는
|
|
312
|
+
* 답하지 않고(`undefined`) 서로 다르다는 사실을 `capacity()` 가 함께 낸다 — `mixedCalendars` 와 같은 규율.
|
|
313
|
+
*/
|
|
263
314
|
routeKeys() {
|
|
264
315
|
if (!this.productionSpec)
|
|
265
316
|
return undefined;
|
|
266
|
-
const
|
|
267
|
-
|
|
317
|
+
const routes = this.distinctRouteKeys();
|
|
318
|
+
if (routes.length !== 1)
|
|
319
|
+
return undefined;
|
|
320
|
+
return this.productionSpec.definition.routes?.find(r => r.key === routes[0])?.steps;
|
|
268
321
|
}
|
|
269
|
-
/**
|
|
270
|
-
|
|
322
|
+
/** 선언된 레시피들이 쓰는 **서로 다른** 라우트 키들. 하나면 용량을 답할 수 있다. */
|
|
323
|
+
distinctRouteKeys() {
|
|
324
|
+
if (!this.productionSpec)
|
|
325
|
+
return [];
|
|
326
|
+
return [...new Set(this.recipesDef().map(r => r.route).filter((k) => !!k))];
|
|
327
|
+
}
|
|
328
|
+
/** recipe.route → 오퍼레이션 시퀀스 해소. 오더가 자기 레시피를 들면 그 라우트다. */
|
|
329
|
+
routeOps(order) {
|
|
271
330
|
const d = this.productionSpec.definition;
|
|
272
|
-
const route = d.routes?.find(r => r.key === this.recipeDef().route);
|
|
331
|
+
const route = d.routes?.find(r => r.key === this.recipeDef(order).route);
|
|
273
332
|
return (route?.steps ?? []).map(sk => d.operations?.find(o => o.key === sk)).filter((o) => !!o);
|
|
274
333
|
}
|
|
275
|
-
/** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면
|
|
334
|
+
/** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면 **그 자재가 선언한 자리**에 생성. */
|
|
276
335
|
onArrivalDef(spec) {
|
|
277
|
-
const rawStore = this.locationByType('raw-store');
|
|
278
|
-
if (!rawStore)
|
|
279
|
-
return;
|
|
280
336
|
const gtin = this.pickGtin(spec.content.skuMix);
|
|
281
337
|
/* 품목 구성이 비어 있으면 도착을 만들지 않는다 — 무엇이 왔는지 말할 수 없는 입고는 사실이 아니다. */
|
|
282
338
|
if (!gtin)
|
|
283
339
|
return;
|
|
284
|
-
|
|
340
|
+
/*
|
|
341
|
+
* **선언된 레시피 **전부**의 소요를 본다** — 하나만 보면 다른 레시피의 자재가 영원히 안 들어오고,
|
|
342
|
+
* 그 레시피의 오더는 이유 없이 대기한다(자재 부족은 조용하다).
|
|
343
|
+
*/
|
|
344
|
+
const inputKey = this.recipesDef()
|
|
345
|
+
.flatMap(r => r.inputs.map(i => i.material))
|
|
346
|
+
.find(k => this.classOf(k) === gtin);
|
|
285
347
|
if (!inputKey)
|
|
286
348
|
return;
|
|
349
|
+
const store = this.locationOfMaterial(inputKey);
|
|
287
350
|
const epc = this.serialOf(inputKey, ++this.epcSeq);
|
|
288
|
-
this.items.set(epc, { epc, gtin, qty: 1, location:
|
|
289
|
-
|
|
290
|
-
this.emit(objectEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.receiving, disposition: DISP.sellable, epcList: [epc], quantityList: [{ epcClass: gtin, quantity: 1 }], readPoint:
|
|
351
|
+
this.items.set(epc, { epc, gtin, qty: 1, location: store.id, disposition: DISP.sellable });
|
|
352
|
+
store.occupancy++;
|
|
353
|
+
this.emit(objectEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.receiving, disposition: DISP.sellable, epcList: [epc], quantityList: [{ epcClass: gtin, quantity: 1 }], readPoint: store.id, bizLocation: store.id }));
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 정의 모드 작업지시 — 레시피 산출물 1개. **오더가 자기 레시피를 든다**(§`FlowOrder.recipeKey`).
|
|
357
|
+
*
|
|
358
|
+
* 선언 수준의 `recipeKey` 가 있으면 그 하나만 만든다(현장이 한 품목만 돌린다고 말한 것이다). 없으면
|
|
359
|
+
* 선언된 레시피를 **차례로** 돈다 — 그것이 제품 전환이고, 체인지오버 셋업은 `changeoverKey`(산출물
|
|
360
|
+
* 클래스)가 달라지는 것으로 자동으로 따라온다. 커널 상수 제품 목록이 필요했던 이유가 이것이었다.
|
|
361
|
+
*
|
|
362
|
+
* 차례는 `orderSeq` 로 정한다 — 난수를 쓰면 fork 예측이 결정적이지 않게 된다.
|
|
363
|
+
*/
|
|
364
|
+
/**
|
|
365
|
+
* **작업지시의 거래 문서 식별자** — 선언이 정하고, 커널은 문서 타입을 정하지 않는다.
|
|
366
|
+
*
|
|
367
|
+
* ── 무엇이 틀렸나 (2026-08-21) ────────────────────────────────────────────
|
|
368
|
+
* 여기는 `gdtiUri(prefix, '403', seq)` 였다. GDTI 는 `회사 프리픽스 + 문서 타입 + 일련번호`이고
|
|
369
|
+
* **문서 타입은 GS1 이 공표하는 목록이 아니다** — 프리픽스를 배정받은 회사가 자기 번호 용량에서
|
|
370
|
+
* 정한다. 그런데 커널이 `'403'` 을 스스로 정해 저널에 영구히 기록했다. 회사의 배정 권한을 커널이
|
|
371
|
+
* 대신 행사한 것이고, 프리픽스 날조와 같은 종류다.
|
|
372
|
+
*
|
|
373
|
+
* ── 순서 ──────────────────────────────────────────────────────────────────
|
|
374
|
+
* ① 선언된 이름공간이 있으면 그 아래 `bt` 형태로 만든다(CBV §8.5.4·§8.5.5). **문서 타입이 필요
|
|
375
|
+
* 없는 길**이고, 회사가 도메인만 있으면 되므로 진입장벽이 가장 낮다.
|
|
376
|
+
* ② 없고 문서 타입을 선언했으면 GDTI 로 만든다(그 현장이 GDTI 를 쓰는 것이다).
|
|
377
|
+
* ③ 둘 다 없으면 **말한다.** 지어내면 그 값이 영구 기록이 되고, 어느 화면도 그것이 발명된
|
|
378
|
+
* 번호라고 말해 주지 않는다.
|
|
379
|
+
*/
|
|
380
|
+
workOrderId(serial) {
|
|
381
|
+
const identity = this.productionSpec.identity;
|
|
382
|
+
for (const ns of identity?.namespaces ?? []) {
|
|
383
|
+
const uri = bizTransactionUri(ns, serial);
|
|
384
|
+
if (uri)
|
|
385
|
+
return uri;
|
|
386
|
+
}
|
|
387
|
+
const docType = identity?.documentTypes?.workorder;
|
|
388
|
+
if (docType)
|
|
389
|
+
return gdtiUri(this.productionSpec.companyPrefix, docType, serial);
|
|
390
|
+
throw new Error('work order needs an identifier the model declares: either `identity.namespaces` (CBV §8.5.4/§8.5.5 ' +
|
|
391
|
+
'`.../bt/<id>`) or `identity.documentTypes.workorder` (the GDTI document type this site assigns). ' +
|
|
392
|
+
'The kernel does not choose a document type — GS1 assigns the company prefix, the company assigns the type.');
|
|
291
393
|
}
|
|
292
|
-
/** 정의 모드 작업지시 — 레시피 산출물 1개. */
|
|
293
394
|
onOrderDef(_spec) {
|
|
294
|
-
const rc = this.recipeDef();
|
|
295
395
|
const id = `order-${++this.orderSeq}`;
|
|
296
|
-
const
|
|
297
|
-
const
|
|
396
|
+
const recipes = this.recipesDef();
|
|
397
|
+
const chosen = this.productionSpec.recipeKey ? undefined : recipes[(this.orderSeq - 1) % recipes.length];
|
|
398
|
+
const rc = this.recipeDef(chosen ? { recipeKey: chosen.key } : undefined);
|
|
399
|
+
const wo = this.workOrderId(++this.soSeq);
|
|
400
|
+
const order = { id, kind: 'workorder', status: 'created', ...(chosen ? { recipeKey: chosen.key } : {}), gtin: this.classOf(rc.outputs[0].material), requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
|
|
298
401
|
this.orders.set(id, order);
|
|
299
402
|
this.emitOrder(order);
|
|
300
403
|
}
|
|
301
404
|
/** 정의 모드 할당 — 레시피 입력 BOM 전량 확보 후 첫 라우트 스텝 태스크. */
|
|
302
405
|
allocateDef(o) {
|
|
303
|
-
const ops = this.routeOps();
|
|
304
|
-
|
|
406
|
+
const ops = this.routeOps(o);
|
|
407
|
+
/* 라우트가 비어 있으면 만들 것이 없다 — 선언이 그렇게 말한 것이다(사실대로 건너뛴다). */
|
|
408
|
+
if (!ops.length)
|
|
305
409
|
return;
|
|
306
|
-
|
|
410
|
+
/*
|
|
411
|
+
* 첫 공정이 자리를 말하지 않으면 **흠이다.** 예전에는 여기서 조용히 `return` 해서 오더가 영원히
|
|
412
|
+
* 배정되지 않았다. 자리의 존재 여부는 로드 시점이 이미 봤으므로, 여기 남는 것은 선언 자체가 빈 경우다.
|
|
413
|
+
*/
|
|
414
|
+
const firstType = ops[0].locationType;
|
|
415
|
+
if (!firstType) {
|
|
416
|
+
throw new Error(`route step '${ops[0].key}' declares no locationType — the kernel cannot choose where work happens`);
|
|
417
|
+
}
|
|
418
|
+
const rc = this.recipeDef(o);
|
|
307
419
|
const picks = [];
|
|
308
420
|
for (const line of rc.inputs) {
|
|
309
421
|
const g = this.classOf(line.material);
|
|
422
|
+
/* 자리 필터는 남는다 — 완제품도 `sellable` 이라, 반제품→완제품 체인에서 이것이 없으면 산출물을 자재로 집어먹는다. */
|
|
423
|
+
const fromType = this.locationTypeOfMaterial(line.material);
|
|
310
424
|
const available = [...this.items.values()]
|
|
311
|
-
.filter(i => i.gtin === g && i.disposition === DISP.sellable && this.locations.get(i.location)?.type ===
|
|
425
|
+
.filter(i => i.gtin === g && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === fromType)
|
|
312
426
|
.map(i => ({ epc: i.epc, location: i.location, qty: 1 }));
|
|
313
427
|
const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
|
|
314
428
|
if (chosen.length < line.qty)
|
|
@@ -334,33 +448,82 @@ export class MesKernel extends FlowEngine {
|
|
|
334
448
|
const order = this.orders.get(t.orderId);
|
|
335
449
|
if (!order)
|
|
336
450
|
throw new Error(`task ${t.id}: order vanished between the core check and the domain hook`);
|
|
337
|
-
const rc = this.recipeDef();
|
|
338
|
-
const ops = this.routeOps();
|
|
451
|
+
const rc = this.recipeDef(order);
|
|
452
|
+
const ops = this.routeOps(order);
|
|
339
453
|
const i = ops.findIndex(s => s.key === t.kind);
|
|
340
454
|
const loc = this.locations.get(t.toNode);
|
|
341
455
|
const bizStep = ops[i]?.bizStep ?? MES_BIZSTEP.producing;
|
|
342
456
|
const isLast = i === ops.length - 1;
|
|
343
457
|
if (!isLast) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
458
|
+
/*
|
|
459
|
+
* 이 단계가 **품목을 만든다고 선언했는지** 본다.
|
|
460
|
+
*
|
|
461
|
+
* 선언했으면 그 품목으로 변환한다(배합물·가열완료물처럼 이름과 코드를 가진 반제품). 선언하지
|
|
462
|
+
* 않았으면 **변환하지 않는다** — 확보분을 그대로 들고 다음 자리로 간다. 그 단계에서 무슨 일이
|
|
463
|
+
* 있었는지는 다음 자리의 이벤트가 말하고, `transformationId` 가 오더의 모든 단계를 잇는다.
|
|
464
|
+
*
|
|
465
|
+
* 예전에는 여기서 `'WIP'` 를 지어냈다 — 선언하지 않은 것을 만들었고, 그래서 모든 중간물이 같은
|
|
466
|
+
* 상품코드가 됐다. **만드는 것은 선언이 정한다.**
|
|
467
|
+
*/
|
|
468
|
+
const producedKey = this.producedMaterialKeyOf(t.kind);
|
|
349
469
|
const next = ops[i + 1];
|
|
350
|
-
|
|
470
|
+
if (producedKey) {
|
|
471
|
+
const inputs = order.allocated.slice();
|
|
472
|
+
const made = this.serialOf(producedKey, ++this.wipSeq);
|
|
473
|
+
this.transform(inputs, [{ epc: made, gtin: this.classOf(producedKey), qty: 1, location: loc.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
|
|
474
|
+
order.allocated = [made];
|
|
475
|
+
}
|
|
476
|
+
/* 다음 자리에 무엇을 들고 가는가 — 만든 것이 있으면 그것, 없으면 들고 있던 것. */
|
|
477
|
+
const carried = order.allocated[0];
|
|
478
|
+
if (!carried) {
|
|
479
|
+
throw new Error(`order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' — the order holds no allocated material ` +
|
|
480
|
+
`and this step declares no produced material. Declare the step's input or its output in the model.`);
|
|
481
|
+
}
|
|
482
|
+
this.emitStationDef(order, next, carried);
|
|
351
483
|
order.status = 'op-' + next.key;
|
|
352
484
|
this.emitOrder(order);
|
|
353
485
|
return;
|
|
354
486
|
}
|
|
355
|
-
const fgStore = this.
|
|
356
|
-
|
|
487
|
+
const fgStore = this.locationOfMaterial(rc.outputs[0].material);
|
|
488
|
+
/*
|
|
489
|
+
* **완제품은 확보분 전량을 소비한다** — 하나만 소비하면 계보가 거짓이 되고 나머지가 영구 잠긴다.
|
|
490
|
+
*
|
|
491
|
+
* ── 무엇이 틀렸나 (2026-08-20) ────────────────────────────────────────────
|
|
492
|
+
* 여기는 `order.allocated[0]` 하나만 변환 입력으로 썼다. 예전에는 그것이 정당했다: 첫 스테이션이
|
|
493
|
+
* **항상** 전량을 WIP 하나로 합쳤으므로(`inputs = allocated.slice()` → `allocated = [made]`)
|
|
494
|
+
* `[0]` 이 그 부품들의 계보를 대표했다.
|
|
495
|
+
*
|
|
496
|
+
* 중간 산출물을 선언하지 않으면 변환하지 않게 되면서 **합치는 자리가 사라졌다.** 그런데 마지막이
|
|
497
|
+
* 여전히 `[0]` 만 참조했다. 대가가 둘이었다:
|
|
498
|
+
* ① **계보가 거짓** — 완제품의 입력이 1개로 기록되니 리콜 역추적이 나머지 자재를 못 찾는다.
|
|
499
|
+
* 식품이라면 회수 범위가 조용히 좁아진다.
|
|
500
|
+
* ② **재고가 영구 잠김** — 남은 확보분이 `reserved` 로 남아 다시 확보되지 않는다. 자재가 계속
|
|
501
|
+
* 누락되다가 생산이 멈추고, 원인은 「자재 부족」으로 보인다.
|
|
502
|
+
*
|
|
503
|
+
* 중간 산출물을 선언한 경로에서는 `allocated` 가 이미 `[made]` 하나이므로 거동이 같다.
|
|
504
|
+
*/
|
|
505
|
+
const consumed = order.allocated.slice();
|
|
506
|
+
if (!consumed.length) {
|
|
507
|
+
/*
|
|
508
|
+
* 씨앗이 이 오더의 확보분을 다 심지 못했으면(원본이 말한 물품이 스냅샷에 없었다) 이 오더는
|
|
509
|
+
* **완료할 수 없다.** 제품을 지어내면 계보가 거짓이 되고, 멈추면 원본의 빈틈 하나가 답 전체를
|
|
510
|
+
* 죽인다. 그래서 **막혔다고 말하고 넘어간다** — 그 사실은 `seedIncomplete` 로 이미 실려 있다.
|
|
511
|
+
*/
|
|
512
|
+
if (order.seedIncomplete) {
|
|
513
|
+
order.status = 'blocked-seed-incomplete';
|
|
514
|
+
this.emitOrder(order);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
throw new Error(`order ${order.id} at final step '${t.kind}': nothing allocated to consume — the kernel does not make a ` +
|
|
518
|
+
`product out of nothing. Declare the recipe inputs, or the step's produced material.`);
|
|
519
|
+
}
|
|
357
520
|
const good = this.rng() < (this.yieldOf(t.kind) ?? DEFAULT_YIELD);
|
|
358
521
|
t.outcome = good ? 'good' : 'scrap'; // 같은 규율 — 판정은 일어난 자리에서 적는다
|
|
359
522
|
this.recordOutput(t.resource, good);
|
|
360
523
|
const disp = good ? DISP.sellable : DISP.non_sellable;
|
|
361
524
|
const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
|
|
362
525
|
const outGtin = this.classOf(rc.outputs[0].material);
|
|
363
|
-
this.transform(
|
|
526
|
+
this.transform(consumed, [{ epc: outEpc, gtin: outGtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep, disposition: disp, transformationId: order.bizTransaction, readPoint: loc.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
|
|
364
527
|
this.emit(objectEvent({ eventTime: this.now(), action: 'OBSERVE', bizStep: MES_BIZSTEP.storing, disposition: disp, epcList: [outEpc], quantityList: [{ epcClass: outGtin, quantity: 1 }], readPoint: fgStore.id, bizLocation: fgStore.id }));
|
|
365
528
|
order.allocated = [];
|
|
366
529
|
order.fulfilled = 1;
|