@operato/twin-kernel 0.0.5 → 0.0.6

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.
@@ -92,28 +92,34 @@ export interface Attention {
92
92
  kind: string;
93
93
  severity: AttentionSeverity;
94
94
  state?: AttentionState;
95
- title: string;
96
- detail?: string;
97
95
  anchor: {
98
96
  nodeId?: string;
99
97
  moverId?: string;
100
98
  orderId?: string;
101
99
  };
102
100
  since?: number;
103
- rationale?: string;
101
+ /**
102
+ * 표현용 원시 파라미터(언어 중립). 커널은 사람이 읽는 문장을 만들지 않는다 — kind + params 만 방출하고
103
+ * title/detail/rationale 는 표현계층(클라 i18next 템플릿)이 kind 로 키를 골라 params 를 보간해 렌더.
104
+ * 예: bottleneck → { nodeId, occupancy, capacity, ratioPct, saturated }.
105
+ */
106
+ params?: Record<string, string | number>;
104
107
  recommendedActions?: RecommendedAction[];
105
108
  suggestedAction?: {
109
+ code: string;
106
110
  command: string;
107
111
  args?: unknown;
108
- label: string;
109
112
  };
110
113
  }
111
- /** 조치방향 — command 있으면 원클릭 실행, 없으면 권고 텍스트. */
114
+ /**
115
+ * 조치방향 — code=안정 조치 키(언어 중립). command 있으면 원클릭 실행, 없으면 권고.
116
+ * 라벨·힌트(사람 언어)는 표현계층이 code 로 렌더(커널은 문장 미보유). command 보유 조치는 code=command 문자열,
117
+ * 권고만(hint only)이던 조치는 'advice.*' 코드.
118
+ */
112
119
  export interface RecommendedAction {
113
- label: string;
120
+ code: string;
114
121
  command?: string;
115
122
  args?: unknown;
116
- hint?: string;
117
123
  }
