@operato/twin-kernel 0.4.0 → 0.4.2

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.
@@ -0,0 +1,118 @@
1
+ /*
2
+ * 수요가 부르는 생산 — **순수**. "재고에 없는 품목을, 만들 수 있으면 만든다."
3
+ *
4
+ * ── 왜 있나 (2026-08-05) ────────────────────────────────────────────────────
5
+ * 유통가공 창고(키팅·세트조립) 템플릿으로 트윈을 만들면 랙이 꽉 차고 출고가 **한 건도** 나오지
6
+ * 않았다. 인바운드는 부품을 넣고 아웃바운드 오더는 **세트**를 요구하는데, 그 세트를 만드는 공정이
7
+ * 아무도 부르지 않아 세트가 영원히 재고에 없었다. 오더는 할당되지 못하고 부품만 쌓였다.
8
+ *
9
+ * 커널에는 그 공정을 실행할 기제가 이미 있었다 — ISA-95 `MaterialSpecification`(consumed/produced)을
10
+ * 코어가 소비/산출로 실행한다. 없던 것은 **"수요를 보고 그 공정을 발행하는 판단"** 하나다.
11
+ * 그 판단만 여기 순수 함수로 둔다(엔진은 상태를 들고 있어 단위테스트로 부르기 어렵다).
12
+ *
13
+ * ── 왜 사슬인가 ────────────────────────────────────────────────────────────
14
+ * 커널의 규칙 둘이 사슬을 강제한다. 둘 다 의도된 규칙이라 우회하지 않고 따른다.
15
+ *
16
+ * ① **자재는 작업이 일어나는 자리에 있어야 소비된다**(`claimMaterials`) — 다른 자리의 부품은 지금
17
+ * 쓸 수 없다. 부품은 랙에 있고 키팅은 작업대에서 하므로 **부품을 작업대로 옮기는 단계**가 앞선다.
18
+ * ② **산출물은 `in_progress` 로 태어난다** — 팔 수 있는 재고가 아니다. 그래서 **되돌려 넣는 단계**가
19
+ * 뒤따른다(그 자리에서 판매 가능으로 바꿔 버리면, 검사·적재 없이 재고가 되는 창고가 된다).
20
+ *
21
+ * 그래서 한 오더가 부르는 것은 세 걸음이다: **부품 이송 → 가공 → 되돌리기.** 이 모듈은 그중
22
+ * "지금 무엇을 발행해야 하나" 를 답한다 — 이송이 아직 안 끝났으면 가공을 발행하지 않는다.
23
+ */
24
+ /** 품목 → 그것을 산출하는 공정. 선언에서 파생한다(코드에 표를 두지 않는다). */
25
+ export function producedByIndex(ops) {
26
+ const idx = new Map();
27
+ for (const op of ops) {
28
+ /*
29
+ * **만드는 공정만 색인한다** — `intent: 'process'`.
30
+ *
31
+ * 산출을 선언한 이동 작업도 있다(적재하면서 부산물이 생기는 식). 그런 것을 "만드는 방법" 으로
32
+ * 골라 발행하면, 이동이어야 할 작업이 가공으로 취급돼 이동 처리가 통째로 건너뛰어진다 —
33
+ * 적합성 하네스가 정확히 그것을 잡았다(자리를 옮기지 않아 미러와 값이 갈렸다).
34
+ */
35
+ if (op.intent !== 'process')
36
+ continue;
37
+ for (const m of op.materialSpecification ?? []) {
38
+ if (m.use !== 'produced')
39
+ continue;
40
+ /* 품목을 지목하지 않은 산출은 색인할 수 없다 — 등급만으로는 "무엇을 만드나" 가 정해지지 않는다. */
41
+ if (!m.materialDefinition)
42
+ continue;
43
+ if (!idx.has(m.materialDefinition))
44
+ idx.set(m.materialDefinition, op);
45
+ }
46
+ }
47
+ return idx;
48
+ }
49
+ /** 공정이 요구하는 소비 자재. */
50
+ const consumedOf = (op) => (op.materialSpecification ?? []).filter(m => m.use === 'consumed' && (m.quantity ?? 0) > 0);
51
+ /** 산출 1회당 몇 개가 나오나 — 부족분을 채우려면 몇 번 돌려야 하는지 계산한다. */
52
+ const outputPerRun = (op, gtin) => {
53
+ const spec = (op.materialSpecification ?? []).find(m => m.use === 'produced' && m.materialDefinition === gtin);
54
+ return Math.max(1, spec?.quantity ?? 1);
55
+ };
56
+ /**
57
+ * 부족한 품목을 만들기 위해 **지금** 발행할 걸음을 정한다.
58
+ *
59
+ * @param gtin 부족한 품목(오더가 요구하는 것).
60
+ * @param shortQty 부족 수량.
61
+ * @param ops 이 트윈에 선언된 오퍼레이션들.
62
+ * @param stock 현재 재고.
63
+ * @param locations 자리 목록(공정 자리 해소용).
64
+ *
65
+ * 한 번에 **한 걸음 종류만** 낸다: 부품이 아직 작업대에 다 오지 않았으면 이송만 내고 가공은 다음에
66
+ * 낸다. 두 걸음을 한꺼번에 내면 가공이 부품 없이 시작을 시도하고, 코어가 거절하는 작업이 큐에 쌓인다.
67
+ */
68
+ export function planMakeToOrder(gtin, shortQty, ops, stock, locations) {
69
+ const op = producedByIndex(ops).get(gtin);
70
+ if (!op)
71
+ return { steps: [], reason: 'not-producible' };
72
+ /* 공정 자리 — 선언된 타입의 자리를 쓴다. 선언이 없으면 어디서 하는 공정인지 알 수 없다. */
73
+ const station = op.locationType ? locations.find(l => l.type === op.locationType) : undefined;
74
+ if (!station)
75
+ return { steps: [], reason: 'no-station' };
76
+ const need = consumedOf(op);
77
+ if (!need.length) {
78
+ /* 소비 없이 산출하는 공정 — 부품 이송이 필요 없다(무에서 나오는 것을 선언한 경우). */
79
+ return { steps: [{ step: 'process', operation: op.key, at: station.id }] };
80
+ }
81
+ const runs = Math.max(1, Math.ceil(shortQty / outputPerRun(op, gtin)));
82
+ const feeds = [];
83
+ let short = false;
84
+ for (const req of need) {
85
+ const want = (req.quantity ?? 0) * runs;
86
+ const atStation = stock.filter(s => s.location === station.id && matches(s, req)).reduce((n, s) => n + s.qty, 0);
87
+ let missing = want - atStation;
88
+ if (missing <= 0)
89
+ continue;
90
+ /* 작업대에 모자라면 다른 자리에서 끌어온다 — 팔 수 있는(예약되지 않은) 재고만. */
91
+ for (const s of stock) {
92
+ if (missing <= 0)
93
+ break;
94
+ if (s.location === station.id || !s.sellable || !matches(s, req))
95
+ continue;
96
+ const take = Math.min(s.qty, missing);
97
+ feeds.push({ step: 'feed', gtin: s.gtin, from: s.location, to: station.id, qty: take });
98
+ missing -= take;
99
+ }
100
+ if (missing > 0)
101
+ short = true; // 트윈 전체에 부품이 모자라다 — 이송으로 해결되지 않는다
102
+ }
103
+ /*
104
+ * 부품이 모자라면 **이송도 발행하지 않는다.** 반쪽만 옮겨 놓으면 그 부품이 작업대에 갇혀
105
+ * (다른 오더도 쓸 수 없고 가공도 시작되지 않는) 교착이 된다 — 코어의 "부분 투입 없음" 과 같은 규율.
106
+ */
107
+ if (short)
108
+ return { steps: [], reason: 'short-materials' };
109
+ if (feeds.length)
110
+ return { steps: feeds, reason: 'waiting-feed' };
111
+ return { steps: [{ step: 'process', operation: op.key, at: station.id }] };
112
+ }
113
+ /** 이 재고가 명세를 만족하나 — 품목 지목만 본다(등급 상속은 엔진이 안다). */
114
+ function matches(s, req) {
115
+ if (req.materialDefinition)
116
+ return s.gtin === req.materialDefinition;
117
+ return false;
118
+ }
@@ -1,8 +1,7 @@
1
- import type { GeneratorSpec, Command, CommandAck } from './contract.ts';
1
+ import type { GeneratorSpec, Command, CommandAck, ProductionSpec } from './contract.ts';
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 DomainDefinition } from './domain-definition.ts';
6
5
  /** 편의 — 시나리오 skuMix 로 쓸 부품 클래스. */
