@operato/twin-kernel 0.4.2 → 0.5.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.
@@ -1391,6 +1391,20 @@ export interface BoardDef {
1391
1391
  * 유통가공(VAS)은 비직렬 클래스+수량이 맞는 모양이므로 코어의 일반 경로를 쓴다.
1392
1392
  */
1393
1393
  productionSpec?: ProductionSpec;
1394
+ /**
1395
+ * 공정 명세 — **ISA-95 `OperationsSegment`.** 이 현장에서 각 작업이 어디서·무엇으로·얼마나 걸리나.
1396
+ *
1397
+ * ── 왜 이제야 선언하나 (2026-08-08) ──────────────────────────────────────────
1398
+ * 이 자리는 **이미 쓰이고 있었다.** 마스터 인제스트가 `board.operations` 로 통과시키고 호스트가
1399
+ * `applyOperations` 로 커널에 싣는다. 그런데 `BoardDef` 는 그것을 말한 적이 없다.
1400
+ *
1401
+ * 바로 위 `productionSpec` 이 겪은 것과 **같은 일**이다 — "계약에 없으니 아무도 이 자리를 발견할
1402
+ * 수 없었다. 타입이 말해 주지 않는 필드는 없는 필드다." 그때 배운 것을 여기 적용한다.
1403
+ *
1404
+ * 소비 경로가 둘인 것은 그대로 둔다(여기와 `productionSpec.definition.operations`) — 후자는
1405
+ * 라우트의 단계를 해소할 때 읽힌다. 집을 하나로 모으는 것은 별건이고, 그 전에 **선언부터** 한다.
1406
+ */
1407
+ operations?: import('./domain-definition.ts').OperationDef[];
1394
1408
  }
