@operato/twin-kernel 0.2.3 → 0.3.0

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.
@@ -8,7 +8,7 @@ export interface ForecastTwin {
8
8
  /**
9
9
  * 시뮬 시각(ms) — **구동 루프가 시각만 읽을 때 쓰는 값.**
10
10
  *
11
- * 없으면 `getSnapshot().simClockMs` 로 떨어지지만, 그 경로는 전 노드·물품·자원·작업·오더를 새로
11
+ * 없으면 `getSnapshot().simClockMs` 로 떨어지지만, 그 경로는 전 자리·물품·자원·작업·오더를 새로
12
12
  * 재료화하고 주목 신호까지 계산한 뒤 숫자 하나만 꺼내 버린다 — tick 마다 그러면 상태 크기 × 지평선
13
13
  * 길이만큼 낭비가 쌓인다(물품 1만 건·30분 지평선에서 측정: 루프 조건에만 149ms vs 0ms).
14
14
  */
package/dist/index.d.ts CHANGED
@@ -24,3 +24,4 @@ export { WmsKernel } from './kernel.ts';
24
24
  export { YmsKernel } from './yms-kernel.ts';
25
25
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from './mes-kernel.ts';
26
26
  export type { MesDefinitionSpec } from './mes-kernel.ts';
27
+ export * from './vocabulary.ts';
package/dist/index.js CHANGED
@@ -23,3 +23,4 @@ export * from "./flow-engine.js";
23
23
  export { WmsKernel } from "./kernel.js";
24
24
  export { YmsKernel } from "./yms-kernel.js";
25
25
  export { MesKernel, MES_PART_GTINS, MES_PRODUCT_GTINS, MES_PRODUCTS } from "./mes-kernel.js";
26
+ export * from "./vocabulary.js";
package/dist/kernel.js CHANGED
@@ -21,7 +21,7 @@ export class WmsKernel extends FlowEngine {
21
21
  }
22
22
  /** 입고 도착 — §4 라이프사이클: ASN(PO) → 팔레트 조립 → 수령 → putaway task. */
23
23
  onArrival(spec) {
24
- const dock = this.nodeByType('dock');
24
+ const dock = this.locationByType('dock');
25
25
  if (!dock)
26
26
  return;
27
27
  const epc = ssccUri(COMPANY_PREFIX, ++this.epcSeq); // 팔레트 SSCC
@@ -104,7 +104,7 @@ export class WmsKernel extends FlowEngine {
104
104
  * 멀티SKU 는 여러 라인의 팔레트를 한 오더로 모아 단일 출하(finalizeOrder 통합 화물).
105
105
  */
106
106
  allocate(o) {
107
- const staging = this.nodeByType('staging');
107
+ const staging = this.locationByType('staging');
108
108
  if (!staging || !o.lines)
109
109
  return;
110
110
  const chosenAll = [];
@@ -114,7 +114,7 @@ export class WmsKernel extends FlowEngine {
114
114
  if (need <= 0)
115
115
  continue;
116
116
  const available = [...this.items.values()]
117
- .filter(i => i.gtin === line.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === 'storage')
117
+ .filter(i => i.gtin === line.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'storage')
118
118
  .map(i => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
119
119
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
120
120
  for (const epc of chosen) {
@@ -141,8 +141,8 @@ export class WmsKernel extends FlowEngine {
141
141
  /** 태스크 완료 — 이동 반영 후 putaway=storing, pick=picking(+전량 시 pack→stage→ship). */
142
142
  onTaskComplete(t) {
143
143
  const item = this.items.get(t.itemEpc);
144
- const from = this.nodes.get(t.fromNode);
145
- const to = this.nodes.get(t.toNode);
144
+ const from = this.locations.get(t.fromNode);
145
+ const to = this.locations.get(t.toNode);
146
146
  from.occupancy--;
147
147
  to.occupancy++;
148
148
  item.location = to.id;
@@ -163,7 +163,7 @@ export class WmsKernel extends FlowEngine {
163
163
  }
164
164
  /** 전량 피킹 → packing(조립)·staging·shipping 마감. 화물 사이트 이탈, 백오더 잔량 재할당. */
165
165
  finalizeOrder(order, staging) {
166
- const shipDock = this.nodeByType('dock-ship') ?? staging;
166
+ const shipDock = this.locationByType('dock-ship') ?? staging;
167
167
  const eventTime = this.now();
168
168
  const shipment = ssccUri(COMPANY_PREFIX, ++this.epcSeq);
169
169
  order.shipmentEpc = shipment;
@@ -6,7 +6,7 @@
6
6
  * ① BOM = 제품별 다품목 레시피(P1=2A+1B / P2=1A+2B — 상이 → 체인지오버 유발).
7
7
  * ② 수율 = 일부 불량(non_sellable) → OEE Quality.
8
8
  * ③ 셋업/체인지오버 = work-center 가 제품 전환 시 셋업(changeoverKey=제품 gtin) → OEE Availability.
9
- * ④ OEE = base 가 무버(설비)별 계측(가동/셋업/기아/품질). cut=cutter·weld=welder 이종 자원.
9
+ * ④ OEE = base 가 설비(설비)별 계측(가동/셋업/기아/품질). cut=cutter·weld=welder 이종 자원.
10
10
  */
11
11
  import { firstFitPolicy } from "./allocation-policy.js";
12
12
  import { FlowEngine } from "./flow-engine.js";
@@ -44,10 +44,10 @@ export const MES_PRODUCTS = PRODUCTS.map(p => ({ gtin: p.gtin, label: p.key }));
44
44
  * 각 스테이션 = 이종 자원(resource) + 제품 전환 시 체인지오버 셋업.
45
45
  */
46
46
  const ROUTE = [
47
- { kind: 'cut', node: 'cut-station', resource: 'cutter' },
48
- { kind: 'weld', node: 'weld-station', resource: 'welder' },
49
- { kind: 'paint', node: 'paint-booth', resource: 'painter' },
50
- { kind: 'assembly', node: 'assembly-line', resource: 'assembler' }
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
51
  ];
52
52
  export class MesKernel extends FlowEngine {
53
53
  wipSeq = 0;
@@ -73,7 +73,7 @@ export class MesKernel extends FlowEngine {
73
73
  const a = cmd.args;
74
74
  if (!a?.resourceId || !a?.gtin)
75
75
  return { commandId: cmd.commandId, accepted: false, errorCode: 'changeover-needs-args', error: 'mes.changeover: resourceId and gtin required' };
76
- const m = this.movers.get(a.resourceId);
76
+ const m = this.equipment.get(a.resourceId);
77
77
  if (!m)
78
78
  return { commandId: cmd.commandId, accepted: false, errorCode: 'resource-not-found', errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
79
79
  if (m.lastChangeoverKey !== a.gtin) {
@@ -81,7 +81,7 @@ export class MesKernel extends FlowEngine {
81
81
  * 오퍼레이션별 셋업 명세는 작업 생성 경로(emitStation*)가 소비한다. */
82
82
  m.setupMs += DEFAULT_SETUP_MS; // 셋업 = OEE 가용성 손실
83
83
  m.lastChangeoverKey = a.gtin;
84
- this.emitMover(m);
84
+ this.emitEquipment(m);
85
85
  }
86
86
  return { commandId: cmd.commandId, accepted: true };
87
87
  }
@@ -91,7 +91,7 @@ export class MesKernel extends FlowEngine {
91
91
  onArrival(spec) {
92
92
  if (this.mesSpec)
93
93
  return this.onArrivalDef(spec);
94
- const rawStore = this.nodeByType('raw-store');
94
+ const rawStore = this.locationByType('raw-store');
95
95
  if (!rawStore)
96
96
  return;
97
97
  const gtin = this.pickGtin(spec.content.skuMix);
@@ -119,7 +119,7 @@ export class MesKernel extends FlowEngine {
119
119
  if (this.mesSpec)
120
120
  return this.allocateDef(o);
121
121
  const s0 = ROUTE[0];
122
- const first = this.nodeByType(s0.node);
122
+ const first = this.locationByType(s0.locationType);
123
123
  const product = this.productOf(o.gtin);
124
124
  if (!first || !product)
125
125
  return;
@@ -127,7 +127,7 @@ export class MesKernel extends FlowEngine {
127
127
  const picks = [];
128
128
  for (const line of product.bom) {
129
129
  const available = [...this.items.values()]
130
- .filter(i => i.gtin === line.part.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === 'raw-store')
130
+ .filter(i => i.gtin === line.part.gtin && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'raw-store')
131
131
  .map(i => ({ epc: i.epc, location: i.location, qty: 1 }));
132
132
  const chosen = this.policy.selectStock({ gtin: line.part.gtin, qty: line.qty, available });
133
133
  if (chosen.length < line.qty)
@@ -144,8 +144,8 @@ export class MesKernel extends FlowEngine {
144
144
  }
145
145
  /** 라우트 스테이션 태스크 발행(공통) — 제자리 가공(process), 이종 자원, 제품 전환 셋업. */
146
146
  emitStation(o, stage, itemEpc, changeoverKey) {
147
- const node = this.nodeByType(stage.node);
148
- const task = { id: `task-${++this.taskSeq}`, kind: stage.kind, status: 'created', itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: stage.kind, fromNode: node.id, toNode: node.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' };
147
+ const loc = this.locationByType(stage.locationType);
148
+ 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' };
149
149
  this.tasks.set(task.id, task);
150
150
  this.emitTask(task);
151
151
  }
@@ -156,13 +156,13 @@ export class MesKernel extends FlowEngine {
156
156
  const order = this.orders.get(t.orderId);
157
157
  const product = this.productOf(order.gtin);
158
158
  const i = ROUTE.findIndex(s => s.kind === t.kind);
159
- const node = this.nodes.get(t.toNode);
159
+ const loc = this.locations.get(t.toNode);
160
160
  const isLast = i === ROUTE.length - 1;
161
161
  if (!isLast) {
162
162
  // 중간 스테이션 — 입력(첫=BOM 전부 / 이후=이전 WIP) → 다음 WIP, 다음 스테이션 태스크.
163
163
  const inputs = order.allocated.slice();
164
164
  const wip = sgtinUri(CP, WIP_ITEMREF, ++this.wipSeq);
165
- this.transform(inputs, [{ epc: wip, gtin: WIP_GTIN, qty: 1, location: node.id, disposition: DISP.in_progress }], { bizStep: MES_BIZSTEP.producing, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
165
+ 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 }] });
166
166
  order.allocated = [wip];
167
167
  const next = ROUTE[i + 1];
168
168
  this.emitStation(order, next, wip, product.gtin);
@@ -171,13 +171,13 @@ export class MesKernel extends FlowEngine {
171
171
  return;
172
172
  }
173
173
  // 마지막 스테이션(조립) → 완성차 (transform 1→1, disposition 으로 수율 loss → OEE 품질)
174
- const fgStore = this.nodeByType('fg-store');
174
+ const fgStore = this.locationByType('fg-store');
175
175
  const wip = order.allocated[0];
176
176
  const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
177
177
  this.recordOutput(t.resource, good); // OEE 품질(마지막 자원별 양품/불량)
178
178
  const disp = good ? DISP.sellable : DISP.non_sellable;
179
179
  const outputEpc = sgtinUri(CP, product.ref, ++this.prodSeq);
180
- 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: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
180
+ 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 }] });
181
181
  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 }));
182
182
  order.allocated = [];
183
183
  order.fulfilled = 1;
@@ -204,7 +204,7 @@ export class MesKernel extends FlowEngine {
204
204
  }
205
205
  /** 정의 모드 수령 — skuMix gtin 이 레시피 입력 자재면 raw-store 에 생성. */
206
206
  onArrivalDef(spec) {
207
- const rawStore = this.nodeByType('raw-store');
207
+ const rawStore = this.locationByType('raw-store');
208
208
  if (!rawStore)
209
209
  return;
210
210
  const gtin = this.pickGtin(spec.content.skuMix);
@@ -228,14 +228,14 @@ export class MesKernel extends FlowEngine {
228
228
  /** 정의 모드 할당 — 레시피 입력 BOM 전량 확보 후 첫 라우트 스텝 태스크. */
229
229
  allocateDef(o) {
230
230
  const ops = this.routeOps();
231
- if (!ops.length || !ops[0].nodeType || !this.nodeByType(ops[0].nodeType))
231
+ if (!ops.length || !ops[0].locationType || !this.locationByType(ops[0].locationType))
232
232
  return;
233
233
  const rc = this.recipeDef();
234
234
  const picks = [];
235
235
  for (const line of rc.inputs) {
236
236
  const g = this.classOf(line.material);
237
237
  const available = [...this.items.values()]
238
- .filter(i => i.gtin === g && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === 'raw-store')
238
+ .filter(i => i.gtin === g && i.disposition === DISP.sellable && this.locations.get(i.location)?.type === 'raw-store')
239
239
  .map(i => ({ epc: i.epc, location: i.location, qty: 1 }));
240
240
  const chosen = this.policy.selectStock({ gtin: g, qty: line.qty, available });
241
241
  if (chosen.length < line.qty)
@@ -251,8 +251,8 @@ export class MesKernel extends FlowEngine {
251
251
  this.emitOrder(o);
252
252
  }
253
253
  emitStationDef(o, op, itemEpc) {
254
- const node = this.nodeByType(op.nodeType);
255
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: node.id, toNode: node.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: node.id, toNode: node.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
254
+ const loc = this.locationByType(op.locationType);
255
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
256
256
  this.tasks.set(task.id, task);
257
257
  this.emitTask(task);
258
258
  }
@@ -262,14 +262,14 @@ export class MesKernel extends FlowEngine {
262
262
  const rc = this.recipeDef();
263
263
  const ops = this.routeOps();
264
264
  const i = ops.findIndex(s => s.key === t.kind);
265
- const node = this.nodes.get(t.toNode);
265
+ const loc = this.locations.get(t.toNode);
266
266
  const bizStep = ops[i]?.bizStep ?? MES_BIZSTEP.producing;
267
267
  const isLast = i === ops.length - 1;
268
268
  if (!isLast) {
269
269
  const inputs = order.allocated.slice();
270
270
  const wip = sgtinUri(this.mesSpec.companyPrefix, 'WIP', ++this.wipSeq);
271
271
  const wipGtin = sgtinClass(this.mesSpec.companyPrefix, 'WIP');
272
- this.transform(inputs, [{ epc: wip, gtin: wipGtin, qty: 1, location: node.id, disposition: DISP.in_progress }], { bizStep, disposition: DISP.in_progress, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
272
+ 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 }] });
273
273
  order.allocated = [wip];
274
274
  const next = ops[i + 1];
275
275
  this.emitStationDef(order, next, wip);
@@ -277,14 +277,14 @@ export class MesKernel extends FlowEngine {
277
277
  this.emitOrder(order);
278
278
  return;
279
279
  }
280
- const fgStore = this.nodeByType('fg-store');
280
+ const fgStore = this.locationByType('fg-store');
281
281
  const wip = order.allocated[0];
282
282
  const good = this.rng() < (this.paramNumber(t.kind, OP_PARAM.yield) ?? DEFAULT_YIELD);
283
283
  this.recordOutput(t.resource, good);
284
284
  const disp = good ? DISP.sellable : DISP.non_sellable;
285
285
  const outEpc = this.serialOf(rc.outputs[0].material, ++this.prodSeq);
286
286
  const outGtin = this.classOf(rc.outputs[0].material);
287
- this.transform([wip], [{ epc: outEpc, gtin: outGtin, qty: 1, location: fgStore.id, disposition: disp }], { bizStep, disposition: disp, transformationId: order.bizTransaction, readPoint: node.id, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: order.bizTransaction }] });
287
+ this.transform([wip], [{ 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 }] });
288
288
  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 }));
289
289
  order.allocated = [];
290
290
  order.fulfilled = 1;
@@ -8,7 +8,7 @@ export declare const MES_BIZSTEP: {
8
8
  export declare const BTT_PRODORDER = "urn:epcglobal:cbv:btt:prodorder";
9
9
  /** 직렬 SGTIN URI (원자재 단위·완제품). */
10
10
  export declare function sgtinUri(companyPrefix: string, itemRef: string, serial: number): string;
11
- /** MES 노드 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(nodeByType/.type). 도메인 SSOT.
11
+ /** MES 자리 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(locationByType/.type). 도메인 SSOT.
12
12
  * 트레일러 제조 라인: 자재→프레임 절단→용접→도장→조립→완성차(kernel ROUTE 와 일치). */
13
- export declare const MES_NODE_TYPES: readonly ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
13
+ export declare const MES_LOCATION_TYPES: readonly ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
14
14
  export declare const MES_TYPES: TwinTypeInfo[];
@@ -10,15 +10,15 @@ export const BTT_PRODORDER = 'urn:epcglobal:cbv:btt:prodorder';
10
10
  export function sgtinUri(companyPrefix, itemRef, serial) {
11
11
  return `urn:epc:id:sgtin:${companyPrefix}.${itemRef}.${serial}`;
12
12
  }
13
- /** MES 노드 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(nodeByType/.type). 도메인 SSOT.
13
+ /** MES 자리 타입 카탈로그 — 커널이 키로 쓰는 제조 로케이션 타입(locationByType/.type). 도메인 SSOT.
14
14
  * 트레일러 제조 라인: 자재→프레임 절단→용접→도장→조립→완성차(kernel ROUTE 와 일치). */
15
- export const MES_NODE_TYPES = ['raw-store', 'cut-station', 'weld-station', 'paint-booth', 'assembly-line', 'fg-store'];
15
+ export const MES_LOCATION_TYPES = ['raw-store', 'cut-station', 'weld-station', 'paint-booth', 'assembly-line', 'fg-store'];
16
16
  /*
17
- * 트윈 타입 서술(ADR-0018 확장) — 노드 키는 MES_NODE_TYPES 단일 출처에서 파생 + 무버(자원) 타입 추가.
18
- * MES=ISA-95 앵커: 스테이션=WorkCenter, 저장소=bizLocation. 무버(가공설비)=Equipment/자산(GIAI). key 는 flow resourceType 과 일치.
17
+ * 트윈 타입 서술(ADR-0018 확장) — 자리 키는 MES_LOCATION_TYPES 단일 출처에서 파생 + 설비(자원) 타입 추가.
18
+ * MES=ISA-95 앵커: 스테이션=WorkCenter, 저장소=bizLocation. 설비(가공설비)=Equipment/자산(GIAI). key 는 flow resourceType 과 일치.
19
19
  */
20
- // label 은 언어 중립 i18n 키(twin.type.<key>) — cls(표준 클래스)만 노드별 메타로 유지. 사람 언어는 표현계층(L2).
21
- const MES_NODE_CLS = {
20
+ // label 은 언어 중립 i18n 키(twin.type.<key>) — cls(표준 클래스)만 자리별 메타로 유지. 사람 언어는 표현계층(L2).
21
+ const MES_LOCATION_CLS = {
22
22
  'raw-store': { epcis: 'bizLocation' },
23
23
  'cut-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
24
24
  'weld-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
@@ -27,9 +27,9 @@ const MES_NODE_CLS = {
27
27
  'fg-store': { epcis: 'bizLocation' }
28
28
  };
29
29
  export const MES_TYPES = [
30
- ...MES_NODE_TYPES.map((k) => ({ key: k, role: 'node', label: `twin.type.${k}`, standardClass: MES_NODE_CLS[k] ?? {}, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
31
- { key: 'cutter', role: 'mover', label: 'twin.type.cutter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
32
- { key: 'welder', role: 'mover', label: 'twin.type.welder', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
33
- { key: 'painter', role: 'mover', label: 'twin.type.painter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
34
- { key: 'assembler', role: 'mover', label: 'twin.type.assembler', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
30
+ ...MES_LOCATION_TYPES.map((k) => ({ key: k, role: 'location', label: `twin.type.${k}`, standardClass: MES_LOCATION_CLS[k] ?? {}, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
31
+ { key: 'cutter', role: 'equipment', label: 'twin.type.cutter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
32
+ { key: 'welder', role: 'equipment', label: 'twin.type.welder', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
33
+ { key: 'painter', role: 'equipment', label: 'twin.type.painter', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
34
+ { key: 'assembler', role: 'equipment', label: 'twin.type.assembler', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
35
35
  ];
@@ -1,4 +1,4 @@
1
- import type { AssetState, BoardDef, CanonicalEnvelope, NodeState, ItemState, MoverState, PersonState, TaskState, OrderState } from './contract.ts';
1
+ import type { AssetState, BoardDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState } from './contract.ts';
2
2
  /**
3
3
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
4
4
  *
@@ -8,7 +8,7 @@ import type { AssetState, BoardDef, CanonicalEnvelope, NodeState, ItemState, Mov
8
8
  */
9
9
  export interface MasterUpdate {
10
10
  op: 'upsert' | 'remove';
11
- node: {
11
+ location: {
12
12
  id: string;
13
13
  type?: string;
14
14
  capacity?: number;
@@ -28,14 +28,14 @@ export interface ProjectedState {
28
28
  correctiveEventIDs: string[];
29
29
  eventID?: string;
30
30
  }[];
31
- nodes: NodeState[];
31
+ locations: LocationState[];
32
32
  items: ItemState[];
33
33
  /** 사람 — 등급·교대·투입. 인원을 선언하지 않은 트윈에서는 빈 배열. */
34
34
  persons: PersonState[];
35
35
  /** 물리 자산(반복사용) — 자산을 선언하지 않은 트윈에서는 빈 배열. */
36
36
  assets: AssetState[];
37
37
  tasks: TaskState[];
38
- movers: MoverState[];
38
+ equipment: EquipmentState[];
39
39
  orders: OrderState[];
40
40
  }
41
41
  export declare class ObservedReducer {
@@ -46,7 +46,7 @@ export declare class ObservedReducer {
46
46
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
47
47
  private pendingParent;
48
48
  private tasks;
49
- private movers;
49
+ private equipment;
50
50
  private persons;
51
51
  private assets;
52
52
  private orders;
@@ -59,7 +59,7 @@ export declare class ObservedReducer {
59
59
  /**
60
60
  * 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
61
61
  *
62
- * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
62
+ * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `locations` 에는
63
63
  * 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
64
64
  * 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
65
65
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
@@ -98,6 +98,6 @@ export declare class ObservedReducer {
98
98
  private mergeItem;
99
99
  /** 이탈(DELETE) — 아이템 + 조립 자식(재귀) 제거. 화물 SSCC DELETE 시 팔레트도 함께 이탈. */
100
100
  private remove;
101
- /** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
101
+ /** 현재 투영 State — 자리 점유는 아이템 위치 집계로 유도(pure projection). */
102
102
  snapshot(): ProjectedState;
103
103
  }
@@ -16,10 +16,10 @@
16
16
  *
17
17
  * 두 갈래 이벤트를 함께 접는다:
18
18
  * - EPCIS(epcis.*) → 재고/위치/조립 (What/Where)
19
- * - 운영 델타(task/equipment/order.status) → tasks·movers·orders (EPCIS 로 재구성 불가한 절반)
19
+ * - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
20
20
  * 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
21
21
  */
22
- import { OP_EVENT, nodeStatusOf } from "./contract.js";
22
+ import { OP_EVENT, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets } from "./contract.js";
23
23
  import { ILMD_ATTR, parseEpc } from "./epcis.js";
24
24
  /**
25
25
  * 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
@@ -39,7 +39,7 @@ export class ObservedReducer {
39
39
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
40
40
  pendingParent = new Map(); // 자식 EPC → 부모(물류단위)
41
41
  tasks = new Map();
42
- movers = new Map();
42
+ equipment = new Map();
43
43
  persons = new Map();
44
44
  assets = new Map();
45
45
  orders = new Map();
@@ -47,36 +47,36 @@ export class ObservedReducer {
47
47
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
48
48
  corrections = [];
49
49
  constructor(board) {
50
- for (const n of board.nodes)
50
+ for (const n of readBoardLocations(board))
51
51
  this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
52
- // 무버 기준선(마스터) — equipment.status 델타로 갱신됨.
53
- for (const m of board.movers)
54
- this.movers.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeNode, origin: 'master' });
52
+ // 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
53
+ for (const m of readBoardEquipment(board))
54
+ this.equipment.set(m.id, { id: m.id, kind: m.kind, status: 'idle', location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), origin: 'master' });
55
55
  // 사람 기준선(마스터) — person.status 델타로 갱신됨.
56
56
  for (const p of board.persons ?? [])
57
- this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle' });
58
- for (const a of board.assets ?? [])
59
- this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: 'idle' });
57
+ this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}) });
58
+ for (const a of readBoardAssets(board))
59
+ this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}) });
60
60
  }
61
61
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
62
62
  applyMaster(u) {
63
63
  if (u.op === 'remove') {
64
- this.master.delete(u.node.id);
64
+ this.master.delete(u.location.id);
65
65
  return;
66
66
  }
67
- const cur = this.master.get(u.node.id);
67
+ const cur = this.master.get(u.location.id);
68
68
  /* **모르는 용량을 0 으로 뭉개지 않는다.** 0 은 "자리가 없다" 는 사실 주장이고, 미지정은 "모른다" 다.
69
- * 계약(`NodeState.capacity`)이 선택 필드로 둔 이유가 이것이며, 0 으로 채우면 포화 판정이 거짓으로
70
- * 성립하고 배정 정책이 그 노드를 영구히 배제한다. 그리고 upsert 가 **구역 소속(parentId)을 지우지
69
+ * 계약(`LocationState.capacity`)이 선택 필드로 둔 이유가 이것이며, 0 으로 채우면 포화 판정이 거짓으로
70
+ * 성립하고 배정 정책이 그 자리를 영구히 배제한다. 그리고 upsert 가 **구역 소속(parentId)을 지우지
71
71
  * 않는다** — 마스터가 말하지 않은 것은 기존 값을 지키는 것이 upsert 의 뜻이다. */
72
- const capacity = u.node.capacity ?? cur?.capacity;
73
- const parallelism = u.node.parallelism ?? cur?.parallelism;
74
- this.master.set(u.node.id, {
75
- id: u.node.id,
76
- type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
72
+ const capacity = u.location.capacity ?? cur?.capacity;
73
+ const parallelism = u.location.parallelism ?? cur?.parallelism;
74
+ this.master.set(u.location.id, {
75
+ id: u.location.id,
76
+ type: u.location.type ?? cur?.type ?? UNKNOWN_TYPE,
77
77
  ...(capacity === undefined ? {} : { capacity }),
78
78
  ...(parallelism === undefined ? {} : { parallelism }),
79
- ...(u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {}),
79
+ ...(u.location.parentId ?? cur?.parentId ? { parentId: u.location.parentId ?? cur?.parentId } : {}),
80
80
  /* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
81
81
  origin: 'master'
82
82
  });
@@ -84,7 +84,7 @@ export class ObservedReducer {
84
84
  /**
85
85
  * 관측된 로케이션을 구조로 승격 — **이벤트가 가르쳐 준 것을 구조에서 지우지 않는다.**
86
86
  *
87
- * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `nodes` 에는
87
+ * 마스터에 없는 로케이션에서 물품이 관측되면, 예전에는 물품의 `location` 에만 남고 `locations` 에는
88
88
  * 나타나지 않았다. 그 결과 그 자리는 스키매틱에 없고, 점유가 집계되지 않고, 병목 주목이 뜰 수
89
89
  * 없었다 — **사실은 들어왔는데 구조가 모르는 상태.** 이제 최소 형태로 승격한다:
90
90
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
@@ -155,13 +155,17 @@ export class ObservedReducer {
155
155
  }
156
156
  case OP_EVENT.equipment: {
157
157
  const d = e.data;
158
- if (this.stale(`mover:${d.moverId}`, e))
158
+ if (this.stale(`eq:${d.moverId}`, e))
159
159
  return;
160
160
  /* 마스터에 없던 자원도 관측으로 자란다(예전부터 그랬다) — 이제 그 사실을 출처로 표시하고,
161
161
  * 자원이 있다고 말하는 자리도 구조로 승격한다(로케이션만 자라지 않던 비대칭 해소). */
162
162
  this.touchLocation(d.location);
163
- const known = this.movers.get(d.moverId);
164
- this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, taskId: d.taskId, motion: d.motion, origin: known?.origin ?? 'observed' });
163
+ const known = this.equipment.get(d.moverId);
164
+ /* 소속(homeLocation)은 델타가 말해 주면 값, 말해 주면 **이미 아는 값을 지킨다**
165
+ 마스터로 알던 소속을 관측 델타 하나가 지워 버리면 그 설비가 롤업에서 통째로 빠진다.
166
+ 소속은 자주 바뀌는 사실이 아니므로 침묵을 "소속 없음" 으로 읽지 않는다. */
167
+ const homeLocation = d.homeLocation ?? known?.homeLocation;
168
+ this.equipment.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, ...(homeLocation ? { homeLocation } : {}), taskId: d.taskId, motion: d.motion, origin: known?.origin ?? 'observed' });
165
169
  break;
166
170
  }
167
171
  case OP_EVENT.person: {
@@ -169,11 +173,17 @@ export class ObservedReducer {
169
173
  if (this.stale(`person:${d.personId}`, e))
170
174
  return;
171
175
  /* 사람도 관측으로 자란다(자원과 같은 정책) — 마스터에 없던 사람이 이벤트에 나오면 승격한다. */
176
+ const known = this.persons.get(d.personId);
177
+ /* 마스터로 알던 사실(등급·위치·속성)은 델타가 말하지 않으면 **지키지 않고 지우면** 안 된다 —
178
+ 설비 소속과 같은 규율이다(침묵을 "없어졌다" 로 읽지 않는다). */
172
179
  this.persons.set(d.personId, {
173
180
  id: d.personId,
174
- personnelClass: d.personnelClass ?? this.persons.get(d.personId)?.personnelClass,
181
+ personnelClassIds: d.personnelClassIds ?? known?.personnelClassIds,
175
182
  status: d.status,
176
183
  taskId: d.taskId,
184
+ ...((d.location ?? known?.location) ? { location: d.location ?? known?.location } : {}),
185
+ ...(known?.properties ? { properties: known.properties } : {}),
186
+ ...(known?.testSpecificationIds ? { testSpecificationIds: known.testSpecificationIds } : {}),
177
187
  ...(d.offShift ? { offShift: true } : {})
178
188
  });
179
189
  break;
@@ -185,7 +195,7 @@ export class ObservedReducer {
185
195
  const cur = this.assets.get(d.assetId);
186
196
  this.assets.set(d.assetId, {
187
197
  id: d.assetId,
188
- assetClass: d.assetClass ?? cur?.assetClass,
198
+ assetClassIds: d.assetClassIds ?? cur?.assetClassIds,
189
199
  location: d.location ?? cur?.location,
190
200
  status: d.status,
191
201
  taskId: d.taskId,
@@ -351,7 +361,7 @@ export class ObservedReducer {
351
361
  this.remove(c);
352
362
  }
353
363
  }
354
- /** 현재 투영 State — 노드 점유는 아이템 위치 집계로 유도(pure projection). */
364
+ /** 현재 투영 State — 자리 점유는 아이템 위치 집계로 유도(pure projection). */
355
365
  snapshot() {
356
366
  const occ = new Map();
357
367
  for (const it of this.items.values())
@@ -359,9 +369,9 @@ export class ObservedReducer {
359
369
  return {
360
370
  revision: this.revision,
361
371
  ...(this.corrections.length ? { corrections: this.corrections.map(c => ({ ...c })) } : {}),
362
- nodes: [...this.master.values()].map(n => {
372
+ locations: [...this.master.values()].map(n => {
363
373
  const occupancy = occ.get(n.id) ?? 0;
364
- const status = nodeStatusOf({ occupancy, capacity: n.capacity });
374
+ const status = locationStatusOf({ occupancy, capacity: n.capacity });
365
375
  return {
366
376
  id: n.id, type: n.type, occupancy,
367
377
  ...(status ? { status } : {}),
@@ -377,7 +387,7 @@ export class ObservedReducer {
377
387
  persons: [...this.persons.values()].map(p => ({ ...p })),
378
388
  assets: [...this.assets.values()].map(a => ({ ...a })),
379
389
  tasks: [...this.tasks.values()].map(t => ({ ...t })),
380
- movers: [...this.movers.values()].map(m => ({ ...m })),
390
+ equipment: [...this.equipment.values()].map(m => ({ ...m })),
381
391
  orders: [...this.orders.values()].map(o => ({ ...o }))
382
392
  };
383
393
  }
@@ -15,14 +15,14 @@ export interface TaskDeltaRow {
15
15
  }
16
16
  /** 작업에 붙어 있는 축의 값 — 어느 전이에서 왔든 모은다. */
17
17
  export interface TaskFacets {
18
- /** 수행 자원(무버). 설계상 자원을 쓰지 않는 공정(체류)에서는 없는 것이 정상이다. */
18
+ /** 수행 자원(설비). 설계상 자원을 쓰지 않는 공정(체류)에서는 없는 것이 정상이다. */
19
19
  resource?: string;
20
20
  /** 작업 종류(커널 어휘 — 사람이 읽는 라벨은 표현 계층의 몫). */
21
21
  kind?: string;
22
22
  /** 소속 오더. **없으면 없는 것이다** — 다른 식별자로 대체하지 않는다. */
23
23
  order?: string;
24
24
  /** 가장 최근 도착 지점. 진행에 따라 바뀌므로 마지막 것이 사실이다. */
25
- node?: string;
25
+ location?: string;
26
26
  }
27
27
  /** 작업 하나의 이정표 — 소비처가 이것으로 구간·지표를 만든다. */
28
28
  export interface TaskRecord {
package/dist/task-fold.js CHANGED
@@ -38,7 +38,7 @@ export function foldTaskRecords(rows) {
38
38
  rec = { taskId: id, facets: {} };
39
39
  byTask.set(id, rec);
40
40
  }
41
- /* 축의 값 — 처음 본 것을 남긴다(작업의 종류·오더·자원은 바뀌지 않는다). 노드는 예외로 마지막 것. */
41
+ /* 축의 값 — 처음 본 것을 남긴다(작업의 종류·오더·자원은 바뀌지 않는다). 자리는 예외로 마지막 것. */
42
42
  const f = rec.facets;
43
43
  if (f.resource === undefined && d.resourceRef)
44
44
  f.resource = d.resourceRef;
@@ -46,9 +46,9 @@ export function foldTaskRecords(rows) {
46
46
  f.kind = d.kind;
47
47
  if (f.order === undefined && d.orderId)
48
48
  f.order = d.orderId;
49
- const node = d.toNode ?? d.fromNode;
50
- if (node)
51
- f.node = node;
49
+ const loc = d.toNode ?? d.fromNode;
50
+ if (loc)
51
+ f.location = loc;
52
52
  if (d.status === 'created')
53
53
  rec.createdMs = at;
54
54
  else if (d.status === 'in-progress')
@@ -0,0 +1,19 @@
1
+ /** 금지 어휘 — 표준 어휘로 대체된 옛 낱말. 파생 식별자까지 잡도록 부분 일치로 본다. */
2
+ export declare const RETIRED_VOCABULARY: readonly ["mover", "Mover", "MOVER", "node", "Node", "NODE"];
3
+ /**
4
+ * 예외 — 남아 있는 것이 **정당한** 토큰. 각 항목에 이유가 붙는다.
5
+ *
6
+ * 판정은 "이 토큰을 정확히 포함하는가" 다(부분 문자열). 그래서 `moverId` 를 허용하면 `moverIds` 도
7
+ * 통과한다 — 와이어 필드에서 파생한 변수까지 허용하려는 의도다.
8
+ */
9
+ export declare const VOCABULARY_EXCEPTIONS: {
10
+ token: string;
11
+ why: string;
12
+ }[];
13
+ /**
14
+ * 한 줄에서 금지 어휘 위반을 찾는다 — 예외에 걸리는 부분은 먼저 지워 놓고 본다.
15
+ *
16
+ * 반환은 위반 낱말 목록(중복 제거 전). 비어 있으면 그 줄은 통과.
17
+ */
18
+ export declare const GUARD_PRAGMA = "vocabulary-guard: allow";
19
+ export declare function retiredVocabularyIn(line: string): string[];