@operato/twin-kernel 0.0.5 → 0.1.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.
@@ -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/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './domain-catalog.ts';
12
12
  export * from './allocation-policy.ts';
13
13
  export * from './duration-estimator.ts';
14
14
  export * from './state-projector.ts';
15
+ export * from './task-fold.ts';
15
16
  export * from './face2-adapter.ts';
16
17
  export * from './runtime.ts';
17
18
  export * from './yms-profile.ts';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export * from "./domain-catalog.js";
12
12
  export * from "./allocation-policy.js";
13
13
  export * from "./duration-estimator.js";
14
14
  export * from "./state-projector.js";
15
+ export * from "./task-fold.js";
15
16
  export * from "./face2-adapter.js";
16
17
  export * from "./runtime.js";
17
18
  export * from "./yms-profile.js";
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
  ];
@@ -0,0 +1,56 @@
1
+ import type { TaskStatusDelta } from './contract.ts';
2
+ /**
3
+ * 되읽을 저널 한 줄 — 필요한 것만.
4
+ *
5
+ * `data`(정본 봉투) 와 `payload`(호스트가 감싸 저장한 형태) 를 모두 받는다. 저장 계층마다 감싸는 방식이
6
+ * 달라 소비처가 각자 벗기고 있었고, 그 벗기는 규칙이 또 갈라졌다.
7
+ */
8
+ export interface TaskDeltaRow {
9
+ /** ISO 시각. 파싱 불가하면 그 줄은 버린다(조용히 0 으로 세지 않는다). */
10
+ eventTime?: string;
11
+ data?: Partial<TaskStatusDelta>;
12
+ payload?: {
13
+ data?: Partial<TaskStatusDelta>;
14
+ } | Partial<TaskStatusDelta>;
15
+ }
16
+ /** 작업에 붙어 있는 축의 값 — 어느 전이에서 왔든 모은다. */
17
+ export interface TaskFacets {
18
+ /** 수행 자원(무버). 설계상 자원을 쓰지 않는 공정(체류)에서는 없는 것이 정상이다. */
19
+ resource?: string;
20
+ /** 작업 종류(커널 어휘 — 사람이 읽는 라벨은 표현 계층의 몫). */
21
+ kind?: string;
22
+ /** 소속 오더. **없으면 없는 것이다** — 다른 식별자로 대체하지 않는다. */
23
+ order?: string;
24
+ /** 가장 최근 도착 지점. 진행에 따라 바뀌므로 마지막 것이 사실이다. */
25
+ node?: string;
26
+ }
27
+ /** 작업 하나의 이정표 — 소비처가 이것으로 구간·지표를 만든다. */
28
+ export interface TaskRecord {
29
+ taskId: string;
30
+ /** 생성 시각. 같은 전이가 여러 번 오면 **마지막** 것(등록 정보 정정이 뒤에 온다). */
31
+ createdMs?: number;
32
+ /** 착수 시각. **처음** 것 — 재시도로 다시 착수 전이가 와도 실제 시작은 처음이다. */
33
+ startedMs?: number;
34
+ /** 완료 시각. **나중** 것 — 완료는 종결 상태라 작업당 하나이고 재전송이면 나중 것이 사실이다. */
35
+ completedMs?: number;
36
+ facets: TaskFacets;
37
+ }
38
+ export interface TaskFoldResult {
39
+ /** 작업별 기록(입력에서 처음 등장한 순서). */
40
+ records: TaskRecord[];
41
+ /** 읽어들인 줄 수(시각을 파싱한 것만). 저널이 비었는지 구별하는 값. */
42
+ rowsRead: number;
43
+ /** 본 시각 중 가장 늦은 것(ms). 없으면 0 — 라이브 엣지·창 기준의 재료. */
44
+ latestMs: number;
45
+ }
46
+ /**
47
+ * `task.status` 델타를 작업 단위 기록으로 접는다.
48
+ *
49
+ * 입력은 **시간순일 필요가 없다**(revision 순·역순 모두 허용) — 이정표는 min/max 로 고르고 축의 값은
50
+ * 처음 본 것을 남기므로 순서에 의존하지 않는다. 단 `createdMs` 는 "마지막 생성 전이" 이므로 같은 작업의
51
+ * 생성이 두 번 오면 **입력 순서상 나중** 것이 남는다.
52
+ *
53
+ * 자원이 실리지 않은 전이도 읽는다 — 생성 전이에만 종류·오더가 실리는 구현이 있어, 그 줄을 건너뛰면
54
+ * 축이 통째로 빈다. "자원이 없어 그릴 수 없다" 는 판단은 소비처가 기록을 보고 한다.
55
+ */
56
+ export declare function foldTaskRecords(rows: TaskDeltaRow[]): TaskFoldResult;
@@ -0,0 +1,60 @@
1
+ function ms(value) {
2
+ if (!value)
3
+ return null;
4
+ const t = Date.parse(value);
5
+ return Number.isFinite(t) ? t : null;
6
+ }
7
+ /** 저장 형태 차이를 여기서 한 번만 벗긴다(소비처가 각자 벗기면 그 규칙이 갈라진다). */
8
+ function deltaOf(row) {
9
+ const p = row.payload;
10
+ return (row.data ?? p?.data ?? p ?? {});
11
+ }
12
+ /**
13
+ * `task.status` 델타를 작업 단위 기록으로 접는다.
14
+ *
15
+ * 입력은 **시간순일 필요가 없다**(revision 순·역순 모두 허용) — 이정표는 min/max 로 고르고 축의 값은
16
+ * 처음 본 것을 남기므로 순서에 의존하지 않는다. 단 `createdMs` 는 "마지막 생성 전이" 이므로 같은 작업의
17
+ * 생성이 두 번 오면 **입력 순서상 나중** 것이 남는다.
18
+ *
19
+ * 자원이 실리지 않은 전이도 읽는다 — 생성 전이에만 종류·오더가 실리는 구현이 있어, 그 줄을 건너뛰면
20
+ * 축이 통째로 빈다. "자원이 없어 그릴 수 없다" 는 판단은 소비처가 기록을 보고 한다.
21
+ */
22
+ export function foldTaskRecords(rows) {
23
+ const byTask = new Map();
24
+ let rowsRead = 0;
25
+ let latestMs = 0;
26
+ for (const row of rows) {
27
+ const at = ms(row.eventTime);
28
+ if (at === null)
29
+ continue;
30
+ const d = deltaOf(row);
31
+ const id = d.taskId;
32
+ if (!id)
33
+ continue;
34
+ rowsRead++;
35
+ latestMs = Math.max(latestMs, at);
36
+ let rec = byTask.get(id);
37
+ if (!rec) {
38
+ rec = { taskId: id, facets: {} };
39
+ byTask.set(id, rec);
40
+ }
41
+ /* 축의 값 — 처음 본 것을 남긴다(작업의 종류·오더·자원은 바뀌지 않는다). 노드는 예외로 마지막 것. */
42
+ const f = rec.facets;
43
+ if (f.resource === undefined && d.resourceRef)
44
+ f.resource = d.resourceRef;
45
+ if (f.kind === undefined && d.kind)
46
+ f.kind = d.kind;
47
+ if (f.order === undefined && d.orderId)
48
+ f.order = d.orderId;
49
+ const node = d.toNode ?? d.fromNode;
50
+ if (node)
51
+ f.node = node;
52
+ if (d.status === 'created')
53
+ rec.createdMs = at;
54
+ else if (d.status === 'in-progress')
55
+ rec.startedMs = rec.startedMs != null ? Math.min(rec.startedMs, at) : at;
56
+ else if (d.status === 'completed')
57
+ rec.completedMs = rec.completedMs != null ? Math.max(rec.completedMs, at) : at;
58
+ }
59
+ return { records: [...byTask.values()], rowsRead, latestMs };
60
+ }
@@ -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
  ];