1395
1409
  /**
1396
1410
  * 생산 선언 — 도메인 정의(ISA-95 operations + BOM)와, 그 추상 자재 키를 실제 GS1 식별자로 잇는 바인딩.
@@ -28,6 +28,130 @@ export interface DomainProfileInfo {
28
28
  locationTypes: readonly string[];
29
29
  }
30
30
  export declare const DOMAIN_CATALOG: Record<DomainSystem, DomainProfileInfo>;
31
+ export interface TwinAxisInfo {
32
+ /**
33
+ * 축의 이름 — 화면·관계 선언이 쓰는 키. 최상위 축은 `BoardDef` 의 키 **그대로**다
34
+ * (저장·계약의 이름과 갈라지면 그 순간 방언이 생긴다).
35
+ */
36
+ axis: string;
37
+ /**
38
+ * board 안에서의 자리. 최상위면 `axis` 와 같고, 중첩이면 경로다
39
+ * (`productionSpec.definition.recipes`).
40
+ *
41
+ * **레시피·라우트는 저장상 `productionSpec` 안에 있지만 개념으로는 1급**이다 — 사람은
42
+ * "레시피" 를 찾지 "생산 정의 안의 레시피" 를 찾지 않는다. 저장 위치가 개념을 가두면
43
+ * 화면이 저장 구조를 흉내 내게 되고, 그건 `ADR-0032`(board 는 캐시) 뒤에 무의미해진다.
44
+ */
45
+ path: string;
46
+ /** i18n 키 — 사람 언어는 표현 계층이 렌더한다(L2). */
47
+ label: string;
48
+ /** 표준 온톨로지 투영. 표준에 자리가 없으면 **빈 객체**(숨기지 않는다). */
49
+ standardClass: {
50
+ epcis?: string;
51
+ isa95?: string;
52
+ iso55000?: string;
53
+ };
54
+ /**
55
+ * 표준이 **정의(class)와 개체(instance)를 가른다.** 같은 자재라도 `MaterialDefinition` 과
56
+ * `MaterialLot` 은 다른 것이다. 화면이 둘을 같은 칸에 담지 않도록 선언에 싣는다.
57
+ * `spec` 은 자원이 아니라 **명세**(공정·생산 정의).
58
+ */
59
+ kind: 'instance' | 'class' | 'spec';
60
+ /** 이 축의 항목이 카탈로그 타입을 갖는가 — 있으면 `types` 와 이어진다(자리·설비만). */
61
+ typeRole?: 'location' | 'equipment';
62
+ }
63
+ /** board 축 전체 — 인스펙션의 **개념 목록**이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
64
+ export declare const TWIN_AXES: TwinAxisInfo[];
65
+ export type TwinRelationTarget = {
66
+ kind: 'axis';
67
+ axis: string;
68
+ } | {
69
+ kind: 'type';
70
+ role: 'location' | 'equipment';
71
+ }
72
+ /**
73
+ * **커널 밖**을 가리키는 참조. 커널은 대상의 이름만 말하고 해소는 호스트가 한다.
74
+ *
75
+ * 실물: `locations[].parentId` 는 자리가 아니라 **공간의 구역**을 가리킨다. 마스터에서는
76
+ * 구역도 자리와 같은 목록에 있다가(`role: 'area'`) 인제스트가 갈라 `TwinArea` 로 보내기 때문에,
77
+ * board 안에는 **대상이 없는 참조**가 남는다. 이것을 `axis: 'locations'` 로 적으면 선언이
78
+ * 거짓말을 하고 지도에 없는 선이 생긴다 — 실제로 그렇게 적었다가 실 데이터로 걸어 보고 잡았다.
79
+ *
80
+ * 커널이 `TwinArea` 를 알 수는 없다(공간은 호스트 개념). 그래서 **모른다는 사실을 선언한다.**
81
+ */
82
+ | {
83
+ kind: 'external';
84
+ entity: string;
85
+ };
86
+ export interface TwinRelationInfo {
87
+ /** 출발 축. */
88
+ from: string;
89
+ /**
90
+ * 그 축의 항목에서 대상 키를 꺼내는 경로. `[]` 는 **배열 펼침**이다.
91
+ * 예: `route` · `steps[]` · `inputs[].material`.
92
+ */
93
+ field: string;
94
+ target: TwinRelationTarget;
95
+ /** 관계의 이름(i18n 키) — 화면의 선과 이웃 묶음에 붙는다. */
96
+ via: string;
97
+ /** 없을 수 있는 참조인가. `false` 인데 비면 **끊어진 참조**로 보고한다. */
98
+ optional?: boolean;
99
+ }
100
+ /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
101
+ export declare const TWIN_RELATIONS: TwinRelationInfo[];
102
+ export type TwinObservationKind =
103
+ /** 여러 번 일어난 일의 **퍼짐**. 중앙값 + p10·p90 + 표본 수. 예: 공정 소요, 체류시간. */
104
+ 'distribution'
105
+ /** 어느 시점의 **차 있음**. 현재 · 최대 · 평균. 예: 자리 점유, 재고. */
106
+ | 'level'
107
+ /** 창(window)당 **비(比)**. 값 + 추세. 예: 가동률, 처리량. */
108
+ | 'rate'
109
+ /** 시간축 위의 **연속 계측**. 스파크라인 + 구간 통계. 예: 부하곡선, 온도. */
110
+ | 'series'
111
+ /** 벌어진 **횟수**. 누계 + 기간. 예: 고장 횟수, 완료 건수. */
112
+ | 'count';
113
+ /**
114
+ * 한 축의 항목이 갖는 **볼 수 있는 속성** 하나.
115
+ *
116
+ * 선언값(원본이 말한 것)과 관측값(트윈이 잰 것)은 **둘 다 없을 수 있다.**
117
+ * · 선언만 있음 — 원본이 말했는데 아직 못 쟀다(표본 부족)
118
+ * · 관측만 있음 — 원본이 침묵한 자리를 트윈이 채우고 있다. **트윈을 가진 이유가 여기 있다**
119
+ * · 둘 다 있고 어긋남 — 원본의 표준값이 이 현장과 다르다는 **발견**
120
+ * 그래서 어느 쪽도 없다고 숨기지 않는다. 없으면 `—` 로 낸다.
121
+ */
122
+ export interface TwinPropertyInfo {
123
+ /** 어느 축의 속성인가. */
124
+ axis: string;
125
+ /** 속성 키 — 화면·질의가 쓰는 이름. */
126
+ key: string;
127
+ label: string;
128
+ kind: TwinObservationKind;
129
+ /**
130
+ * 선언값이 항목의 어느 필드에 있나. **없으면 원본이 말하지 않는 속성**이다
131
+ * (그래도 관측은 있을 수 있다 — 위 두 번째 경우).
132
+ */
133
+ declaredField?: string;
134
+ /**
135
+ * 이 관측을 **누가 내나.** 커널·호스트의 실제 산출 이름을 적는다. 지어낸 관측을 선언하지
136
+ * 않기 위한 표식이다 — 없으면 아직 아무도 안 낸다는 뜻이고, 화면은 `—` 를 낸다.
137
+ */
138
+ observedBy?: string;
139
+ /** 단위(UN/CEFACT 공통코드 또는 `ms`·`ratio`). **단위 없는 물리량은 쓰지 않는다**(계약의 규율). */
140
+ uom?: string;
141
+ }
142
+ /**
143
+ * 오늘 **실제로 짝이 서는 것**만 선언한다. 그럴듯한 속성을 미리 적어 두면 화면이 빈칸을 줄줄이
144
+ * 내고, 그건 "아직 안 쟀다" 가 아니라 "이 트윈은 부실하다" 로 읽힌다.
145
+ * `series` 는 아직 **아무도 내지 않는다** — EMS 가 붙을 때 생산자와 함께 선언한다.
146
+ */
147
+ export declare const TWIN_PROPERTIES: TwinPropertyInfo[];
148
+ /** 이 축이 보여 줄 속성들 — 없으면 빈 배열(속성 없는 축이 정상이다). */
149
+ export declare const propertiesOf: (axis: string) => TwinPropertyInfo[];
150
+ /** 이 축에서 나가는 관계 · 이 축으로 들어오는 관계 — 항목 패널의 "이어진 것" 양방향. */
151
+ export declare const relationsFrom: (axis: string) => TwinRelationInfo[];
152
+ export declare const relationsTo: (axis: string) => TwinRelationInfo[];
153
+ /** 축 이름 → 서술. 모르는 축이면 `undefined` — 화면은 그것을 "미선언" 으로 낸다(숨기지 않는다). */
154
+ export declare function axisInfo(axis: string): TwinAxisInfo | undefined;
31
155
  export declare const DOMAIN_SYSTEMS: DomainSystem[];