7
6
  export declare const MES_PART_GTINS: {
8
7
  partA: string;
@@ -18,24 +17,12 @@ export declare const MES_PRODUCTS: {
18
17
  gtin: string;
19
18
  label: string;
20
19
  }[];
21
- /**
22
- * 정의-구동 모드 스펙 — 커널이 도메인 정의(추상)를 소비. 구체 gtin 은 여기서:
23
- * 자재 키 → GS1 item reference 바인딩 + company prefix (정의는 gtin 을 모른다, plan §게이트 (b)).
24
- */
25
- export interface MesDefinitionSpec {
26
- definition: DomainDefinition;
27
- /** 자재 키 → GS1 item reference. 구체 gtin = sgtin(companyPrefix, itemRef). */
28
- binding: Record<string, string>;
29
- companyPrefix: string;
30
- /** 사용할 recipe 키(미지정 시 첫 recipe). */
31
- recipeKey?: string;
32
- }
33
20
  export declare class MesKernel extends FlowEngine {
34
21
  private wipSeq;
35
22
  private prodSeq;
36
23
  /** 정의-구동 모드(선택). 미지정 시 레거시 하드코딩 경로 — byte-identical. */
37
- private mesSpec?;
38
- constructor(tenantId: string, policy?: AllocationPolicy, mesSpec?: MesDefinitionSpec);
24
+ private productionSpec?;
25
+ constructor(tenantId: string, policy?: AllocationPolicy, productionSpec?: ProductionSpec);
39
26
  /**
40
27
  * **산출을 두 곳에서 만들지 않는다** — 기동 때 막는다.
41
28
  *
@@ -49,18 +49,37 @@ const ROUTE = [
49
49
  { kind: 'paint', locationType: 'paint-booth', resource: 'painter' },
50
50
  { kind: 'assembly', locationType: 'assembly-line', resource: 'assembler' }
51
51
  ];
52
+ /*
53
+ * 정의-구동 모드 스펙은 **계약의 `ProductionSpec`** 이다(`contract.ts`).
54
+ *
55
+ * 예전에는 이 파일에 `MesDefinitionSpec` 이라는 같은 모양의 타입이 따로 있었고, 보드에도 `mesSpec`
56
+ * 이라는 이름으로 실렸다. 담긴 것은 ISA-95 `OperationsSegment` + BOM 이라 MES 만의 것이 아닌데
57
+ * (창고의 유통가공도 같은 "자재를 소비해 자재를 산출하는 공정" 이다) 이름이 MES 였기 때문에
58
+ * WMS 트윈은 이 선언을 실을 생각조차 하지 못했다. 이름과 타입을 하나로 합쳤다 — 되돌리지 말 것,
59
+ * 근거는 `contract.ts` 의 `productionSpec` 주석에 있다.
60
+ */
52
61
  export class MesKernel extends FlowEngine {
53
62
  wipSeq = 0;
54
63
  prodSeq = 0;
55
64
  /** 정의-구동 모드(선택). 미지정 시 레거시 하드코딩 경로 — byte-identical. */
56
- mesSpec;
57
- constructor(tenantId, policy = firstFitPolicy, mesSpec) {
65
+ productionSpec;
66
+ constructor(tenantId, policy = firstFitPolicy, productionSpec) {
58
67
  super(tenantId, policy);
59
- this.mesSpec = mesSpec;
68
+ this.productionSpec = productionSpec;
60
69
  /* 정의가 있으면 그 안의 오퍼레이션 명세(소요·변동·모수)를 커널이 소비한다 — 없으면 기본값 경로. */
61
- if (mesSpec?.definition?.operations) {
62
- this.loadOperations(mesSpec.definition.operations);
63
- this.assertNoDoubleProduction(mesSpec.definition.operations);
70
+ if (productionSpec?.definition?.operations) {
71
+ this.loadOperations(productionSpec.definition.operations);
72
+ this.assertNoDoubleProduction(productionSpec.definition.operations);
73
+ }
74
+ /*
75
+ * **직렬 생산에는 바인딩이 있어야 한다.** 레시피 경로는 자재 키를 실제 GS1 식별자로 옮겨야
76
+ * 하는데(정의는 gtin 을 모른다) 그 바인딩이 없으면 품번 없는 물건을 만들게 된다.
77
+ * 계약에서 `binding`·`companyPrefix` 는 선택이다 — 유통가공(비직렬 클래스+수량)은 자재 명세에
78
+ * gtin 을 직접 적으므로 필요 없다. 그래서 **필요한 경로에서만** 요구하고, 없으면 기동을 막는다.
79
+ */
80
+ if (productionSpec?.definition?.recipes?.length && (!productionSpec.binding || !productionSpec.companyPrefix)) {
81
+ throw new Error('recipe-driven production needs `binding` and `companyPrefix` — the definition does not know GTINs, ' +
82
+ 'so material keys cannot be resolved to GS1 identifiers');
64
83
  }
65
84
  }
66
85
  /**
@@ -110,7 +129,7 @@ export class MesKernel extends FlowEngine {
110
129
  }
111
130
  /** 부품 수령(다품종) — skuMix 의 gtin 으로 부품 종류 결정. */
112
131
  onArrival(spec) {
113
- if (this.mesSpec)
132
+ if (this.productionSpec)
114
133
  return this.onArrivalDef(spec);
115
134
  const rawStore = this.locationByType('raw-store');
116
135
  if (!rawStore)
@@ -126,7 +145,7 @@ export class MesKernel extends FlowEngine {
126
145
  }
127
146
  /** 작업지시 — 제품 2종 교대(체인지오버 유발). 제품 gtin 을 오더에 기록. */
128
147
  onOrder(_spec) {
129
- if (this.mesSpec)
148
+ if (this.productionSpec)
130
149
  return this.onOrderDef(_spec);
131
150
  const product = PRODUCTS[this.orderSeq % PRODUCTS.length];
132
151
  const id = `order-${++this.orderSeq}`;
@@ -137,7 +156,7 @@ export class MesKernel extends FlowEngine {
137
156
  }
138
157
  /** 할당 — 제품 BOM 각 라인의 부품 예약(하나라도 부족하면 대기) + 라우트 첫 스테이션(절단) 태스크. */
139
158
  allocate(o) {
140
- if (this.mesSpec)
159
+ if (this.productionSpec)
141
160
  return this.allocateDef(o);
142
161
  const s0 = ROUTE[0];
143
162
  const first = this.locationByType(s0.locationType);
@@ -179,13 +198,13 @@ export class MesKernel extends FlowEngine {
179
198
  * 하나가 정확도 추세 전체를 죽였다.** 이제 코어가 이 답을 보고 그 작업만 접는다.
180
199
  */
181
200
  canComplete(t) {
182
- if (this.mesSpec)
201
+ if (this.productionSpec)
183
202
  return !!(t.orderId && this.orders.get(t.orderId));
184
203
  const order = t.orderId ? this.orders.get(t.orderId) : undefined;
185
204
  return !!order && !!this.productOf(order.gtin);
186
205
  }
187
206
  onTaskComplete(t) {
188
- if (this.mesSpec)
207
+ if (this.productionSpec)
189
208
  return this.onTaskCompleteDef(t);
190
209
  /* 여기 도달했다면 `canComplete` 가 이미 확인했다 — 그래도 단정(`!`)은 쓰지 않는다. */
191
210
  const order = this.orders.get(t.orderId);
@@ -223,15 +242,15 @@ export class MesKernel extends FlowEngine {
223
242
  }
224
243
  // ── 정의-구동 모드 (도메인 정의 데이터로 실행 — 레거시와 분리, 하드코딩 대체) ──
225
244
  recipeDef() {
226
- const d = this.mesSpec.definition;
227
- return (this.mesSpec.recipeKey ? d.recipes?.find(r => r.key === this.mesSpec.recipeKey) : d.recipes?.[0]);
245
+ const d = this.productionSpec.definition;
246
+ return (this.productionSpec.recipeKey ? d.recipes?.find(r => r.key === this.productionSpec.recipeKey) : d.recipes?.[0]);
228
247
  }
229
248
  /** 자재 키 → 구체 gtin 클래스(idpat). 구체 식별은 바인딩+prefix 로 인스턴스가 주입. */
230
249
  classOf(materialKey) {
231
- return sgtinClass(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey]);
250
+ return sgtinClass(this.productionSpec.companyPrefix, this.productionSpec.binding[materialKey]);
232
251
  }
233
252
  serialOf(materialKey, serial) {
234
- return sgtinUri(this.mesSpec.companyPrefix, this.mesSpec.binding[materialKey], serial);
253
+ return sgtinUri(this.productionSpec.companyPrefix, this.productionSpec.binding[materialKey], serial);
235
254
  }
236
255
  /**
237
256
  * 라우트를 용량 계산에 알려 준다 — 수율을 거슬러 올릴 때 순서가 곧 계산이다.
@@ -240,14 +259,14 @@ export class MesKernel extends FlowEngine {
240
259
  * 틀린다). 생산 정의를 가진 커널만 이 답을 안다.
241
260
  */
242
261
  routeKeys() {
243
- if (!this.mesSpec)
262
+ if (!this.productionSpec)
244
263
  return undefined;
245
- const d = this.mesSpec.definition;
264
+ const d = this.productionSpec.definition;
246
265
  return d.routes?.find(r => r.key === this.recipeDef().route)?.steps;
247
266
  }
248
267
  /** recipe.route → 오퍼레이션 시퀀스 해소. */
249
268
  routeOps() {
250
- const d = this.mesSpec.definition;
269
+ const d = this.productionSpec.definition;
251
270
  const route = d.routes?.find(r => r.key === this.recipeDef().route);
252
271
  return (route?.steps ?? []).map(sk => d.operations?.find(o => o.key === sk)).filter((o) => !!o);
253
272
  }
@@ -272,7 +291,7 @@ export class MesKernel extends FlowEngine {
272
291
  onOrderDef(_spec) {
273
292
  const rc = this.recipeDef();
274
293
  const id = `order-${++this.orderSeq}`;
275
- const wo = gdtiUri(this.mesSpec.companyPrefix, '403', ++this.soSeq);
294
+ const wo = gdtiUri(this.productionSpec.companyPrefix, '403', ++this.soSeq);
276
295
  const order = { id, kind: 'workorder', status: 'created', gtin: this.classOf(rc.outputs[0].material), requested: 1, fulfilled: 0, bizTransaction: wo, allocated: [], picked: [], ...this.promiseOf(_spec) };
277
296
  this.orders.set(id, order);
278
297
  this.emitOrder(order);
@@ -321,8 +340,8 @@ export class MesKernel extends FlowEngine {
321
340
  const isLast = i === ops.length - 1;
322
341
  if (!isLast) {
323
342
  const inputs = order.allocated.slice();
324
- const wip = sgtinUri(this.mesSpec.companyPrefix, 'WIP', ++this.wipSeq);
325
- const wipGtin = sgtinClass(this.mesSpec.companyPrefix, 'WIP');
343
+ const wip = sgtinUri(this.productionSpec.companyPrefix, 'WIP', ++this.wipSeq);
344
+ const wipGtin = sgtinClass(this.productionSpec.companyPrefix, 'WIP');
326
345
  this.transform(inputs, [{ epc: wip, gtin: wipGtin, 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 }] });
327
346
  order.allocated = [wip];
328
347
  const next = ops[i + 1];
@@ -37,6 +37,14 @@ export interface ProjectedState {
37
37
  tasks: TaskState[];
38
38
  equipment: EquipmentState[];
39
39
  orders: OrderState[];
40
+ /**
41
+ * 사람이 확인(ack)해 둔 주목 신호 id — **파생될 수 없는 유일한 축**이라 저널에서 되살린다.
42
+ *
43
+ * 신호 자체는 상태에서 다시 계산되므로 여기 싣지 않는다. 확인했다는 사실만 남는다.
44
+ * 조건이 사라진 신호의 id 가 남아 있어도 해롭지 않다(그 신호가 없으므로 표시할 대상이 없고,
45
+ * 재발하면 새 시작으로 다시 활성된다).
46
+ */
47
+ acked: string[];
40
48
  }
41
49
  export declare class ObservedReducer {
42
50
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
@@ -50,6 +58,8 @@ export declare class ObservedReducer {
50
58
  private persons;
51
59
  private assets;
52
60
  private orders;
61
+ /** 확인해 둔 주목 신호 id — `attention.acked` 이벤트로만 들어온다(계산으로 만들지 않는다). */
62
+ private acked;
53
63
  revision: number;
54
64
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
55
65
  private corrections;
@@ -50,6 +50,8 @@ export class ObservedReducer {
50
50
  persons = new Map();
51
51
  assets = new Map();
52
52
  orders = new Map();
53
+ /** 확인해 둔 주목 신호 id — `attention.acked` 이벤트로만 들어온다(계산으로 만들지 않는다). */
54
+ acked = new Set();
53
55
  revision = 0;
54
56
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
55
57
  corrections = [];
@@ -318,6 +320,13 @@ export class ObservedReducer {
318
320
  this.touchLocation(d.location);
319
321
  break;
320
322
  }
323
+ case OP_EVENT.attentionAck: {
324
+ /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
325
+ const d = e.data;
326
+ if (d?.id)
327
+ this.acked.add(d.id);
328
+ return;
329
+ }
321
330
  case OP_EVENT.order: {
322
331
  const d = e.data;
323
332
  if (this.stale(`order:${d.orderId}`, e))
@@ -585,7 +594,8 @@ export class ObservedReducer {
585
594
  assets: [...this.assets.values()].map(a => ({ ...a, ...this.effectivityPart(a) })),
586
595
  tasks: [...this.tasks.values()].map(t => ({ ...t })),
587
596
  equipment: [...this.equipment.values()].map(m => ({ ...m, ...this.effectivityPart(m), ...this.offShiftPart(`eq:${m.id}`) })),
588
- orders: [...this.orders.values()].map(o => ({ ...o }))
597
+ orders: [...this.orders.values()].map(o => ({ ...o })),
598
+ acked: [...this.acked]
589
599
  };
590
600
  }
591
601
  }
@@ -0,0 +1,15 @@
1
+ import type { ScenarioDef } from './contract.ts';
2
+ export type ScenarioValidation = {
3
+ ok: true;
4
+ } | {
5
+ ok: false;
6
+ errorCode: string;
7
+ errorParams?: Record<string, string | number>;
8
+ };
9
+ /**
10
+ * 시나리오 선언이 실릴 수 있는가.
11
+ *
12
+ * 첫 번째 잘못에서 멈추고 그것을 말한다 — 전부 모아 보고하면 화면이 무엇부터 고칠지 알 수 없다.
13
+ * 생성기가 하나도 없는 것은 **잘못이 아니다**(자극 없이 관측만 하는 시나리오는 성립한다).
14
+ */
15
+ export declare function validateScenario(def: ScenarioDef | undefined | null): ScenarioValidation;
@@ -0,0 +1,72 @@
1
+ const DISTRIBUTIONS = new Set(['poisson', 'uniform', 'constant', 'profile']);
2
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
3
+ const bad = (errorCode, errorParams) => ({ ok: false, errorCode, errorParams });
4
+ /** 생성기 한 개 — 무엇이 빠지면 tick 이 터지는지를 기준으로 본다(장식이 아니라 필수만). */
5
+ function validateGenerator(g, at) {
6
+ if (!g || typeof g !== 'object')
7
+ return bad('scenario-generator-invalid', { at });
8
+ if (!g.kind || typeof g.kind !== 'string')
9
+ return bad('scenario-generator-kind-required', { at });
10
+ /* 도착률 — 없거나 숫자가 아니면 간격을 계산할 수 없다(여기서 터졌다). */
11
+ const rate = g.rate;
12
+ if (!rate || typeof rate !== 'object')
13
+ return bad('scenario-rate-required', { at, kind: g.kind });
14
+ if (!isNum(rate.meanPerHour) || rate.meanPerHour < 0)
15
+ return bad('scenario-rate-mean-invalid', { at, kind: g.kind });
16
+ if (!DISTRIBUTIONS.has(rate.distribution))
17
+ return bad('scenario-rate-distribution-invalid', { at, kind: g.kind, distribution: String(rate.distribution ?? '') });
18
+ /* `profile` 은 시간대 배율 표가 있어야 뜻이 있다 — 없으면 조용히 상수처럼 굴러 선언과 다르게 동작한다. */
19
+ if (rate.distribution === 'profile' && !Array.isArray(rate.profile))
20
+ return bad('scenario-rate-profile-required', { at, kind: g.kind });
21
+ /* 내용 — 무엇을 얼마나 만드는가. skuMix 가 비면 만들 물건이 없다. */
22
+ const content = g.content;
23
+ if (!content || typeof content !== 'object')
24
+ return bad('scenario-content-required', { at, kind: g.kind });
25
+ if (!Array.isArray(content.skuMix) || content.skuMix.length === 0)
26
+ return bad('scenario-sku-mix-required', { at, kind: g.kind });
27
+ for (const s of content.skuMix) {
28
+ if (!s?.gtin || typeof s.gtin !== 'string')
29
+ return bad('scenario-sku-gtin-required', { at, kind: g.kind });
30
+ if (!isNum(s.weight) || s.weight <= 0)
31
+ return bad('scenario-sku-weight-invalid', { at, kind: g.kind, gtin: String(s.gtin) });
32
+ }
33
+ const q = content.qtyPerLine;
34
+ if (!q || !isNum(q.min) || !isNum(q.max))
35
+ return bad('scenario-qty-required', { at, kind: g.kind });
36
+ if (q.min < 0 || q.max < q.min)
37
+ return bad('scenario-qty-range-invalid', { at, kind: g.kind, min: q.min, max: q.max });
38
+ const l = content.linesPerOrder;
39
+ if (l && (!isNum(l.min) || !isNum(l.max) || l.min < 1 || l.max < l.min))
40
+ return bad('scenario-lines-range-invalid', { at, kind: g.kind });
41
+ if (g.stimulus !== undefined && g.stimulus !== 'arrival' && g.stimulus !== 'order') {
42
+ return bad('scenario-stimulus-invalid', { at, kind: g.kind, stimulus: String(g.stimulus) });
43
+ }
44
+ return { ok: true };
45
+ }
46
+ /**
47
+ * 시나리오 선언이 실릴 수 있는가.
48
+ *
49
+ * 첫 번째 잘못에서 멈추고 그것을 말한다 — 전부 모아 보고하면 화면이 무엇부터 고칠지 알 수 없다.
50
+ * 생성기가 하나도 없는 것은 **잘못이 아니다**(자극 없이 관측만 하는 시나리오는 성립한다).
51
+ */
52
+ export function validateScenario(def) {
53
+ if (!def || typeof def !== 'object')
54
+ return bad('scenario-invalid');
55
+ if (def.seed !== undefined && !isNum(def.seed))
56
+ return bad('scenario-seed-invalid');
57
+ if (def.speed !== undefined && (!isNum(def.speed) || def.speed <= 0))
58
+ return bad('scenario-speed-invalid');
59
+ if (def.horizon !== undefined && (!isNum(def.horizon) || def.horizon < 0))
60
+ return bad('scenario-horizon-invalid');
61
+ const gens = def.generators;
62
+ if (gens === undefined)
63
+ return { ok: true }; // 자극 없는 시나리오 — 성립한다
64
+ if (!Array.isArray(gens))
65
+ return bad('scenario-generators-invalid');
66
+ for (let i = 0; i < gens.length; i++) {
67
+ const r = validateGenerator(gens[i], i);
68
+ if (!r.ok)
69
+ return r;
70
+ }
71
+ return { ok: true };
72
+ }
@@ -25,6 +25,12 @@ export const VOCABULARY_EXCEPTIONS = [
25
25
  { token: 'moverId', why: 'journal wire field — renaming would mix two keys for one fact across history' },
26
26
  { token: 'fromNode', why: 'journal wire field (task.status payload)' },
27
27
  { token: 'toNode', why: 'journal wire field (task.status payload)' },
28
+ /* ── 보드의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
29
+ * 설비 배열의 이름은 세 세대를 거쳤다(movers → equipmentList → equipment). 저장된 보드에는
30
+ * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 보드는 **설비가 0인 공장**
31
+ * 으로 조용히 읽힌다 — 화면의 설비 수가 0이 되고 용량 판정에서 자원이 사라진다. 쓰는 곳은
32
+ * `readBoardEquipment` 한 곳뿐이고, 거기서 새 이름으로 정규화해 내보낸다. */
33
+ { token: 'movers', why: 'legacy board key (movers → equipmentList → equipment); read-only normalization in readBoardEquipment, 13 stored boards still use it' },
28
34
  /* ── 씬 컴포넌트 타입 — 보드에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
29
35
  * 보드 7개가 이 타입으로 컴포넌트를 담고 있어 개명하면 그 컴포넌트가 조용히 안 그려진다.
30
36
  * 그리고 씬에서 이 이름은 자원이 아니라 **움직임 표현**을 가리킨다(표준과 충돌 아님). */
@@ -16,5 +16,16 @@ export declare const BTT: {
16
16
  * WMS 자리 타입 카탈로그 — 커널이 실제로 키로 쓰는 로케이션 타입(locationByType/slotViews).
17
17
  * 도메인 어휘 SSOT: 호스트·UI 는 이걸 소싱하고 재선언하지 않는다(방언 금지).
18
18
  */
19
- export declare const WMS_LOCATION_TYPES: readonly ["dock", "storage", "staging", "dock-ship"];
19
+ export declare const WMS_LOCATION_TYPES: readonly ["dock", "storage", "staging", "dock-ship", "vas-station"];
20
+ /**
21
+ * 유통가공 작업대(`vas-station`) — 창고에서 **자재를 소비해 자재를 산출하는** 자리.
22
+ *
23
+ * 표준으로는 ISA-95 `WorkCenter` 이고, 하는 일은 EPCIS 로 보면 변환(부품 → 세트 SKU)이다.
24
+ * "VAS" 는 물류업계 용어일 뿐 표준 엔티티가 아니라, 커널은 그 이름의 개념을 새로 만들지 않는다 —
25
+ * 능력으로 말한다: 물건을 담고(`storable`) 가공한다(`processable`).
26
+ *
27
+ * 예전에는 이 타입이 카탈로그에 **없었다.** 그래서 유통가공 창고 템플릿이 이 자리를 만들 때마다
28
+ * 인제스트가 "모르는 자리 타입" 경고를 냈고(그 경고는 화면에도 보이지 않았다), 커널은 그 자리를
29
+ * 흐름에서 빼 두었다. 키팅이 일어나지 않는 유통가공 창고가 그렇게 만들어졌다.
30
+ */
20
31
  export declare const WMS_TYPES: TwinTypeInfo[];
@@ -17,13 +17,27 @@ export const BTT = {
17
17
  * WMS 자리 타입 카탈로그 — 커널이 실제로 키로 쓰는 로케이션 타입(locationByType/slotViews).
18
18
  * 도메인 어휘 SSOT: 호스트·UI 는 이걸 소싱하고 재선언하지 않는다(방언 금지).
19
19
  */
20
- export const WMS_LOCATION_TYPES = ['dock', 'storage', 'staging', 'dock-ship'];
20
+ export const WMS_LOCATION_TYPES = ['dock', 'storage', 'staging', 'dock-ship', 'vas-station'];
21
21
  /*
22
22
  * 트윈 타입 서술(ADR-0018 확장) — 자리 키는 WMS_LOCATION_TYPES 단일 출처에서 파생 + 설비 타입 추가.
23
23
  * WMS=EPCIS 도메인: 로케이션=bizLocation(SGLN), 설비(지게차)=추적 오브젝트/자산(GIAI). 능력은 씬 소유라 미포함.
24
24
  */
25
25
  // label 은 언어 중립 i18n 키(twin.type.<key>) — 사람 언어는 표현계층이 렌더(design/plans/i18n.md L2).
26
+ /**
27
+ * 유통가공 작업대(`vas-station`) — 창고에서 **자재를 소비해 자재를 산출하는** 자리.
28
+ *
29
+ * 표준으로는 ISA-95 `WorkCenter` 이고, 하는 일은 EPCIS 로 보면 변환(부품 → 세트 SKU)이다.
30
+ * "VAS" 는 물류업계 용어일 뿐 표준 엔티티가 아니라, 커널은 그 이름의 개념을 새로 만들지 않는다 —
31
+ * 능력으로 말한다: 물건을 담고(`storable`) 가공한다(`processable`).
32
+ *
33
+ * 예전에는 이 타입이 카탈로그에 **없었다.** 그래서 유통가공 창고 템플릿이 이 자리를 만들 때마다
34
+ * 인제스트가 "모르는 자리 타입" 경고를 냈고(그 경고는 화면에도 보이지 않았다), 커널은 그 자리를
35
+ * 흐름에서 빼 두었다. 키팅이 일어나지 않는 유통가공 창고가 그렇게 만들어졌다.
36
+ */
26
37
  export const WMS_TYPES = [
27
- ...WMS_LOCATION_TYPES.map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
28
- { key: 'forklift', role: 'equipment', label: 'twin.type.forklift', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] }
38
+ ...WMS_LOCATION_TYPES.filter(k => k !== 'vas-station').map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
39
+ { key: 'vas-station', role: 'location', label: 'twin.type.vas-station', standardClass: { epcis: 'bizLocation', isa95: 'WorkCenter' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable', 'processable'] },
40
+ { key: 'forklift', role: 'equipment', label: 'twin.type.forklift', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] },
41
+ /** 유통가공 작업자·작업대 설비 — 가공을 수행하는 능동 자원(ISA-95 `Equipment`). */
42
+ { key: 'packer', role: 'equipment', label: 'twin.type.packer', standardClass: { epcis: 'object', isa95: 'Equipment' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
29
43
  ];