@@ -61,6 +61,7 @@ __export(index_exports, {
61
61
  deriveAttentions: () => deriveAttentions,
62
62
  fefoPolicy: () => fefoPolicy,
63
63
  firstFitPolicy: () => firstFitPolicy,
64
+ foldTaskRecords: () => foldTaskRecords,
64
65
  gdtiUri: () => gdtiUri,
65
66
  graiUri: () => graiUri,
66
67
  ingest: () => ingest,
@@ -531,10 +532,9 @@ var BTT = {
531
532
  so: "urn:epcglobal:cbv:btt:so"
532
533
  };
533
534
  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
535
  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"] }
536
+ ...WMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
537
+ { key: "forklift", role: "mover", label: "twin.type.forklift", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
538
538
  ];
539
539
 
540
540
  // src/capability.ts
@@ -602,10 +602,9 @@ function graiUri(companyPrefix, assetType, serial) {
602
602
  return `urn:epc:id:grai:${companyPrefix}.${assetType}.${String(serial).padStart(6, "0")}`;
603
603
  }
604
604
  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
605
  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"] }
606
+ ...YMS_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: { epcis: "bizLocation" }, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
607
+ { key: "hostler", role: "mover", label: "twin.type.hostler", standardClass: { epcis: "object", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["mobile", "operable"] }
609
608
  ];
610
609
 
611
610
  // src/mes-profile.ts
@@ -622,28 +621,29 @@ function sgtinUri(companyPrefix, itemRef, serial) {
622
621
  return `urn:epc:id:sgtin:${companyPrefix}.${itemRef}.${serial}`;
623
622
  }
624
623
  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" } }
624
+ var MES_NODE_CLS = {
625
+ "raw-store": { epcis: "bizLocation" },
626
+ "cut-station": { isa95: "WorkCenter", epcis: "bizLocation" },
627
+ "weld-station": { isa95: "WorkCenter", epcis: "bizLocation" },
628
+ "paint-booth": { isa95: "WorkCenter", epcis: "bizLocation" },
629
+ "assembly-line": { isa95: "WorkCenter", epcis: "bizLocation" },
630
+ "fg-store": { epcis: "bizLocation" }
632
631
  };
633
632
  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"] }
633
+ ...MES_NODE_TYPES.map((k) => ({ key: k, role: "node", label: `twin.type.${k}`, standardClass: MES_NODE_CLS[k] ?? {}, identity: { scheme: "gs1:SGLN" }, capabilities: ["storable"] })),
634
+ { key: "cutter", role: "mover", label: "twin.type.cutter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
635
+ { key: "welder", role: "mover", label: "twin.type.welder", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
636
+ { key: "painter", role: "mover", label: "twin.type.painter", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] },
637
+ { key: "assembler", role: "mover", label: "twin.type.assembler", standardClass: { isa95: "Equipment", iso55000: "Asset" }, identity: { scheme: "gs1:GIAI" }, capabilities: ["processable", "operable"] }
639
638
  ];
640
639
 
641
640
  // src/domain-catalog.ts
642
641
  var nodeKeys = (types) => types.filter((t) => t.role === "node").map((t) => t.key);
643
642
  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) }
643
+ // label 언어 중립 i18n (twin.system.<code>) 사람 언어는 표현계층이 렌더(L2).
644
+ wms: { system: "wms", label: "twin.system.wms", types: WMS_TYPES, nodeTypes: nodeKeys(WMS_TYPES) },
645
+ yms: { system: "yms", label: "twin.system.yms", types: YMS_TYPES, nodeTypes: nodeKeys(YMS_TYPES) },
646
+ mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, nodeTypes: nodeKeys(MES_TYPES) }
647
647
  };
648
648
  var DOMAIN_SYSTEMS = ["wms", "yms", "mes"];
649
649
  function capabilitiesForType(system, typeKey) {
@@ -680,7 +680,47 @@ var fefoPolicy = {
680
680
  };
681
681
 
682
682
  // src/duration-estimator.ts
683
- var constantDuration = (ms) => ({ estimate: () => ms });
683
+ var constantDuration = (ms2) => ({ estimate: () => ms2 });
684
+
685
+ // src/task-fold.ts
686
+ function ms(value) {
687
+ if (!value) return null;
688
+ const t = Date.parse(value);
689
+ return Number.isFinite(t) ? t : null;
690
+ }
691
+ function deltaOf(row) {
692
+ const p = row.payload;
693
+ return row.data ?? p?.data ?? p ?? {};
694
+ }
695
+ function foldTaskRecords(rows) {
696
+ const byTask = /* @__PURE__ */ new Map();
697
+ let rowsRead = 0;
698
+ let latestMs = 0;
699
+ for (const row of rows) {
700
+ const at = ms(row.eventTime);
701
+ if (at === null) continue;
702
+ const d = deltaOf(row);
703
+ const id = d.taskId;
704
+ if (!id) continue;
705
+ rowsRead++;
706
+ latestMs = Math.max(latestMs, at);
707
+ let rec = byTask.get(id);
708
+ if (!rec) {
709
+ rec = { taskId: id, facets: {} };
710
+ byTask.set(id, rec);
711
+ }
712
+ const f = rec.facets;
713
+ if (f.resource === void 0 && d.resourceRef) f.resource = d.resourceRef;
714
+ if (f.kind === void 0 && d.kind) f.kind = d.kind;
715
+ if (f.order === void 0 && d.orderId) f.order = d.orderId;
716
+ const node = d.toNode ?? d.fromNode;
717
+ if (node) f.node = node;
718
+ if (d.status === "created") rec.createdMs = at;
719
+ else if (d.status === "in-progress") rec.startedMs = rec.startedMs != null ? Math.min(rec.startedMs, at) : at;
720
+ else if (d.status === "completed") rec.completedMs = rec.completedMs != null ? Math.max(rec.completedMs, at) : at;
721
+ }
722
+ return { records: [...byTask.values()], rowsRead, latestMs };
723
+ }
684
724
 
685
725
  // src/face2-adapter.ts
686
726
  function get(obj, path) {
@@ -801,19 +841,17 @@ function deriveAttentions(view, acked) {
801
841
  const out = [];
802
842
  for (const m of view.movers) {
803
843
  if (m.status === "down") {
804
- const where = m.location ?? "\uD574\uB2F9 \uACF5\uC815";
805
844
  out.push({
806
845
  id: `breakdown:${m.id}`,
807
846
  kind: "breakdown",
808
847
  severity: "critical",
809
- title: `\uC124\uBE44 \uACE0\uC7A5 \xB7 ${m.id}`,
810
848
  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.`,
849
+ params: { moverId: m.id, ...m.location ? { nodeId: m.location } : {} },
812
850
  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" }
851
+ { code: "act.repair", command: CMD.resourceRepair, args: { resourceId: m.id } },
852
+ { code: "act.hold-until-repair", command: CMD.resourceHold, args: { resourceId: m.id } }
815
853
  ],
816
- suggestedAction: { command: CMD.resourceRepair, args: { resourceId: m.id }, label: "\uC218\uB9AC" }
854
+ suggestedAction: { code: "act.repair", command: CMD.resourceRepair, args: { resourceId: m.id } }
817
855
  });
818
856
  }
819
857
  }
@@ -826,13 +864,10 @@ function deriveAttentions(view, acked) {
826
864
  id: `bottleneck:${n.id}`,
827
865
  kind: "bottleneck",
828
866
  severity: saturated ? "high" : "medium",
829
- title: `${saturated ? "\uBCD1\uBAA9" : "\uD63C\uC7A1"} \xB7 ${n.id} \uC810\uC720 ${n.occupancy}/${n.capacity}`,
830
867
  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
- ]
868
+ params: { nodeId: n.id, occupancy: n.occupancy ?? 0, capacity: n.capacity ?? 0, ratioPct: Math.round(r * 100), saturated: saturated ? 1 : 0 },
869
+ recommendedActions: [{ code: "advice.add-resource" }, { code: "advice.downstream-priority" }]
870
+ // 권고만(command 없음) 표현계층이 advice.* 코드로 렌더
836
871
  });
837
872
  }
838
873
  }
@@ -845,15 +880,13 @@ function deriveAttentions(view, acked) {
845
880
  id: `scrap:${m.id}`,
846
881
  kind: "scrap-high",
847
882
  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
883
  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.`,
884
+ params: { moverId: m.id, goodCount: m.goodCount ?? 0, scrapCount: m.scrapCount ?? 0, ratePct: Math.round(rate * 100) },
852
885
  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" }
886
+ { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } },
887
+ { code: "act.reset-metrics", command: CMD.resourceResetMetrics, args: { resourceId: m.id } }
855
888
  ],
856
- suggestedAction: { command: CMD.resourceHold, args: { resourceId: m.id }, label: "\uC810\uAC80 \uC815\uC9C0" }
889
+ suggestedAction: { code: "act.hold-for-inspection", command: CMD.resourceHold, args: { resourceId: m.id } }
857
890
  });
858
891
  }
859
892
  }
@@ -862,11 +895,10 @@ function deriveAttentions(view, acked) {
862
895
  id: `hold:${o.id}`,
863
896
  kind: "hold",
864
897
  severity: "medium",
865
- title: `\uC624\uB354 \uBCF4\uB958 \xB7 ${o.id}`,
866
898
  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" }
899
+ params: { orderId: o.id },
900
+ recommendedActions: [{ code: "act.resume-order", command: CMD.orderResume, args: { orderId: o.id } }],
901
+ suggestedAction: { code: "act.resume-order", command: CMD.orderResume, args: { orderId: o.id } }
870
902
  });
871
903
  }
872
904
  if (acked) {
@@ -997,13 +1029,13 @@ var FlowEngine = class {
997
1029
  // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
998
1030
  dispatch(cmd) {
999
1031
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
1000
- const fail = (error) => ({ commandId: cmd.commandId, accepted: false, error });
1032
+ const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
1001
1033
  switch (cmd.type) {
1002
1034
  case CMD.orderHold:
1003
1035
  case CMD.orderResume: {
1004
1036
  const orderId = cmd.args?.orderId;
1005
1037
  const order = orderId ? this.orders.get(orderId) : void 0;
1006
- if (!order) return fail(`order \uC5C6\uC74C: ${orderId}`);
1038
+ if (!order) return fail("order-not-found", { orderId: orderId ?? "" });
1007
1039
  order.held = cmd.type === CMD.orderHold;
1008
1040
  this.emitOrder(order);
1009
1041
  return ok();
@@ -1017,7 +1049,7 @@ var FlowEngine = class {
1017
1049
  case CMD.resourceHold:
1018
1050
  case CMD.resourceResume: {
1019
1051
  const m = this.movers.get(cmd.args?.resourceId ?? "");
1020
- if (!m) return fail("resource \uC5C6\uC74C");
1052
+ if (!m) return fail("resource-not-found");
1021
1053
  m.held = cmd.type === CMD.resourceHold;
1022
1054
  this.emitMover(m);
1023
1055
  return ok();
@@ -1025,7 +1057,7 @@ var FlowEngine = class {
1025
1057
  case CMD.resourceDown: {
1026
1058
  const a = cmd.args;
1027
1059
  const m = this.movers.get(a?.resourceId ?? "");
1028
- if (!m) return fail("resource \uC5C6\uC74C");
1060
+ if (!m) return fail("resource-not-found");
1029
1061
  if (m.status !== "down") {
1030
1062
  m.status = "down";
1031
1063
  m.repairUntilMs = this.clockMs + (Number(a?.durationMs) || m.mttrMs || 6e4);
@@ -1035,7 +1067,7 @@ var FlowEngine = class {
1035
1067
  }
1036
1068
  case CMD.resourceRepair: {
1037
1069
  const m = this.movers.get(cmd.args?.resourceId ?? "");
1038
- if (!m) return fail("resource \uC5C6\uC74C");
1070
+ if (!m) return fail("resource-not-found");
1039
1071
  if (m.status === "down") {
1040
1072
  m.status = m.taskId ? "busy" : "idle";
1041
1073
  m.repairUntilMs = void 0;
@@ -1046,7 +1078,7 @@ var FlowEngine = class {
1046
1078
  }
1047
1079
  case CMD.resourceResetMetrics: {
1048
1080
  const m = this.movers.get(cmd.args?.resourceId ?? "");
1049
- if (!m) return fail("resource \uC5C6\uC74C");
1081
+ if (!m) return fail("resource-not-found");
1050
1082
  m.runMs = 0;
1051
1083
  m.setupMs = 0;
1052
1084
  m.downMs = 0;
@@ -1059,8 +1091,8 @@ var FlowEngine = class {
1059
1091
  }
1060
1092
  case CMD.resourceAdd: {
1061
1093
  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}`);
1094
+ if (!a?.kind) return fail("kind-required");
1095
+ if (!a?.homeNode || !this.nodes.has(a.homeNode)) return fail("home-node-not-found", { homeNode: a?.homeNode ?? "" });
1064
1096
  const count = Math.max(1, Math.min(50, Number(a.count) || 1));
1065
1097
  let seq = this.movers.size;
1066
1098
  for (let i = 0; i < count; i++) {
@@ -1078,7 +1110,7 @@ var FlowEngine = class {
1078
1110
  }
1079
1111
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
1080
1112
  handleCommand(cmd) {
1081
- return { commandId: cmd.commandId, accepted: false, error: `\uC54C \uC218 \uC5C6\uB294 \uCEE4\uB9E8\uB4DC: ${cmd.type}` };
1113
+ return { commandId: cmd.commandId, accepted: false, errorCode: "unknown-command", errorParams: { type: cmd.type }, error: `unknown-command: ${cmd.type}` };
1082
1114
  }
1083
1115
  scenario = {
1084
1116
  load: (def) => {
@@ -1455,7 +1487,7 @@ var WmsKernel = class extends FlowEngine {
1455
1487
  if (cmd.type === CMD.orderRelease) {
1456
1488
  const a = cmd.args;
1457
1489
  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" };
1490
+ if (lines.length === 0) return { commandId: cmd.commandId, accepted: false, errorCode: "order-release-needs-lines", error: "order.release: gtin or lines required" };
1459
1491
  this.createSalesOrder(lines);
1460
1492
  return { commandId: cmd.commandId, accepted: true };
1461
1493
  }
@@ -1755,9 +1787,9 @@ var MesKernel = class extends FlowEngine {
1755
1787
  handleCommand(cmd) {
1756
1788
  if (cmd.type === MES_CMD.changeover) {
1757
1789
  const a = cmd.args;
1758
- if (!a?.resourceId || !a?.gtin) return { commandId: cmd.commandId, accepted: false, error: "mes.changeover: resourceId\xB7gtin \uD544\uC694" };
1790
+ if (!a?.resourceId || !a?.gtin) return { commandId: cmd.commandId, accepted: false, errorCode: "changeover-needs-args", error: "mes.changeover: resourceId and gtin required" };
1759
1791
  const m = this.movers.get(a.resourceId);
1760
- if (!m) return { commandId: cmd.commandId, accepted: false, error: `resource \uC5C6\uC74C: ${a.resourceId}` };
1792
+ if (!m) return { commandId: cmd.commandId, accepted: false, errorCode: "resource-not-found", errorParams: { resourceId: a.resourceId }, error: `resource-not-found: ${a.resourceId}` };
1761
1793
  if (m.lastChangeoverKey !== a.gtin) {
1762
1794
  m.setupMs += SETUP_MS;
1763
1795
  m.lastChangeoverKey = a.gtin;
@@ -1999,6 +2031,7 @@ var MesKernel = class extends FlowEngine {
1999
2031
  deriveAttentions,
2000
2032
  fefoPolicy,
2001
2033
  firstFitPolicy,
2034
+ foldTaskRecords,
2002
2035
  gdtiUri,
2003
2036
  graiUri,
2004
2037
  ingest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.0.5",
3
+ "version": "0.1.0",
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": {