32
156
  /** 타입 키 → 능력 프로파일(커널 SSOT). 호스트가 라이브 페이로드에 투영, 컴포넌트가 능력을 렌더. */
33
157
  export declare function capabilitiesForType(system: DomainSystem, typeKey: string): CapabilityKey[];
@@ -8,6 +8,94 @@ export const DOMAIN_CATALOG = {
8
8
  yms: { system: 'yms', label: 'twin.system.yms', types: YMS_TYPES, locationTypes: locationKeys(YMS_TYPES) },
9
9
  mes: { system: 'mes', label: 'twin.system.mes', types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) }
10
10
  };
11
+ /** board 축 전체 — 인스펙션의 **개념 목록**이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
12
+ export const TWIN_AXES = [
13
+ { axis: 'locations', path: 'locations', label: 'twin.axis.locations', kind: 'instance', typeRole: 'location',
14
+ standardClass: { epcis: 'SGLN' } },
15
+ { axis: 'equipment', path: 'equipment', label: 'twin.axis.equipment', kind: 'instance', typeRole: 'equipment',
16
+ standardClass: { isa95: 'Equipment', iso55000: 'Asset', epcis: 'GIAI' } },
17
+ { axis: 'persons', path: 'persons', label: 'twin.axis.persons', kind: 'instance',
18
+ standardClass: { isa95: 'Person' } },
19
+ { axis: 'assets', path: 'assets', label: 'twin.axis.assets', kind: 'instance',
20
+ standardClass: { isa95: 'PhysicalAsset', iso55000: 'Asset' } },
21
+ { axis: 'personnelClasses', path: 'personnelClasses', label: 'twin.axis.personnelClasses', kind: 'class',
22
+ standardClass: { isa95: 'PersonnelClass' } },
23
+ { axis: 'equipmentClasses', path: 'equipmentClasses', label: 'twin.axis.equipmentClasses', kind: 'class',
24
+ standardClass: { isa95: 'EquipmentClass' } },
25
+ { axis: 'assetClasses', path: 'assetClasses', label: 'twin.axis.assetClasses', kind: 'class',
26
+ standardClass: { isa95: 'PhysicalAssetClass' } },
27
+ { axis: 'materialDefinitions', path: 'materialDefinitions', label: 'twin.axis.materialDefinitions', kind: 'class',
28
+ standardClass: { isa95: 'MaterialDefinition' } },
29
+ { axis: 'materialClasses', path: 'materialClasses', label: 'twin.axis.materialClasses', kind: 'class',
30
+ standardClass: { isa95: 'MaterialClass' } },
31
+ { axis: 'operations', path: 'operations', label: 'twin.axis.operations', kind: 'spec',
32
+ standardClass: { isa95: 'OperationsSegment' } },
33
+ { axis: 'productionSpec', path: 'productionSpec', label: 'twin.axis.productionSpec', kind: 'spec',
34
+ standardClass: { isa95: 'OperationsSegment' } },
35
+ /*
36
+ * 아래 셋은 저장상 `productionSpec.definition` 안에 있지만 **개념으로는 1급**이다.
37
+ * `materials` 가 최상위 `materialDefinitions` 와 **둘 다** 있는 것은 지금 상태 그대로다 —
38
+ * 전자는 도메인 정의의 가벼운 목록(`{key,label}`), 후자는 ISA-95 풍부형이다. 같은 것의 집이
39
+ * 둘이라는 사실을 **선언이 드러낸다**(감추면 화면이 어느 쪽을 보는지 아무도 모른다).
40
+ */
41
+ { axis: 'recipes', path: 'productionSpec.definition.recipes', label: 'twin.axis.recipes', kind: 'spec',
42
+ standardClass: { isa95: 'OperationsSegment' } },
43
+ { axis: 'routes', path: 'productionSpec.definition.routes', label: 'twin.axis.routes', kind: 'spec',
44
+ standardClass: { isa95: 'OperationsSegment' } },
45
+ { axis: 'materials', path: 'productionSpec.definition.materials', label: 'twin.axis.materials', kind: 'class',
46
+ standardClass: { isa95: 'MaterialDefinition' } }
47
+ ];
48
+ /** 관계 전체 — 지도의 선과 항목의 이웃이 여기서 나온다(화면은 이 목록을 갖지 않는다). */
49
+ export const TWIN_RELATIONS = [
50
+ /* 자리가 속한 구역 — **board 밖**(호스트의 `TwinArea`). 해소는 호스트가 한다. */
51
+ { from: 'locations', field: 'parentId', target: { kind: 'external', entity: 'space.area' }, via: 'twin.rel.area', optional: true },
52
+ /* 자원이 사는 자리 */
53
+ { from: 'equipment', field: 'homeLocation', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.home', optional: true },
54
+ { from: 'persons', field: 'homeLocation', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.home', optional: true },
55
+ { from: 'assets', field: 'homeLocation', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.home', optional: true },
56
+ /* 자원의 등급 — 설비만 `kind`(타입)로 가고 사람·자산은 등급 목록으로 간다. 표준의 비대칭 그대로. */
57
+ { from: 'persons', field: 'personnelClassIds[]', target: { kind: 'axis', axis: 'personnelClasses' }, via: 'twin.rel.class', optional: true },
58
+ { from: 'assets', field: 'assetClassIds[]', target: { kind: 'axis', axis: 'assetClasses' }, via: 'twin.rel.class', optional: true },
59
+ { from: 'equipment', field: 'kind', target: { kind: 'type', role: 'equipment' }, via: 'twin.rel.type' },
60
+ { from: 'locations', field: 'type', target: { kind: 'type', role: 'location' }, via: 'twin.rel.type' },
61
+ /* 생산 — 레시피에서 시작해 라우트·공정·품목으로 퍼진다 */
62
+ { from: 'recipes', field: 'route', target: { kind: 'axis', axis: 'routes' }, via: 'twin.rel.route', optional: true },
63
+ { from: 'recipes', field: 'inputs[].material', target: { kind: 'axis', axis: 'materials' }, via: 'twin.rel.input' },
64
+ { from: 'recipes', field: 'outputs[].material', target: { kind: 'axis', axis: 'materials' }, via: 'twin.rel.output' },
65
+ { from: 'routes', field: 'steps[]', target: { kind: 'axis', axis: 'operations' }, via: 'twin.rel.step' },
66
+ /* 공정이 도는 자리·자원 — 항목이 아니라 **타입**을 가리킨다 */
67
+ { from: 'operations', field: 'locationType', target: { kind: 'type', role: 'location' }, via: 'twin.rel.at', optional: true },
68
+ { from: 'operations', field: 'resourceType', target: { kind: 'type', role: 'equipment' }, via: 'twin.rel.by', optional: true }
69
+ ];
70
+ /**
71
+ * 오늘 **실제로 짝이 서는 것**만 선언한다. 그럴듯한 속성을 미리 적어 두면 화면이 빈칸을 줄줄이
72
+ * 내고, 그건 "아직 안 쟀다" 가 아니라 "이 트윈은 부실하다" 로 읽힌다.
73
+ * `series` 는 아직 **아무도 내지 않는다** — EMS 가 붙을 때 생산자와 함께 선언한다.
74
+ */
75
+ export const TWIN_PROPERTIES = [
76
+ /* 공정 — 선언(ISO 8601 기간)과 관측(저널에서 접은 분포)이 둘 다 있는 유일한 짝. */
77
+ { axis: 'operations', key: 'duration', label: 'twin.prop.duration', kind: 'distribution',
78
+ declaredField: 'duration', observedBy: 'kpi-fold.workTime', uom: 'ms' },
79
+ /* 자리 — 용량은 선언, 점유는 관측. 단위가 다른 것이 아니라 **같은 축의 두 값**이다. */
80
+ { axis: 'locations', key: 'capacity', label: 'twin.prop.capacity', kind: 'level',
81
+ declaredField: 'capacity', observedBy: 'state.location.occupancy' },
82
+ /* 설비 — 고장 모델은 선언, 가동은 관측(OEE). 선언 없이도 관측은 선다. */
83
+ { axis: 'equipment', key: 'mtbf', label: 'twin.prop.mtbf', kind: 'distribution',
84
+ declaredField: 'mtbfMs', observedBy: 'oee.availability', uom: 'ms' },
85
+ { axis: 'equipment', key: 'mttr', label: 'twin.prop.mttr', kind: 'distribution',
86
+ declaredField: 'mttrMs', observedBy: 'oee.availability', uom: 'ms' },
87
+ { axis: 'equipment', key: 'availability', label: 'twin.prop.availability', kind: 'rate',
88
+ observedBy: 'oee.availability', uom: 'ratio' }
89
+ ];
90
+ /** 이 축이 보여 줄 속성들 — 없으면 빈 배열(속성 없는 축이 정상이다). */
91
+ export const propertiesOf = (axis) => TWIN_PROPERTIES.filter(p => p.axis === axis);
92
+ /** 이 축에서 나가는 관계 · 이 축으로 들어오는 관계 — 항목 패널의 "이어진 것" 양방향. */
93
+ export const relationsFrom = (axis) => TWIN_RELATIONS.filter(r => r.from === axis);
94
+ export const relationsTo = (axis) => TWIN_RELATIONS.filter(r => r.target.kind === 'axis' && r.target.axis === axis);
95
+ /** 축 이름 → 서술. 모르는 축이면 `undefined` — 화면은 그것을 "미선언" 으로 낸다(숨기지 않는다). */
96
+ export function axisInfo(axis) {
97
+ return TWIN_AXES.find(a => a.axis === axis);
98
+ }
11
99
  export const DOMAIN_SYSTEMS = ['wms', 'yms', 'mes'];
12
100
  /** 타입 키 → 능력 프로파일(커널 SSOT). 호스트가 라이브 페이로드에 투영, 컴포넌트가 능력을 렌더. */
13
101
  export function capabilitiesForType(system, typeKey) {
@@ -376,6 +376,7 @@ export declare abstract class FlowEngine implements TwinKernel {
376
376
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
377
377
  private _attentionSince;
378
378
  dispatch(cmd: Command): CommandAck;
379
+ private dispatchInner;
379
380
  /** 도메인 커맨드 처리(order.release 등). 기본은 거절 — 도메인이 override. */
380
381
  protected handleCommand(cmd: Command): CommandAck;
381
382
  readonly scenario: ScenarioControl;
@@ -617,6 +618,17 @@ export declare abstract class FlowEngine implements TwinKernel {
617
618
  private oeeOf;
618
619
  /** 정책에 넘길 특정 타입 자리의 관측 뷰 — 예약(그 자리로 향하는 in-flight task) 포함. */
619
620
  protected slotViews(locationType: string): SlotView[];
621
+ /**
622
+ * 지금 처리 중인 커맨드의 상관값 — **디스패치 동안에만 있다.**
623
+ *
624
+ * 커맨드가 낳은 이벤트에 이 값을 실어야 "이 지시가 실제로 무엇을 일으켰나" 를 나중에 물을 수 있다.
625
+ * 그게 없으면 승인 기록은 "허락했다" 까지이고, 그 뒤 공장이 어떻게 움직였는지와 이어지지 않는다.
626
+ *
627
+ * **한계를 밝힌다**: 여기서 잇는 것은 그 자리에서 방출된 이벤트뿐이다. 나중 틱에 일어나는 후속
628
+ * (예: resourceDown 이 정한 수리 완료)은 시뮬 시간의 결과라 이어지지 않는다. 즉시 인과만 잇는다 —
629
+ * 먼 인과까지 같은 값으로 묶으면 "이 승인 때문"이라는 말이 사실보다 넓어진다.
630
+ */
631
+ private _correlationId?;
620
632
  protected emit(event: EpcisEvent): void;
621
633
  protected emitOp(eventType: string, data: unknown): void;
622
634
  /**
@@ -546,6 +546,20 @@ export class FlowEngine {
546
546
  /** 주목 신호가 **처음 성립한 시각**(id → ISO). 조건이 사라지면 지운다 — 재발은 새 시작이다. */
547
547
  _attentionSince = new Map(); // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
548
548
  dispatch(cmd) {
549
+ /*
550
+ * 이 커맨드가 낳는 이벤트에 상관값을 단다. 명시값이 없으면 **commandId 를 쓴다** — 호출자가
551
+ * 따로 챙기지 않아도 모든 지시가 자기 결과와 이어진다(감사 기록이 commandId 를 이미 갖고 있다).
552
+ * finally 로 반드시 걷는다: 남겨 두면 그 뒤 틱에서 일어난 무관한 일까지 이 커맨드 탓이 된다.
553
+ */
554
+ this._correlationId = cmd.correlationId ?? cmd.commandId;
555
+ try {
556
+ return this.dispatchInner(cmd);
557
+ }
558
+ finally {
559
+ this._correlationId = undefined;
560
+ }
561
+ }
562
+ dispatchInner(cmd) {
549
563
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
550
564
  // 거절 사유 = 언어 중립 코드 + 원시 파라미터. error 는 영어 폴백(로그·개발자용).
551
565
  const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
@@ -1282,15 +1296,26 @@ export class FlowEngine {
1282
1296
  views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
1283
1297
  return views;
1284
1298
  }
1299
+ /**
1300
+ * 지금 처리 중인 커맨드의 상관값 — **디스패치 동안에만 있다.**
1301
+ *
1302
+ * 커맨드가 낳은 이벤트에 이 값을 실어야 "이 지시가 실제로 무엇을 일으켰나" 를 나중에 물을 수 있다.
1303
+ * 그게 없으면 승인 기록은 "허락했다" 까지이고, 그 뒤 공장이 어떻게 움직였는지와 이어지지 않는다.
1304
+ *
1305
+ * **한계를 밝힌다**: 여기서 잇는 것은 그 자리에서 방출된 이벤트뿐이다. 나중 틱에 일어나는 후속
1306
+ * (예: resourceDown 이 정한 수리 완료)은 시뮬 시간의 결과라 이어지지 않는다. 즉시 인과만 잇는다 —
1307
+ * 먼 인과까지 같은 값으로 묶으면 "이 승인 때문"이라는 말이 사실보다 넓어진다.
1308
+ */
1309
+ _correlationId;
1285
1310
  emit(event) {
1286
1311
  this.revision++;
1287
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, data: event };
1312
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data: event };
1288
1313
  for (const h of this.handlers)
1289
1314
  h(e);
1290
1315
  }
1291
1316
  emitOp(eventType, data) {
1292
1317
  this.revision++;
1293
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
1318
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...(this._correlationId ? { correlationId: this._correlationId } : {}), data };
1294
1319
  for (const h of this.handlers)
1295
1320
  h(e);
1296
1321
  }
@@ -51,6 +51,9 @@ __export(index_exports, {
51
51
  PRIORITY_UNSET: () => PRIORITY_UNSET,
52
52
  RETIRED_VOCABULARY: () => RETIRED_VOCABULARY,
53
53
  StateProjector: () => ObservedReducer,
54
+ TWIN_AXES: () => TWIN_AXES,
55
+ TWIN_PROPERTIES: () => TWIN_PROPERTIES,
56
+ TWIN_RELATIONS: () => TWIN_RELATIONS,
54
57
  TwinHistory: () => TwinHistory,
55
58
  TwinObserver: () => TwinObserver,
56
59
  TwinRuntime: () => TwinRuntime,
@@ -67,6 +70,7 @@ __export(index_exports, {
67
70
  activeShiftOf: () => activeShiftOf,
68
71
  aggregationEvent: () => aggregationEvent,
69
72
  analyzeCapacity: () => analyzeCapacity,
73
+ axisInfo: () => axisInfo,
70
74
  capabilitiesForType: () => capabilitiesForType,
71
75
  classClosure: () => classClosure,
72
76
  compareStates: () => compareStates,
@@ -102,10 +106,13 @@ __export(index_exports, {
102
106
  parseIsoDuration: () => parseIsoDuration,
103
107
  partialFitPolicy: () => partialFitPolicy,
104
108
  priorityRank: () => priorityRank,
109
+ propertiesOf: () => propertiesOf,
105
110
  quantityIn: () => quantityIn,
106
111
  readBoardAssets: () => readBoardAssets,
107
112
  readBoardEquipment: () => readBoardEquipment,
108
113
  readBoardLocations: () => readBoardLocations,
114
+ relationsFrom: () => relationsFrom,
115
+ relationsTo: () => relationsTo,
109
116
  replay: () => replay,
110
117
  replaySegments: () => replaySegments,
111
118
  retiredVocabularyIn: () => retiredVocabularyIn,
@@ -1555,6 +1562,189 @@ var DOMAIN_CATALOG = {
1555
1562
  yms: { system: "yms", label: "twin.system.yms", types: YMS_TYPES, locationTypes: locationKeys(YMS_TYPES) },
1556
1563
  mes: { system: "mes", label: "twin.system.mes", types: MES_TYPES, locationTypes: locationKeys(MES_TYPES) }
1557
1564
  };
1565
+ var TWIN_AXES = [
1566
+ {
1567
+ axis: "locations",
1568
+ path: "locations",
1569
+ label: "twin.axis.locations",
1570
+ kind: "instance",
1571
+ typeRole: "location",
1572
+ standardClass: { epcis: "SGLN" }
1573
+ },
1574
+ {
1575
+ axis: "equipment",
1576
+ path: "equipment",
1577
+ label: "twin.axis.equipment",
1578
+ kind: "instance",
1579
+ typeRole: "equipment",
1580
+ standardClass: { isa95: "Equipment", iso55000: "Asset", epcis: "GIAI" }
1581
+ },
1582
+ {
1583
+ axis: "persons",
1584
+ path: "persons",
1585
+ label: "twin.axis.persons",
1586
+ kind: "instance",
1587
+ standardClass: { isa95: "Person" }
1588
+ },
1589
+ {
1590
+ axis: "assets",
1591
+ path: "assets",
1592
+ label: "twin.axis.assets",
1593
+ kind: "instance",
1594
+ standardClass: { isa95: "PhysicalAsset", iso55000: "Asset" }
1595
+ },
1596
+ {
1597
+ axis: "personnelClasses",
1598
+ path: "personnelClasses",
1599
+ label: "twin.axis.personnelClasses",
1600
+ kind: "class",
1601
+ standardClass: { isa95: "PersonnelClass" }
1602
+ },
1603
+ {
1604
+ axis: "equipmentClasses",
1605
+ path: "equipmentClasses",
1606
+ label: "twin.axis.equipmentClasses",
1607
+ kind: "class",
1608
+ standardClass: { isa95: "EquipmentClass" }
1609
+ },
1610
+ {
1611
+ axis: "assetClasses",
1612
+ path: "assetClasses",
1613
+ label: "twin.axis.assetClasses",
1614
+ kind: "class",
1615
+ standardClass: { isa95: "PhysicalAssetClass" }
1616
+ },
1617
+ {
1618
+ axis: "materialDefinitions",
1619
+ path: "materialDefinitions",
1620
+ label: "twin.axis.materialDefinitions",
1621
+ kind: "class",
1622
+ standardClass: { isa95: "MaterialDefinition" }
1623
+ },
1624
+ {
1625
+ axis: "materialClasses",
1626
+ path: "materialClasses",
1627
+ label: "twin.axis.materialClasses",
1628
+ kind: "class",
1629
+ standardClass: { isa95: "MaterialClass" }
1630
+ },
1631
+ {
1632
+ axis: "operations",
1633
+ path: "operations",
1634
+ label: "twin.axis.operations",
1635
+ kind: "spec",
1636
+ standardClass: { isa95: "OperationsSegment" }
1637
+ },
1638
+ {
1639
+ axis: "productionSpec",
1640
+ path: "productionSpec",
1641
+ label: "twin.axis.productionSpec",
1642
+ kind: "spec",
1643
+ standardClass: { isa95: "OperationsSegment" }
1644
+ },
1645
+ /*
1646
+ * 아래 셋은 저장상 `productionSpec.definition` 안에 있지만 **개념으로는 1급**이다.
1647
+ * `materials` 가 최상위 `materialDefinitions` 와 **둘 다** 있는 것은 지금 상태 그대로다 —
1648
+ * 전자는 도메인 정의의 가벼운 목록(`{key,label}`), 후자는 ISA-95 풍부형이다. 같은 것의 집이
1649
+ * 둘이라는 사실을 **선언이 드러낸다**(감추면 화면이 어느 쪽을 보는지 아무도 모른다).
1650
+ */
1651
+ {
1652
+ axis: "recipes",
1653
+ path: "productionSpec.definition.recipes",
1654
+ label: "twin.axis.recipes",
1655
+ kind: "spec",
1656
+ standardClass: { isa95: "OperationsSegment" }
1657
+ },
1658
+ {
1659
+ axis: "routes",
1660
+ path: "productionSpec.definition.routes",
1661
+ label: "twin.axis.routes",
1662
+ kind: "spec",
1663
+ standardClass: { isa95: "OperationsSegment" }
1664
+ },
1665
+ {
1666
+ axis: "materials",
1667
+ path: "productionSpec.definition.materials",
1668
+ label: "twin.axis.materials",
1669
+ kind: "class",
1670
+ standardClass: { isa95: "MaterialDefinition" }
1671
+ }
1672
+ ];
1673
+ var TWIN_RELATIONS = [
1674
+ /* 자리가 속한 구역 — **board 밖**(호스트의 `TwinArea`). 해소는 호스트가 한다. */
1675
+ { from: "locations", field: "parentId", target: { kind: "external", entity: "space.area" }, via: "twin.rel.area", optional: true },
1676
+ /* 자원이 사는 자리 */
1677
+ { from: "equipment", field: "homeLocation", target: { kind: "axis", axis: "locations" }, via: "twin.rel.home", optional: true },
1678
+ { from: "persons", field: "homeLocation", target: { kind: "axis", axis: "locations" }, via: "twin.rel.home", optional: true },
1679
+ { from: "assets", field: "homeLocation", target: { kind: "axis", axis: "locations" }, via: "twin.rel.home", optional: true },
1680
+ /* 자원의 등급 — 설비만 `kind`(타입)로 가고 사람·자산은 등급 목록으로 간다. 표준의 비대칭 그대로. */
1681
+ { from: "persons", field: "personnelClassIds[]", target: { kind: "axis", axis: "personnelClasses" }, via: "twin.rel.class", optional: true },
1682
+ { from: "assets", field: "assetClassIds[]", target: { kind: "axis", axis: "assetClasses" }, via: "twin.rel.class", optional: true },
1683
+ { from: "equipment", field: "kind", target: { kind: "type", role: "equipment" }, via: "twin.rel.type" },
1684
+ { from: "locations", field: "type", target: { kind: "type", role: "location" }, via: "twin.rel.type" },
1685
+ /* 생산 — 레시피에서 시작해 라우트·공정·품목으로 퍼진다 */
1686
+ { from: "recipes", field: "route", target: { kind: "axis", axis: "routes" }, via: "twin.rel.route", optional: true },
1687
+ { from: "recipes", field: "inputs[].material", target: { kind: "axis", axis: "materials" }, via: "twin.rel.input" },
1688
+ { from: "recipes", field: "outputs[].material", target: { kind: "axis", axis: "materials" }, via: "twin.rel.output" },
1689
+ { from: "routes", field: "steps[]", target: { kind: "axis", axis: "operations" }, via: "twin.rel.step" },
1690
+ /* 공정이 도는 자리·자원 — 항목이 아니라 **타입**을 가리킨다 */
1691
+ { from: "operations", field: "locationType", target: { kind: "type", role: "location" }, via: "twin.rel.at", optional: true },
1692
+ { from: "operations", field: "resourceType", target: { kind: "type", role: "equipment" }, via: "twin.rel.by", optional: true }
1693
+ ];
1694
+ var TWIN_PROPERTIES = [
1695
+ /* 공정 — 선언(ISO 8601 기간)과 관측(저널에서 접은 분포)이 둘 다 있는 유일한 짝. */
1696
+ {
1697
+ axis: "operations",
1698
+ key: "duration",
1699
+ label: "twin.prop.duration",
1700
+ kind: "distribution",
1701
+ declaredField: "duration",
1702
+ observedBy: "kpi-fold.workTime",
1703
+ uom: "ms"
1704
+ },
1705
+ /* 자리 — 용량은 선언, 점유는 관측. 단위가 다른 것이 아니라 **같은 축의 두 값**이다. */
1706
+ {
1707
+ axis: "locations",
1708
+ key: "capacity",
1709
+ label: "twin.prop.capacity",
1710
+ kind: "level",
1711
+ declaredField: "capacity",
1712
+ observedBy: "state.location.occupancy"
1713
+ },
1714
+ /* 설비 — 고장 모델은 선언, 가동은 관측(OEE). 선언 없이도 관측은 선다. */
1715
+ {
1716
+ axis: "equipment",
1717
+ key: "mtbf",
1718
+ label: "twin.prop.mtbf",
1719
+ kind: "distribution",
1720
+ declaredField: "mtbfMs",
1721
+ observedBy: "oee.availability",
1722
+ uom: "ms"
1723
+ },
1724
+ {
1725
+ axis: "equipment",
1726
+ key: "mttr",
1727
+ label: "twin.prop.mttr",
1728
+ kind: "distribution",
1729
+ declaredField: "mttrMs",
1730
+ observedBy: "oee.availability",
1731
+ uom: "ms"
1732
+ },
1733
+ {
1734
+ axis: "equipment",
1735
+ key: "availability",
1736
+ label: "twin.prop.availability",
1737
+ kind: "rate",
1738
+ observedBy: "oee.availability",
1739
+ uom: "ratio"
1740
+ }
1741
+ ];
1742
+ var propertiesOf = (axis) => TWIN_PROPERTIES.filter((p) => p.axis === axis);
1743
+ var relationsFrom = (axis) => TWIN_RELATIONS.filter((r) => r.from === axis);
1744
+ var relationsTo = (axis) => TWIN_RELATIONS.filter((r) => r.target.kind === "axis" && r.target.axis === axis);
1745
+ function axisInfo(axis) {
1746
+ return TWIN_AXES.find((a) => a.axis === axis);
1747
+ }
1558
1748
  var DOMAIN_SYSTEMS = ["wms", "yms", "mes"];
1559
1749
  function capabilitiesForType(system, typeKey) {
1560
1750
  return DOMAIN_CATALOG[system]?.types.find((t) => t.key === typeKey)?.capabilities ?? [];
@@ -2353,6 +2543,14 @@ var FlowEngine = class {
2353
2543
  _attentionSince = /* @__PURE__ */ new Map();
2354
2544
  // 확인(ack)된 주목 신호 id — 조건 지속돼도 acknowledged 로 표시(재발 시 재활성)
2355
2545
  dispatch(cmd) {
2546
+ this._correlationId = cmd.correlationId ?? cmd.commandId;
2547
+ try {
2548
+ return this.dispatchInner(cmd);
2549
+ } finally {
2550
+ this._correlationId = void 0;
2551
+ }
2552
+ }
2553
+ dispatchInner(cmd) {
2356
2554
  const ok = () => ({ commandId: cmd.commandId, accepted: true });
2357
2555
  const fail = (errorCode, errorParams) => ({ commandId: cmd.commandId, accepted: false, errorCode, errorParams, error: errorCode });
2358
2556
  switch (cmd.type) {
@@ -3014,14 +3212,25 @@ var FlowEngine = class {
3014
3212
  for (const n of this.locations.values()) if (n.type === locationType) views.push({ id: n.id, capacity: n.capacity, occupancy: n.occupancy, reserved: reserved.get(n.id) ?? 0 });
3015
3213
  return views;
3016
3214
  }
3215
+ /**
3216
+ * 지금 처리 중인 커맨드의 상관값 — **디스패치 동안에만 있다.**
3217
+ *
3218
+ * 커맨드가 낳은 이벤트에 이 값을 실어야 "이 지시가 실제로 무엇을 일으켰나" 를 나중에 물을 수 있다.
3219
+ * 그게 없으면 승인 기록은 "허락했다" 까지이고, 그 뒤 공장이 어떻게 움직였는지와 이어지지 않는다.
3220
+ *
3221
+ * **한계를 밝힌다**: 여기서 잇는 것은 그 자리에서 방출된 이벤트뿐이다. 나중 틱에 일어나는 후속
3222
+ * (예: resourceDown 이 정한 수리 완료)은 시뮬 시간의 결과라 이어지지 않는다. 즉시 인과만 잇는다 —
3223
+ * 먼 인과까지 같은 값으로 묶으면 "이 승인 때문"이라는 말이 사실보다 넓어진다.
3224
+ */
3225
+ _correlationId;
3017
3226
  emit(event) {
3018
3227
  this.revision++;
3019
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, data: event };
3228
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType: `epcis.${event.type}`, eventTime: event.eventTime, tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data: event };
3020
3229
  for (const h of this.handlers) h(e);
3021
3230
  }
3022
3231
  emitOp(eventType, data) {
3023
3232
  this.revision++;
3024
- const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, data };
3233
+ const e = { eventId: `${this.tenantId}-evt-${++this.eventSeq}`, eventType, eventTime: this.now(), tenantId: this.tenantId, ...this._correlationId ? { correlationId: this._correlationId } : {}, data };
3025
3234
  for (const h of this.handlers) h(e);
3026
3235
  }
3027
3236
  /**
@@ -4660,6 +4869,9 @@ function retiredVocabularyIn(line) {
4660
4869
  PRIORITY_UNSET,
4661
4870
  RETIRED_VOCABULARY,
4662
4871
  StateProjector,
4872
+ TWIN_AXES,
4873
+ TWIN_PROPERTIES,
4874
+ TWIN_RELATIONS,
4663
4875
  TwinHistory,
4664
4876
  TwinObserver,
4665
4877
  TwinRuntime,
@@ -4676,6 +4888,7 @@ function retiredVocabularyIn(line) {
4676
4888
  activeShiftOf,
4677
4889
  aggregationEvent,
4678
4890
  analyzeCapacity,
4891
+ axisInfo,
4679
4892
  capabilitiesForType,
4680
4893
  classClosure,
4681
4894
  compareStates,
@@ -4711,10 +4924,13 @@ function retiredVocabularyIn(line) {
4711
4924
  parseIsoDuration,
4712
4925
  partialFitPolicy,
4713
4926
  priorityRank,
4927
+ propertiesOf,
4714
4928
  quantityIn,
4715
4929
  readBoardAssets,
4716
4930
  readBoardEquipment,
4717
4931
  readBoardLocations,
4932
+ relationsFrom,
4933
+ relationsTo,
4718
4934
  replay,
4719
4935
  replaySegments,
4720
4936
  retiredVocabularyIn,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.4.2",
3
+ "version": "0.5.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": {