118
124
  export interface StateSnapshot {
119
125
  revision: number;
@@ -135,6 +141,12 @@ export interface Command<T = unknown> {
135
141
  export interface CommandAck {
136
142
  commandId: string;
137
143
  accepted: boolean;
144
+ /**
145
+ * 거절 사유 — 언어 중립. errorCode(안정 코드) + errorParams(원시값)로 방출하고 사람 언어는
146
+ * 표현계층(클라 i18next)이 렌더한다(무방언·다국어, attention 과 동형). error 는 개발자/로그용 영어 폴백.
147
+ */
148
+ errorCode?: string;
149
+ errorParams?: Record<string, string | number>;
138
150
  error?: string;
139
151
  }
140
152
  /**
@@ -3,9 +3,10 @@ import { YMS_TYPES } from "./yms-profile.js";
3
3
  import { MES_TYPES } from "./mes-profile.js";
4
4
  const nodeKeys = (types) => types.filter(t => t.role === 'node').map(t => t.key);
5
5
  export const DOMAIN_CATALOG = {
6
- wms: { system: 'wms', label: 'WMS (물류창고)', types: WMS_TYPES, nodeTypes: nodeKeys(WMS_TYPES) },
7
- yms: { system: 'yms', label: 'YMS (야드)', types: YMS_TYPES, nodeTypes: nodeKeys(YMS_TYPES) },
8
- mes: { system: 'mes', label: 'MES (제조)', types: MES_TYPES, nodeTypes: nodeKeys(MES_TYPES) }
6
+ // label 언어 중립 i18n (twin.system.<code>) 사람 언어는 표현계층이 렌더(L2).
7
+ wms: { system: 'wms', label: 'twin.system.wms', types: WMS_TYPES, nodeTypes: nodeKeys(WMS_TYPES) },
8
+ yms: { system: 'yms', label: 'twin.system.yms', types: YMS_TYPES, nodeTypes: nodeKeys(YMS_TYPES) },
9
+ mes: { system: 'mes', label: 'twin.system.mes', types: MES_TYPES, nodeTypes: nodeKeys(MES_TYPES) }
9
10
  };
10
11
  export const DOMAIN_SYSTEMS = ['wms', 'yms', 'mes'];
11
12
  /** 타입 키 → 능력 프로파일(커널 SSOT). 호스트가 라이브 페이로드에 투영, 컴포넌트가 능력을 렌더. */
@@ -29,19 +29,20 @@ function mulberry32(seed) {
29
29
  * sim/live 가 같은 임계값·규칙을 쓴다. good/scrap 미제공(관측 상태에 없음)이면 scrap-high 는 자연 스킵(metric 층 갭).
30
30
  */
31
31
  export function deriveAttentions(view, acked) {
32
+ // 언어 중립: kind + params(원시값) + 조치 code 만 방출. 사람이 읽는 title/detail/rationale/라벨은
33
+ // 표현계층(클라 i18next)이 kind/code 로 렌더한다(무방언·다국어 — design/plans/i18n.md L3).
32
34
  const out = [];
33
35
  for (const m of view.movers) {
34
36
  if (m.status === 'down') {
35
- const where = m.location ?? '해당 공정';
36
37
  out.push({
37
- id: `breakdown:${m.id}`, kind: 'breakdown', severity: 'critical', title: `설비 고장 · ${m.id}`,
38
+ id: `breakdown:${m.id}`, kind: 'breakdown', severity: 'critical',
38
39
  anchor: { moverId: m.id, nodeId: m.location },
39
- rationale: `설비 정지 ${where}의 작업이 중단되어 하류 정체·처리량 감소로 이어집니다.`,
40
+ params: { moverId: m.id, ...(m.location ? { nodeId: m.location } : {}) },
40
41
  recommendedActions: [
41
- { label: '수리 지시', command: CMD.resourceRepair, args: { resourceId: m.id }, hint: '설비를 즉시 복구해 가동 재개' },
42
- { label: '계획 정지 유지', command: CMD.resourceHold, args: { resourceId: m.id }, hint: '수리 전까지 배정에서 제외' }
42
+ { code: 'act.repair', command: CMD.resourceRepair, args: { resourceId: m.id } },
43
+ { code: 'act.hold-until-repair', command: CMD.resourceHold, args: { resourceId: m.id } }
43
44
  ],
44
- suggestedAction: { command: CMD.resourceRepair, args: { resourceId: m.id }, label: '수리' }
45
+ suggestedAction: { code: 'act.repair', command: CMD.resourceRepair, args: { resourceId: m.id } }
45
46
  });
46
47
  }
47
48
  }
@@ -52,13 +53,10 @@ export function deriveAttentions(view, acked) {
52
53
  const saturated = r >= 1;
53
54
  out.push({
54
55
  id: `bottleneck:${n.id}`, kind: 'bottleneck', severity: saturated ? 'high' : 'medium',
55
- title: `${saturated ? '병목' : '혼잡'} · ${n.id} 점유 ${n.occupancy}/${n.capacity}`,
56
56
  anchor: { nodeId: n.id },
57
- rationale: `${n.id} 점유 ${Math.round(r * 100)}% 상류 대기가 쌓여 리드타임이 늘고 처리량이 제한됩니다.`,
58
- recommendedActions: [
59
- { label: '자원 추가 검토', hint: '무버·처리 능력을 보강해 병목 완화' },
60
- { label: '하류 우선 처리', hint: '적체 해소를 위해 배출 우선순위 조정' }
61
- ]
57
+ params: { nodeId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
58
+ recommendedActions: [{ code: 'advice.add-resource' }, { code: 'advice.downstream-priority' }]
59
+ // 권고만(command 없음) 표현계층이 advice.* 코드로 렌더
62
60
  });
63
61
  }
64
62
  }
@@ -70,25 +68,24 @@ export function deriveAttentions(view, acked) {
70
68
  if (rate >= 0.15)
71
69
  out.push({
72
70
  id: `scrap:${m.id}`, kind: 'scrap-high', severity: rate >= 0.3 ? 'high' : 'medium',
73
- title: `불량률 ${Math.round(rate * 100)}% · ${m.id}`, detail: `양품 ${m.goodCount} / 불량 ${m.scrapCount}`,
74
71
  anchor: { moverId: m.id, nodeId: m.location },
75
- rationale: `불량률 ${Math.round(rate * 100)}% — 재작업·수율 손실이 누적됩니다. 설비 상태·셋업 편차를 점검하세요.`,
72
+ params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
76
73
  recommendedActions: [
77
- { label: '설비 점검 정지', command: CMD.resourceHold, args: { resourceId: m.id }, hint: '점검을 위해 배정에서 제외' },
78
- { label: '계측 리셋', command: CMD.resourceResetMetrics, args: { resourceId: m.id }, hint: '교정 후 수율 재측정' }
74
+ { code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } },
75
+ { code: 'act.reset-metrics', command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
79
76
  ],
80
- suggestedAction: { command: CMD.resourceHold, args: { resourceId: m.id }, label: '점검 정지' }
77
+ suggestedAction: { code: 'act.hold-for-inspection', command: CMD.resourceHold, args: { resourceId: m.id } }
81
78
  });
82
79
  }
83
80
  }
84
81
  for (const o of view.orders) {
85
82
  if (o.held)
86
83
  out.push({
87
- id: `hold:${o.id}`, kind: 'hold', severity: 'medium', title: `오더 보류 · ${o.id}`,
84
+ id: `hold:${o.id}`, kind: 'hold', severity: 'medium',
88
85
  anchor: { orderId: o.id },
89
- rationale: `오더 진행이 멈춤 — 납기 지연 위험. 보류 사유 해소 후 재개하세요.`,
90
- recommendedActions: [{ label: '재개', command: CMD.orderResume, args: { orderId: o.id }, hint: '보류를 풀고 흐름 재개' }],
91
- suggestedAction: { command: CMD.orderResume, args: { orderId: o.id }, label: '재개' }
86
+ params: { orderId: o.id },
87
+ recommendedActions: [{ code: 'act.resume-order', command: CMD.orderResume, args: { orderId: o.id } }],
88
+ suggestedAction: { code: 'act.resume-order', command: CMD.orderResume, args: { orderId: o.id } }
92
89
  });
93
90
  }
94
91
  if (acked)
@@ -221,14 +218,15 @@ export class FlowEngine {
221
218
  _acked = new Set(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
222
219
  dispatch(cmd) {
223
220
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
224
- const fail = (error) => ({ commandId: cmd.commandId, accepted: false, error });
221
+ // 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
222
+ const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
225
223
  switch (cmd.type) {
226
224
  case CMD.orderHold:
227
225
  case CMD.orderResume: {
228
226
  const orderId = cmd.args?.orderId;
229
227
  const order = orderId ? this.orders.get(orderId) : undefined;
230
228
  if (!order)
231
- return fail(`order 없음: ${orderId}`);
229
+ return fail('order-not-found', { orderId: orderId ?? '' });
232
230
  order.held = cmd.type === CMD.orderHold;
233
231
  this.emitOrder(order);
234
232
  return ok();
@@ -244,7 +242,7 @@ export class FlowEngine {
244
242
  case CMD.resourceResume: {
245
243
  const m = this.movers.get(cmd.args?.resourceId ?? '');
246
244
  if (!m)
247
- return fail('resource 없음');
245
+ return fail('resource-not-found');
248
246
  m.held = cmd.type === CMD.resourceHold; // 계획 정지 → 배정 스킵
249
247
  this.emitMover(m);
250
248
  return ok();
@@ -253,7 +251,7 @@ export class FlowEngine {
253
251
  const a = cmd.args;
254
252
  const m = this.movers.get(a?.resourceId ?? '');
255
253
  if (!m)
256
- return fail('resource 없음');
254
+ return fail('resource-not-found');
257
255
  if (m.status !== 'down') {
258
256
  m.status = 'down'; // 비계획 고장 주입 — 기존 고장 machinery(processFailures)가 downMs 누적·수리 처리
259
257
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 60_000);
@@ -264,7 +262,7 @@ export class FlowEngine {
264
262
  case CMD.resourceRepair: {
265
263
  const m = this.movers.get(cmd.args?.resourceId ?? '');
266
264
  if (!m)
267
- return fail('resource 없음');
265
+ return fail('resource-not-found');
268
266
  if (m.status === 'down') {
269
267
  m.status = m.taskId ? 'busy' : 'idle'; // 진행중 task 있으면 재개(고장모델 수리 로직과 동형)
270
268
  m.repairUntilMs = undefined;
@@ -277,7 +275,7 @@ export class FlowEngine {
277
275
  case CMD.resourceResetMetrics: {
278
276
  const m = this.movers.get(cmd.args?.resourceId ?? '');
279
277
  if (!m)
280
- return fail('resource 없음');
278
+ return fail('resource-not-found');
281
279
  m.runMs = 0;
282
280
  m.setupMs = 0;
283
281
  m.downMs = 0;
@@ -293,9 +291,9 @@ export class FlowEngine {
293
291
  // 새 무버는 즉시 배정 대상(다음 tick). 유일 id 생성(충돌 회피). 좌표/persistence 는 호스트 몫(커널=위상만).
294
292
  const a = cmd.args;
295
293
  if (!a?.kind)
296
- return fail('kind 필요');
294
+ return fail('kind-required');
297
295
  if (!a?.homeNode || !this.nodes.has(a.homeNode))
298
- return fail(`homeNode 없음: ${a?.homeNode}`);
296
+ return fail('home-node-not-found', { homeNode: a?.homeNode ?? '' });
299
297
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
300
298
  let seq = this.movers.size;
301
299
  for (let i = 0; i < count; i++) {
@@ -315,7 +313,7 @@ export class FlowEngine {
315
313
  }
316
314
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
317
315
  handleCommand(cmd) {
318
- return { commandId: cmd.commandId, accepted: false, error: `알 없는 커맨드: ${cmd.type}` };
316
+ return { commandId: cmd.commandId, accepted: false, errorCode: 'unknown-command', errorParams: { type: cmd.type }, error: `unknown-command: ${cmd.type}` };
319
317
  }
320
318
  scenario = {
321
319
  load: (def) => {
package/dist/kernel.js CHANGED
@@ -72,7 +72,7 @@ export class WmsKernel extends FlowEngine {
72
72
  const a = cmd.args;
73
73
  const lines = a?.lines ?? (a?.gtin ? [{ gtin: a.gtin, qty: a.qty ?? 1 }] : []);
74
74
  if (lines.length === 0)
75
- return { commandId: cmd.commandId, accepted: false, error: 'order.release: gtin 또는 lines 필요' };
75
+ return { commandId: cmd.commandId, accepted: false, errorCode: 'order-release-needs-lines', error: 'order.release: gtin or lines required' };
76
76
  this.createSalesOrder(lines);
77
77
  return { commandId: cmd.commandId, accepted: true };
78
78
  }
@@ -65,10 +65,10 @@ export class MesKernel extends FlowEngine {
65
65
  if (cmd.type === MES_CMD.changeover) {
66
66
  const a = cmd.args;
67
67
  if (!a?.resourceId || !a?.gtin)
68
- return { commandId: cmd.commandId, accepted: false, error: 'mes.changeover: resourceId·gtin 필요' };
68
+ return { commandId: cmd.commandId, accepted: false, errorCode: 'changeover-needs-args', error: 'mes.changeover: resourceId and gtin required' };
69
69
  const m = this.movers.get(a.resourceId);
70
70
  if (!m)
71
- return { commandId: cmd.commandId, accepted: false, error: `resource 없음: ${a.resourceId}` };
71
+ return { commandId: cmd.commandId, accepted: false, errorCode: 'resource-not-found', errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
72
72
  if (m.lastChangeoverKey !== a.gtin) {
73
73
  m.setupMs += SETUP_MS; // 셋업 = OEE 가용성 손실
74
74
  m.lastChangeoverKey = a.gtin;
@@ -17,18 +17,19 @@ export const MES_NODE_TYPES = ['raw-store', 'cut-station', 'weld-station', 'pain
17
17
  * 트윈 타입 서술(ADR-0018 확장) — 노드 키는 MES_NODE_TYPES 단일 출처에서 파생 + 무버(자원) 타입 추가.
18
18
  * MES=ISA-95 앵커: 스테이션=WorkCenter, 저장소=bizLocation. 무버(가공설비)=Equipment/자산(GIAI). key 는 flow resourceType 과 일치.
19
19
  */
20
- const MES_NODE_META = {
21
- 'raw-store': { label: '자재 창고', cls: { epcis: 'bizLocation' } },
22
- 'cut-station': { label: '프레임 절단', cls: { isa95: 'WorkCenter', epcis: 'bizLocation' } },
23
- 'weld-station': { label: '용접', cls: { isa95: 'WorkCenter', epcis: 'bizLocation' } },
24
- 'paint-booth': { label: '도장', cls: { isa95: 'WorkCenter', epcis: 'bizLocation' } },
25
- 'assembly-line': { label: '조립·의장', cls: { isa95: 'WorkCenter', epcis: 'bizLocation' } },
26
- 'fg-store': { label: '완성차 보관', cls: { epcis: 'bizLocation' } }
20
+ // label 언어 중립 i18n 키(twin.type.<key>) — cls(표준 클래스)만 노드별 메타로 유지. 사람 언어는 표현계층(L2).
21
+ const MES_NODE_CLS = {
22
+ 'raw-store': { epcis: 'bizLocation' },
23
+ 'cut-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
24
+ 'weld-station': { isa95: 'WorkCenter', epcis: 'bizLocation' },
25
+ 'paint-booth': { isa95: 'WorkCenter', epcis: 'bizLocation' },
26
+ 'assembly-line': { isa95: 'WorkCenter', epcis: 'bizLocation' },
27
+ 'fg-store': { epcis: 'bizLocation' }
27
28
  };
28
29
  export const MES_TYPES = [
29
- ...MES_NODE_TYPES.map((k) => ({ key: k, role: 'node', label: MES_NODE_META[k]?.label ?? k, standardClass: MES_NODE_META[k]?.cls ?? {}, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
30
- { key: 'cutter', role: 'mover', label: '절단 설비', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
31
- { key: 'welder', role: 'mover', label: '용접 로봇', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
32
- { key: 'painter', role: 'mover', label: '도장 로봇', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] },
33
- { key: 'assembler', role: 'mover', label: '조립 설비', standardClass: { isa95: 'Equipment', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['processable', 'operable'] }
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'] }
34
35
  ];
@@ -22,8 +22,8 @@ export const WMS_NODE_TYPES = ['dock', 'storage', 'staging', 'dock-ship'];
22
22
  * 트윈 타입 서술(ADR-0018 확장) — 노드 키는 WMS_NODE_TYPES 단일 출처에서 파생 + 무버 타입 추가.
23
23
  * WMS=EPCIS 도메인: 로케이션=bizLocation(SGLN), 무버(지게차)=추적 오브젝트/자산(GIAI). 능력은 씬 소유라 미포함.
24
24
  */
25
- const WMS_NODE_LABELS = { dock: '입고 도크', storage: '보관 위치', staging: '스테이징', 'dock-ship': '출고 도크' };
25
+ // label 언어 중립 i18n 키(twin.type.<key>) 사람 언어는 표현계층이 렌더(design/plans/i18n.md L2).
26
26
  export const WMS_TYPES = [
27
- ...WMS_NODE_TYPES.map((k) => ({ key: k, role: 'node', label: WMS_NODE_LABELS[k] ?? k, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
28
- { key: 'forklift', role: 'mover', label: '지게차', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] }
27
+ ...WMS_NODE_TYPES.map((k) => ({ key: k, role: 'node', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
28
+ { key: 'forklift', role: 'mover', label: 'twin.type.forklift', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] }
29
29
  ];
@@ -18,8 +18,7 @@ export const YMS_NODE_TYPES = ['gate', 'yard-slot', 'dock-door', 'staging'];
18
18
  * 트윈 타입 서술(ADR-0018 확장) — 노드 키는 YMS_NODE_TYPES 단일 출처에서 파생 + 무버 타입 추가.
19
19
  * YMS=EPCIS zone: 로케이션/존=bizLocation(SGLN), 무버(야드 트랙터)=오브젝트/자산(GIAI).
20
20
  */
21
- const YMS_NODE_LABELS = { gate: '게이트', 'yard-slot': '야드 슬롯', 'dock-door': '도크 도어', staging: '스테이징' };
22
21
  export const YMS_TYPES = [
23
- ...YMS_NODE_TYPES.map((k) => ({ key: k, role: 'node', label: YMS_NODE_LABELS[k] ?? k, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
24
- { key: 'hostler', role: 'mover', label: '야드 트랙터', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] }
22
+ ...YMS_NODE_TYPES.map((k) => ({ key: k, role: 'node', label: `twin.type.${k}`, standardClass: { epcis: 'bizLocation' }, identity: { scheme: 'gs1:SGLN' }, capabilities: ['storable'] })),
23
+ { key: 'hostler', role: 'mover', label: 'twin.type.hostler', standardClass: { epcis: 'object', iso55000: 'Asset' }, identity: { scheme: 'gs1:GIAI' }, capabilities: ['mobile', 'operable'] }
25
24
  ];
@@ -531,10 +531,9 @@ var BTT = {
531
531
  so: "urn:epcglobal:cbv:btt:so"
532
532
  };
533
533
  var WMS_NODE_TYPES = ["dock", "storage", "staging", "dock-ship"];
534
- var WMS_NODE_LABELS = { dock: "\uC785\uACE0 \uB3C4\uD06C", storage: "\uBCF4\uAD00 \uC704\uCE58", staging: "\uC2A4\uD14C\uC774\uC9D5", "dock-ship": "\uCD9C\uACE0 \uB3C4\uD06C" };
535
534
  var WMS_TYPES = [
536
- ...WMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: WMS_NODE_LABELS[k] ?? k, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
537
- { key: "forklift", role: "mover", label: "\uC9C0\uAC8C\uCC28", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
535
+ ...WMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
536
+ { key: "forklift", role: "mover", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
538
537
  ];
539
538
 
540
539
  // src/capability.ts
@@ -602,10 +601,9 @@ function graiUri(companyPrefix, assetType, serial) {
602
601
  return `urn:epc:id:grai:${companyPrefix}.${assetType}.${String(serial).padStart(6, "0")}`;
603
602
  }
604
603
  var YMS_NODE_TYPES = ["gate", "yard-slot", "dock-door", "staging"];
605
- var YMS_NODE_LABELS = { gate: "\uAC8C\uC774\uD2B8", "yard-slot": "\uC57C\uB4DC \uC2AC\uB86F", "dock-door": "\uB3C4\uD06C \uB3C4\uC5B4", staging: "\uC2A4\uD14C\uC774\uC9D5" };
606
604
  var YMS_TYPES = [
607
- ...YMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: YMS_NODE_LABELS[k] ?? k, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
608
- { key: "hostler", role: "mover", label: "\uC57C\uB4DC \uD2B8\uB799\uD130", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
605
+ ...YMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
606
+ { key: "hostler", role: "mover", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
609
607
  ];
610
608
 
611
609
  // src/mes-profile.ts
@@ -622,28 +620,29 @@ function sgtinUri(companyPrefix, itemRef, serial) {
622
620
  return `urn:epc:id:sgtin:${companyPrefix}.${itemRef}.${serial}`;
623
621
  }
624
622
  var MES_NODE_TYPES = ["raw-store", "cut-station", "weld-station", "paint-booth", "assembly-line", "fg-store"];
625
- var MES_NODE_META = {
626
- "raw-store": { label: "\uC790\uC7AC \uCC3D\uACE0", cls: { epcis: "bizLocation" } },
627
- "cut-station": { label: "\uD504\uB808\uC784 \uC808\uB2E8", cls: { isa95: "WorkCenter", epcis: "bizLocation" } },
628
- "weld-station": { label: "\uC6A9\uC811", cls: { isa95: "WorkCenter", epcis: "bizLocation" } },
629
- "paint-booth": { label: "\uB3C4\uC7A5", cls: { isa95: "WorkCenter", epcis: "bizLocation" } },
630
- "assembly-line": { label: "\uC870\uB9BD\xB7\uC758\uC7A5", cls: { isa95: "WorkCenter", epcis: "bizLocation" } },
631
- "fg-store": { label: "\uC644\uC131\uCC28 \uBCF4\uAD00", cls: { epcis: "bizLocation" } }
623
+ var MES_NODE_CLS = {
624
+ "raw-store": { epcis: "bizLocation" },
625
+ "cut-station": { isa95: "WorkCenter", epcis: "bizLocation" },
626
+ "weld-station": { isa95: "WorkCenter", epcis: "bizLocation" },
627
+ "paint-booth": { isa95: "WorkCenter", epcis: "bizLocation" },
628
+ "assembly-line": { isa95: "WorkCenter", epcis: "bizLocation" },
629
+ "fg-store": { epcis: "bizLocation" }
632
630
  };
633
631
  var MES_TYPES = [
634
- ...MES_NODE_TYPES.map((k) => ({ key: k, role: "node", label: MES_NODE_META[k]?.label ?? k, standardClass: MES_NODE_META[k]?.cls ?? {}, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
635
- { key: "cutter", role: "mover", label: "\uC808\uB2E8 \uC124\uBE44", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
636
- { key: "welder", role: "mover", label: "\uC6A9\uC811 \uB85C\uBD07", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
637
- { key: "painter", role: "mover", label: "\uB3C4\uC7A5 \uB85C\uBD07", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
638
- { key: "assembler", role: "mover", label: "\uC870\uB9BD \uC124\uBE44", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
632
+ ...MES_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: MES_NODE_CLS[k] ?? {}, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
633
+ { key: "cutter", role: "mover", label: "twin.type.cutter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
634
+ { key: "welder", role: "mover", label: "twin.type.welder", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
635
+ { key: "painter", role: "mover", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
636
+ { key: "assembler", role: "mover", label: "twin.type.assembler", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
639
637
  ];
640
638
 
641
639
  // src/domain-catalog.ts
642
640
  var nodeKeys = (types) => types.filter((t) => t.role === "node").map((t) => t.key);
643
641
  var DOMAIN_CATALOG = {
644
- wms: { system: "wms", label: "WMS (\uBB3C\uB958\uCC3D\uACE0)", types: WMS_TYPES, nodeTypes: nodeKeys(WMS_TYPES) },
645
- yms: { system: "yms", label: "YMS (\uC57C\uB4DC)", types: YMS_TYPES, nodeTypes: nodeKeys(YMS_TYPES) },
646
- mes: { system: "mes", label: "MES (\uC81C\uC870)", types: MES_TYPES, nodeTypes: nodeKeys(MES_TYPES) }
642
+ // label 언어 중립 i18n (twin.system.<code>) 사람 언어는 표현계층이 렌더(L2).
643
+ wms: { system: "wms", label: "twin.system.wms", types: WMS_TYPES, nodeTypes: nodeKeys(WMS_TYPES) },
644
+ yms: { system: "yms", label: "twin.system.yms", types: YMS_TYPES, nodeTypes: nodeKeys(YMS_TYPES) },
645
+ mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, nodeTypes: nodeKeys(MES_TYPES) }
647
646
  };
648
647
  var DOMAIN_SYSTEMS = ["wms", "yms", "mes"];
649
648
  function capabilitiesForType(system, typeKey) {
@@ -801,19 +800,17 @@ function deriveAttentions(view, acked) {
801
800
  const out = [];
802
801
  for (const m of view.movers) {
803
802
  if (m.status === "down") {
804
- const where = m.location ?? "\uD574\uB2F9 \uACF5\uC815";
805
803
  out.push({
806
804
  id: `breakdown:${m.id}`,
807
805
  kind: "breakdown",
808
806
  severity: "critical",
809
- title: `\uC124\uBE44 \uACE0\uC7A5 \xB7 ${m.id}`,
810
807
  anchor: { moverId: m.id, nodeId: m.location },
811
- rationale: `\uC124\uBE44 \uC815\uC9C0 \u2014 ${where}\uC758 \uC791\uC5C5\uC774 \uC911\uB2E8\uB418\uC5B4 \uD558\uB958 \uC815\uCCB4\xB7\uCC98\uB9AC\uB7C9 \uAC10\uC18C\uB85C \uC774\uC5B4\uC9D1\uB2C8\uB2E4.`,
808
+ params: { moverId: m.id, ...m.location ? { nodeId: m.location } : {} },
812
809
  recommendedActions: [
813
- { label: "\uC218\uB9AC \uC9C0\uC2DC", command: CMD.resourceRepair, args: { resourceId: m.id }, hint: "\uC124\uBE44\uB97C \uC989\uC2DC \uBCF5\uAD6C\uD574 \uAC00\uB3D9 \uC7AC\uAC1C" },
814
- { label: "\uACC4\uD68D \uC815\uC9C0 \uC720\uC9C0", command: CMD.resourceHold, args: { resourceId: m.id }, hint: "\uC218\uB9AC \uC804\uAE4C\uC9C0 \uBC30\uC815\uC5D0\uC11C \uC81C\uC678" }
810
+ { code: "act.repair", command: CMD.resourceRepair, args: { resourceId: m.id } },
811
+ { code: "act.hold-until-repair", command: CMD.resourceHold, args: { resourceId: m.id } }
815
812
  ],
816
- suggestedAction: { command: CMD.resourceRepair, args: { resourceId: m.id }, label: "\uC218\uB9AC" }
813
+ suggestedAction: { code: "act.repair", command: CMD.resourceRepair, args: { resourceId: m.id } }
817
814
  });
818
815
  }
819
816
  }
@@ -826,13 +823,10 @@ function deriveAttentions(view, acked) {
826
823
  id: `bottleneck:${n.id}`,
827
824
  kind: "bottleneck",
828
825
  severity: saturated ? "high" : "medium",
829
- title: `${saturated ? "\uBCD1\uBAA9" : "\uD63C\uC7A1"} \xB7 ${n.id} \uC810\uC720 ${n.occupancy}/${n.capacity}`,
830
826
  anchor: { nodeId: n.id },
831
- rationale: `${n.id} \uC810\uC720 ${Math.round(r * 100)}% \u2014 \uC0C1\uB958 \uB300\uAE30\uAC00 \uC313\uC5EC \uB9AC\uB4DC\uD0C0\uC784\uC774 \uB298\uACE0 \uCC98\uB9AC\uB7C9\uC774 \uC81C\uD55C\uB429\uB2C8\uB2E4.`,
832
- recommendedActions: [
833
- { label: "\uC790\uC6D0 \uCD94\uAC00 \uAC80\uD1A0", hint: "\uBB34\uBC84\xB7\uCC98\uB9AC \uB2A5\uB825\uC744 \uBCF4\uAC15\uD574 \uBCD1\uBAA9 \uC644\uD654" },
834
- { label: "\uD558\uB958 \uC6B0\uC120 \uCC98\uB9AC", hint: "\uC801\uCCB4 \uD574\uC18C\uB97C \uC704\uD574 \uBC30\uCD9C \uC6B0\uC120\uC21C\uC704 \uC870\uC815" }
835
- ]
827
+ params: { nodeId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
828
+ recommendedActions: [{ code: "advice.add-resource" }, { code: "advice.downstream-priority" }]
829
+ // 권고만(command 없음) 표현계층이 advice.* 코드로 렌더
836
830
  });
837
831
  }
838
832
  }
@@ -845,15 +839,13 @@ function deriveAttentions(view, acked) {
845
839
  id: `scrap:${m.id}`,
846
840
  kind: "scrap-high",
847
841
  severity: rate >= 0.3 ? "high" : "medium",
848
- title: `\uBD88\uB7C9\uB960 ${Math.round(rate * 100)}% \xB7 ${m.id}`,
849
- detail: `\uC591\uD488 ${m.goodCount} / \uBD88\uB7C9 ${m.scrapCount}`,
850
842
  anchor: { moverId: m.id, nodeId: m.location },
851
- rationale: `\uBD88\uB7C9\uB960 ${Math.round(rate * 100)}% \u2014 \uC7AC\uC791\uC5C5\xB7\uC218\uC728 \uC190\uC2E4\uC774 \uB204\uC801\uB429\uB2C8\uB2E4. \uC124\uBE44 \uC0C1\uD0DC\xB7\uC14B\uC5C5 \uD3B8\uCC28\uB97C \uC810\uAC80\uD558\uC138\uC694.`,
843
+ params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
852
844
  recommendedActions: [
853
- { label: "\uC124\uBE44 \uC810\uAC80 \uC815\uC9C0", command: CMD.resourceHold, args: { resourceId: m.id }, hint: "\uC810\uAC80\uC744 \uC704\uD574 \uBC30\uC815\uC5D0\uC11C \uC81C\uC678" },
854
- { label: "\uACC4\uCE21 \uB9AC\uC14B", command: CMD.resourceResetMetrics, args: { resourceId: m.id }, hint: "\uAD50\uC815 \uD6C4 \uC218\uC728 \uC7AC\uCE21\uC815" }
845
+ { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } },
846
+ { code: "act.reset-metrics", command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
855
847
  ],
856
- suggestedAction: { command: CMD.resourceHold, args: { resourceId: m.id }, label: "\uC810\uAC80 \uC815\uC9C0" }
848
+ suggestedAction: { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } }
857
849
  });
858
850
  }
859
851
  }
@@ -862,11 +854,10 @@ function deriveAttentions(view, acked) {
862
854
  id: `hold:${o.id}`,
863
855
  kind: "hold",
864
856
  severity: "medium",
865
- title: `\uC624\uB354 \uBCF4\uB958 \xB7 ${o.id}`,
866
857
  anchor: { orderId: o.id },
867
- rationale: `\uC624\uB354 \uC9C4\uD589\uC774 \uBA48\uCDA4 \u2014 \uB0A9\uAE30 \uC9C0\uC5F0 \uC704\uD5D8. \uBCF4\uB958 \uC0AC\uC720 \uD574\uC18C \uD6C4 \uC7AC\uAC1C\uD558\uC138\uC694.`,
868
- recommendedActions: [{ label: "\uC7AC\uAC1C", command: CMD.orderResume, args: { orderId: o.id }, hint: "\uBCF4\uB958\uB97C \uD480\uACE0 \uD750\uB984 \uC7AC\uAC1C" }],
869
- suggestedAction: { command: CMD.orderResume, args: { orderId: o.id }, label: "\uC7AC\uAC1C" }
858
+ params: { orderId: o.id },
859
+ recommendedActions: [{ code: "act.resume-order", command: CMD.orderResume, args: { orderId: o.id } }],
860
+ suggestedAction: { code: "act.resume-order", command: CMD.orderResume, args: { orderId: o.id } }
870
861
  });
871
862
  }
872
863
  if (acked) {
@@ -997,13 +988,13 @@ var FlowEngine = class {
997
988
  // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
998
989
  dispatch(cmd) {
999
990
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
1000
- const fail = (error) => ({ commandId: cmd.commandId, accepted: false, error });
991
+ const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
1001
992
  switch (cmd.type) {
1002
993
  case CMD.orderHold:
1003
994
  case CMD.orderResume: {
1004
995
  const orderId = cmd.args?.orderId;
1005
996
  const order = orderId ? this.orders.get(orderId) : void 0;
1006
- if (!order) return fail(`order \uC5C6\uC74C: ${orderId}`);
997
+ if (!order) return fail("order-not-found", { orderId: orderId ?? "" });
1007
998
  order.held = cmd.type === CMD.orderHold;
1008
999
  this.emitOrder(order);
1009
1000
  return ok();
@@ -1017,7 +1008,7 @@ var FlowEngine = class {
1017
1008
  case CMD.resourceHold:
1018
1009
  case CMD.resourceResume: {
1019
1010
  const m = this.movers.get(cmd.args?.resourceId ?? "");
1020
- if (!m) return fail("resource \uC5C6\uC74C");
1011
+ if (!m) return fail("resource-not-found");
1021
1012
  m.held = cmd.type === CMD.resourceHold;
1022
1013
  this.emitMover(m);
1023
1014
  return ok();
@@ -1025,7 +1016,7 @@ var FlowEngine = class {
1025
1016
  case CMD.resourceDown: {
1026
1017
  const a = cmd.args;
1027
1018
  const m = this.movers.get(a?.resourceId ?? "");
1028
- if (!m) return fail("resource \uC5C6\uC74C");
1019
+ if (!m) return fail("resource-not-found");
1029
1020
  if (m.status !== "down") {
1030
1021
  m.status = "down";
1031
1022
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 6e4);
@@ -1035,7 +1026,7 @@ var FlowEngine = class {
1035
1026
  }
1036
1027
  case CMD.resourceRepair: {
1037
1028
  const m = this.movers.get(cmd.args?.resourceId ?? "");
1038
- if (!m) return fail("resource \uC5C6\uC74C");
1029
+ if (!m) return fail("resource-not-found");
1039
1030
  if (m.status === "down") {
1040
1031
  m.status = m.taskId ? "busy" : "idle";
1041
1032
  m.repairUntilMs = void 0;
@@ -1046,7 +1037,7 @@ var FlowEngine = class {
1046
1037
  }
1047
1038
  case CMD.resourceResetMetrics: {
1048
1039
  const m = this.movers.get(cmd.args?.resourceId ?? "");
1049
- if (!m) return fail("resource \uC5C6\uC74C");
1040
+ if (!m) return fail("resource-not-found");
1050
1041
  m.runMs = 0;
1051
1042
  m.setupMs = 0;
1052
1043
  m.downMs = 0;
@@ -1059,8 +1050,8 @@ var FlowEngine = class {
1059
1050
  }
1060
1051
  case CMD.resourceAdd: {
1061
1052
  const a = cmd.args;
1062
- if (!a?.kind) return fail("kind \uD544\uC694");
1063
- if (!a?.homeNode || !this.nodes.has(a.homeNode)) return fail(`homeNode \uC5C6\uC74C: ${a?.homeNode}`);
1053
+ if (!a?.kind) return fail("kind-required");
1054
+ if (!a?.homeNode || !this.nodes.has(a.homeNode)) return fail("home-node-not-found", { homeNode: a?.homeNode ?? "" });
1064
1055
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
1065
1056
  let seq = this.movers.size;
1066
1057
  for (let i = 0; i < count; i++) {
@@ -1078,7 +1069,7 @@ var FlowEngine = class {
1078
1069
  }
1079
1070
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
1080
1071
  handleCommand(cmd) {
1081
- return { commandId: cmd.commandId, accepted: false, error: `\uC54C \uC218 \uC5C6\uB294 \uCEE4\uB9E8\uB4DC: ${cmd.type}` };
1072
+ return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `unknown-command: ${cmd.type}` };
1082
1073
  }
1083
1074
  scenario = {
1084
1075
  load: (def) => {
@@ -1455,7 +1446,7 @@ var WmsKernel = class extends FlowEngine {
1455
1446
  if (cmd.type === CMD.orderRelease) {
1456
1447
  const a = cmd.args;
1457
1448
  const lines = a?.lines ?? (a?.gtin ? [{ gtin: a.gtin, qty: a.qty ?? 1 }] : []);
1458
- if (lines.length === 0) return { commandId: cmd.commandId, accepted: false, error: "order.release: gtin \uB610\uB294 lines \uD544\uC694" };
1449
+ if (lines.length === 0) return { commandId: cmd.commandId, accepted: false, errorCode: "order-release-needs-lines", error: "order.release: gtin or lines required" };
1459
1450
  this.createSalesOrder(lines);
1460
1451
  return { commandId: cmd.commandId, accepted: true };
1461
1452
  }
@@ -1755,9 +1746,9 @@ var MesKernel = class extends FlowEngine {
1755
1746
  handleCommand(cmd) {
1756
1747
  if (cmd.type === MES_CMD.changeover) {
1757
1748
  const a = cmd.args;
1758
- if (!a?.resourceId || !a?.gtin) return { commandId: cmd.commandId, accepted: false, error: "mes.changeover: resourceId\xB7gtin \uD544\uC694" };
1749
+ if (!a?.resourceId || !a?.gtin) return { commandId: cmd.commandId, accepted: false, errorCode: "changeover-needs-args", error: "mes.changeover: resourceId and gtin required" };
1759
1750
  const m = this.movers.get(a.resourceId);
1760
- if (!m) return { commandId: cmd.commandId, accepted: false, error: `resource \uC5C6\uC74C: ${a.resourceId}` };
1751
+ if (!m) return { commandId: cmd.commandId, accepted: false, errorCode: "resource-not-found", errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
1761
1752
  if (m.lastChangeoverKey !== a.gtin) {
1762
1753
  m.setupMs += SETUP_MS;
1763
1754
  m.lastChangeoverKey = a.gtin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {