@things-factory/headless-twin 10.0.6 → 10.0.7

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.
Files changed (150) hide show
  1. package/README.md +37 -24
  2. package/dist-server/engine/entity-delta.d.ts +13 -1
  3. package/dist-server/engine/entity-delta.js +130 -16
  4. package/dist-server/engine/entity-delta.js.map +1 -1
  5. package/dist-server/engine/index.d.ts +4 -0
  6. package/dist-server/engine/index.js +4 -0
  7. package/dist-server/engine/index.js.map +1 -1
  8. package/dist-server/engine/kpi-fold.d.ts +20 -3
  9. package/dist-server/engine/kpi-fold.js +21 -4
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +9 -0
  12. package/dist-server/engine/kpi-query.js +118 -19
  13. package/dist-server/engine/kpi-query.js.map +1 -1
  14. package/dist-server/engine/kpi-target.d.ts +141 -0
  15. package/dist-server/engine/kpi-target.js +137 -0
  16. package/dist-server/engine/kpi-target.js.map +1 -0
  17. package/dist-server/engine/live-attentions.d.ts +4 -2
  18. package/dist-server/engine/live-attentions.js +5 -1
  19. package/dist-server/engine/live-attentions.js.map +1 -1
  20. package/dist-server/engine/measured-estimator.d.ts +50 -0
  21. package/dist-server/engine/measured-estimator.js +78 -0
  22. package/dist-server/engine/measured-estimator.js.map +1 -0
  23. package/dist-server/engine/model-basis.d.ts +23 -0
  24. package/dist-server/engine/model-basis.js +100 -0
  25. package/dist-server/engine/model-basis.js.map +1 -0
  26. package/dist-server/engine/oee-accumulator.d.ts +2 -2
  27. package/dist-server/engine/oee-accumulator.js +4 -4
  28. package/dist-server/engine/oee-accumulator.js.map +1 -1
  29. package/dist-server/engine/spec-coverage.d.ts +49 -0
  30. package/dist-server/engine/spec-coverage.js +60 -0
  31. package/dist-server/engine/spec-coverage.js.map +1 -0
  32. package/dist-server/engine/structure-diff.d.ts +25 -0
  33. package/dist-server/engine/structure-diff.js +63 -0
  34. package/dist-server/engine/structure-diff.js.map +1 -0
  35. package/dist-server/engine/travel-estimator.d.ts +57 -0
  36. package/dist-server/engine/travel-estimator.js +120 -0
  37. package/dist-server/engine/travel-estimator.js.map +1 -0
  38. package/dist-server/engine/twin-engine.d.ts +153 -17
  39. package/dist-server/engine/twin-engine.js +473 -81
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +5 -5
  42. package/dist-server/engine/warm-start.js +4 -4
  43. package/dist-server/engine/warm-start.js.map +1 -1
  44. package/dist-server/service/index.d.ts +4 -2
  45. package/dist-server/service/index.js +21 -14
  46. package/dist-server/service/index.js.map +1 -1
  47. package/dist-server/service/reference/reference-live.js +2 -1
  48. package/dist-server/service/reference/reference-live.js.map +1 -1
  49. package/dist-server/service/reference/reference-master.d.ts +307 -1
  50. package/dist-server/service/reference/reference-master.js +96 -8
  51. package/dist-server/service/reference/reference-master.js.map +1 -1
  52. package/dist-server/service/reference/reference-resolver.js +3 -3
  53. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  54. package/dist-server/service/reference/template-registry.d.ts +1 -1
  55. package/dist-server/service/reference/template-registry.js.map +1 -1
  56. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  57. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  58. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  59. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  60. package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
  61. package/dist-server/service/twin-event/twin-event-keys.js +3 -3
  62. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  63. package/dist-server/service/twin-event/twin-event.d.ts +11 -0
  64. package/dist-server/service/twin-event/twin-event.js +6 -1
  65. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  66. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  67. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-journal/twin-journal-query.d.ts +9 -0
  71. package/dist-server/service/twin-journal/twin-journal-query.js +54 -3
  72. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  73. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  75. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  76. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  77. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  78. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  79. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  80. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  81. package/dist-server/service/twin-space/twin-space.js +5 -0
  82. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  83. package/dist-server/service/twin-structure/index.d.ts +2 -0
  84. package/dist-server/service/twin-structure/index.js +6 -0
  85. package/dist-server/service/twin-structure/index.js.map +1 -0
  86. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  87. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  88. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  89. package/dist-server/service/twin-target/index.d.ts +4 -0
  90. package/dist-server/service/twin-target/index.js +8 -0
  91. package/dist-server/service/twin-target/index.js.map +1 -0
  92. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  93. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  94. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  95. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  96. package/dist-server/service/twin-target/twin-target.js +112 -0
  97. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  98. package/dist-server/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +6 -6
  100. package/server/engine/entity-delta.ts +135 -15
  101. package/server/engine/index.ts +4 -0
  102. package/server/engine/kpi-fold.ts +40 -8
  103. package/server/engine/kpi-query.ts +129 -19
  104. package/server/engine/kpi-target.ts +226 -0
  105. package/server/engine/live-attentions.ts +12 -2
  106. package/server/engine/measured-estimator.ts +91 -0
  107. package/server/engine/model-basis.ts +94 -0
  108. package/server/engine/oee-accumulator.ts +5 -5
  109. package/server/engine/spec-coverage.ts +85 -0
  110. package/server/engine/structure-diff.ts +88 -0
  111. package/server/engine/travel-estimator.ts +133 -0
  112. package/server/engine/twin-engine.ts +498 -84
  113. package/server/engine/warm-start.ts +8 -8
  114. package/server/service/index.ts +7 -0
  115. package/server/service/reference/reference-live.ts +2 -1
  116. package/server/service/reference/reference-master.ts +383 -10
  117. package/server/service/reference/reference-resolver.ts +3 -3
  118. package/server/service/reference/template-registry.ts +1 -1
  119. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  120. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  121. package/server/service/twin-event/twin-event-keys.ts +2 -2
  122. package/server/service/twin-event/twin-event.ts +15 -1
  123. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  124. package/server/service/twin-instance/twin-instance.ts +1 -1
  125. package/server/service/twin-journal/twin-journal-query.ts +52 -4
  126. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  127. package/server/service/twin-space/twin-space-area.ts +1 -1
  128. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  129. package/server/service/twin-space/twin-space.ts +14 -0
  130. package/server/service/twin-structure/index.ts +3 -0
  131. package/server/service/twin-structure/twin-structure.ts +93 -0
  132. package/server/service/twin-target/index.ts +5 -0
  133. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  134. package/server/service/twin-target/twin-target.ts +97 -0
  135. package/test/capability-mapping.test.ts +5 -5
  136. package/test/duration-estimators.test.ts +144 -0
  137. package/test/entity-delta.test.ts +150 -19
  138. package/test/ingest-bench.test.ts +9 -9
  139. package/test/kpi-fold.test.ts +232 -3
  140. package/test/live-mirror-parity.test.ts +53 -19
  141. package/test/master-to-twin.test.ts +87 -13
  142. package/test/model-basis.test.ts +86 -0
  143. package/test/oee-accumulator.test.ts +9 -9
  144. package/test/scale-twin-bench.test.ts +22 -22
  145. package/test/spec-coverage.test.ts +113 -0
  146. package/test/streamline-e2e.test.ts +10 -10
  147. package/test/structure-revision-db.test.ts +310 -0
  148. package/test/twin-event-keys.test.ts +2 -2
  149. package/test/vocabulary-guard.test.ts +43 -0
  150. package/test/warm-start.test.ts +9 -9
@@ -10,13 +10,13 @@
10
10
 
11
11
  /** 커널에 심을 관측 상태 — 구조가 아니라 "무엇이 어디에 얼마나". */
12
12
  export interface ObservedSeed {
13
- nodes: unknown[]
13
+ locations: unknown[]
14
14
  items: unknown[]
15
- movers: unknown[]
15
+ equipment: unknown[]
16
16
  }
17
17
 
18
18
  export type WarmStartPlan =
19
- | { action: 'hydrate'; seed: ObservedSeed; itemCount: number; moverCount: number }
19
+ | { action: 'hydrate'; seed: ObservedSeed; itemCount: number; equipmentCount: number }
20
20
  /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 심으면 측정이 오염된다. */
21
21
  | { action: 'skip'; reason: 'bench' }
22
22
  /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */
@@ -32,7 +32,7 @@ export type WarmStartPlan =
32
32
  * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작하는 편이 정직하다.
33
33
  */
34
34
  export function planWarmStart(
35
- state: { nodes?: unknown[]; items?: unknown[]; movers?: unknown[] } | null | undefined,
35
+ state: { locations?: unknown[]; items?: unknown[]; equipment?: unknown[] } | null | undefined,
36
36
  purpose: string | undefined,
37
37
  canHydrate: boolean
38
38
  ): WarmStartPlan {
@@ -40,14 +40,14 @@ export function planWarmStart(
40
40
  if (purpose === 'bench') return { action: 'skip', reason: 'bench' }
41
41
  if (!state) return { action: 'skip', reason: 'no-state' }
42
42
 
43
- const nodes = state.nodes ?? []
43
+ const locations = state.locations ?? []
44
44
  const items = state.items ?? []
45
- const movers = state.movers ?? []
45
+ const equipment = state.equipment ?? []
46
46
  /* 셋 다 비었으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
47
- if (nodes.length === 0 && items.length === 0 && movers.length === 0) return { action: 'skip', reason: 'no-state' }
47
+ if (locations.length === 0 && items.length === 0 && equipment.length === 0) return { action: 'skip', reason: 'no-state' }
48
48
 
49
49
  /* 지원 여부는 마지막에 본다 — 심을 게 있는데 못 심는 상황이라야 경고할 값어치가 있다. */
50
50
  if (!canHydrate) return { action: 'skip', reason: 'unsupported' }
51
51
 
52
- return { action: 'hydrate', seed: { nodes, items, movers }, itemCount: items.length, moverCount: movers.length }
52
+ return { action: 'hydrate', seed: { locations, items, equipment }, itemCount: items.length, equipmentCount: equipment.length }
53
53
  }
@@ -4,6 +4,8 @@ export * from './twin-instance/twin-instance.js'
4
4
  export * from './twin-space/twin-space.js'
5
5
  export * from './twin-space/twin-space-representation.js'
6
6
  export * from './twin-space/twin-space-area.js'
7
+ export * from './twin-target/twin-target.js'
8
+ export * from './twin-structure/twin-structure.js'
7
9
  export * from './twin-state/twin-state-subscription.js'
8
10
  export * from './reference/reference-master.js'
9
11
  export * from './reference/reference-registry.js'
@@ -17,7 +19,9 @@ export * from './twin-lifecycle/domain-catalog.js'
17
19
  /* IMPORT ENTITIES AND RESOLVERS */
18
20
  import { entities as TwinEventEntities } from './twin-event/index.js'
19
21
  import { entities as TwinInstanceEntities } from './twin-instance/index.js'
22
+ import { entities as TwinStructureEntities } from './twin-structure/index.js'
20
23
  import { entities as TwinSpaceEntities, resolvers as TwinSpaceResolvers } from './twin-space/index.js'
24
+ import { entities as TwinTargetEntities, resolvers as TwinTargetResolvers } from './twin-target/index.js'
21
25
  import { entities as TwinReferenceEntities, resolvers as TwinReferenceResolvers } from './reference/index.js'
22
26
  import { resolvers as TwinStateResolvers } from './twin-state/index.js'
23
27
  import { resolvers as TwinControlResolvers } from './twin-control/index.js'
@@ -31,7 +35,9 @@ export const entities = [
31
35
  /* ENTITIES */
32
36
  ...TwinEventEntities,
33
37
  ...TwinInstanceEntities,
38
+ ...TwinStructureEntities,
34
39
  ...TwinSpaceEntities,
40
+ ...TwinTargetEntities,
35
41
  ...TwinReferenceEntities
36
42
  ]
37
43
 
@@ -49,6 +55,7 @@ export const schema = {
49
55
  ...TwinAttentionResolvers,
50
56
  ...TwinMetricsResolvers,
51
57
  ...TwinSpaceResolvers,
58
+ ...TwinTargetResolvers,
52
59
  ...TwinReferenceResolvers
53
60
  ],
54
61
  directives: {}
@@ -30,7 +30,8 @@ export async function startReferenceLiveFeed(
30
30
  if (!det?.board) throw new Error(`instance "${instanceId}" not provisioned (no board) — ingest master before live start`)
31
31
 
32
32
  // mirror 모드 기동 — 커널 tick 없이 projector 가 실 이벤트를 미러(startLive 가 realityMode='mirror' 설정).
33
- TwinEngine.startLive(instanceId, domainId, det.kind ?? 'wms', det.board)
33
+ // 시각 기준은 **공간**이 갖는다(테넌트 아님) 교대의 HH:MM 을 어느 기준으로 읽나.
34
+ TwinEngine.startLive(instanceId, domainId, det.kind ?? 'wms', await TwinEngine.withSpaceTimeBase(det.board, domainId))
34
35
 
35
36
  stopReferenceLiveFeed(instanceId) // 재기동 시 이전 피드 정리
36
37
  const unsubscribe = adapter.openLiveFeed(cfg, site, (records: unknown[]) => {
@@ -7,17 +7,84 @@
7
7
  * 좌표·representations 는 트윈 공간층(보완/추가). 실 시스템이 좌표를 안 주면 layout 없이 위상만 인제스트.
8
8
  */
9
9
 
10
+ import type { EquipmentLevel, WorkCalendarEntry } from '@operato/twin-kernel'
11
+
10
12
  export interface RefLocation {
11
13
  id: string
12
14
  type: string // 노드는 커널 노드타입(dock/storage/yard-slot…), area 는 공간 region 타입
13
15
  name?: string
14
- role: 'node' | 'area' | 'both'
16
+ role: 'location' | 'area' | 'both'
15
17
  parentId?: string
18
+ /** 저장 용량 — 이 자리에 동시에 놓일 수 있는 물품 수. */
16
19
  capacity?: number
20
+ /**
21
+ * 동시 처리 수 — 이 자리에서 한 번에 진행될 수 있는 작업 수. **저장 용량과 다른 축**이다
22
+ * (자재를 20개 쌓아 두는 절단 스테이션이 한 번에 한 대만 깎는다). 미지정=제약 없음.
23
+ */
24
+ parallelism?: number
25
+ /**
26
+ * **ISA-95 설비 계층 단계** — `EquipmentLevel` 열거값(`ProductionLine`·`StorageZone`·`WorkCenter`…).
27
+ *
28
+ * `type` 은 현장의 낱말(`paint-booth`)이고 이것은 **표준이 정한 역할**이다. 둘은 다른 축이라 함께 든다 —
29
+ * 연동·집계는 표준 축으로, 화면은 현장 낱말로. 미지정이면 그 자리의 표준 역할을 아직 모른다는 뜻이고,
30
+ * 짐작해 채우지 않는다.
31
+ */
32
+ level?: EquipmentLevel
17
33
  footprint?: { x: number; y: number; w: number; h: number } // 있으면 area 기하 + 노드 중심좌표
18
34
  gs1Id?: string // 정식 GS1 정체성(§J) — 위치=SGLN. 결합(tag)/추적 canonical id. 받아들이기 경계서 부여(로컬 id=name/xref 로 보존).
19
35
  }
20
36
 
37
+ /**
38
+ * 설비 속성 — **ISA-95 `EquipmentSpecificationProperty`(`OpEquipmentSpecificationPropertyType`) 1:1.**
39
+ * 그 타입도 `ID` + `Value`(`ValueString`·`DataType`·`UnitOfMeasure`) 이므로 공정 모수와 같은 모양이다.
40
+ *
41
+ * 단위 코드는 **UN/CEFACT 권고 20 공통코드**(EPCIS `uom` 과 같은 코드계): 속도는 `MTS`(metre per
42
+ * second) 또는 `KMH`(kilometre per hour). 코드를 우리가 발명하지 않는다.
43
+ */
44
+ /**
45
+ * 자원 속성 — **커널 `ResourceProperty` 와 같은 모양**이고, 그 모양은 ISA-95 가 세 자원에 똑같이
46
+ * 정의한 것이다(`EquipmentPropertyType` · `PersonPropertyType` · `PhysicalAssetPropertyType`).
47
+ *
48
+ * 그래서 자원마다 다른 속성 타입을 두지 않는다 — 예전에는 설비만 속성을 가졌고(RefEquipmentProperty),
49
+ * 인원·자산에 붙는 사실은 **실을 자리가 없어서 들어오지 못했다.**
50
+ */
51
+ export interface RefResourceProperty {
52
+ id: string
53
+ description?: string
54
+ value?: string
55
+ /** 표준 `Value.DataType`. */
56
+ dataType?: string
57
+ /** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드. 물리량에 단위가 없으면 소비처가 거절한다. */
58
+ uom?: string
59
+ /** 하위 속성(재귀) — 표준 `<X>PropertyChild`. */
60
+ children?: RefResourceProperty[]
61
+ /** 이 개체 속성이 구체화하는 등급 속성 — 표준 `<X>ClassPropertyID`. */
62
+ classPropertyId?: string
63
+ }
64
+
65
+ /** 옛 이름 — 설비 전용이던 시절의 별칭(소비처 호환). 신규 소싱은 `RefResourceProperty`. */
66
+ export type RefEquipmentProperty = RefResourceProperty
67
+
68
+ /**
69
+ * 자원 **등급 정의** — 커널 `ResourceClassDef` 와 같은 모양이고, 그 모양은 ISA-95 가 세 자원에 똑같이
70
+ * 정의한 것이다(`PersonnelClassType`·`EquipmentClassType`·`PhysicalAssetClassType`).
71
+ *
72
+ * 왜 필요한가: 원 시스템은 등급 **체계**를 갖고 있다("6축 용접 자격자" 는 "용접 자격자" 이고, 그것은
73
+ * "생산직" 이다). 그 체계가 들어올 자리가 없으면 요구와 소속이 **문자열이 똑같을 때만** 맞아,
74
+ * 현장에서는 되는 배정이 모델에서 막힌다.
75
+ */
76
+ export interface RefResourceClass {
77
+ id: string
78
+ description?: string
79
+ /** 상위 등급들 — 표준 `<X>ClassBaseID`(복수 = 다중 상속). */
80
+ baseIds?: string[]
81
+ properties?: RefResourceProperty[]
82
+ testSpecificationIds?: string[]
83
+ /** 유효 기간(ISO) — 표준 `EffectiveStartDate`/`EffectiveEndDate`. 밖이면 자격이 성립하지 않는다. */
84
+ effectiveStart?: string
85
+ effectiveEnd?: string
86
+ }
87
+
21
88
  export interface RefEquipment {
22
89
  id: string
23
90
  kind: string
@@ -25,6 +92,192 @@ export interface RefEquipment {
25
92
  mtbfMs?: number // 평균 고장 간격(확률적 고장) — 지정 시 커널이 breakdown 시뮬 → 주목(고장) 발생
26
93
  mttrMs?: number // 평균 수리 시간
27
94
  gs1Id?: string // 정식 GS1 정체성(§J) — 설비=GIAI.
95
+ /**
96
+ * 교대(가동시간) — 이 시간대에만 배정된다. `startHour <= endHour` 면 같은 날, 넘어가면 야간(22→6).
97
+ * 미지정=24시간 가용. 교대 밖 자원은 고장·계획정지와 **구별되는 이유**로 쉰다(상태에 드러난다).
98
+ */
99
+ window?: { startHour: number; endHour: number }
100
+ /**
101
+ * 근무 캘린더 — 표준 `WorkCalendarEntry`(복수). 있으면 `window` 보다 이것을 본다.
102
+ *
103
+ * 축을 프레임워크 `@things-factory/work-shift` 의 `WorkShift`(`fromDate`·`fromTime`·`toDate`·`toTime`)와
104
+ * 맞춰 두었다 — 그 엔티티를 교대의 정본으로 결선할 때 직선 매핑이 되게. **아직 결선하지 않았다**:
105
+ * 그 엔티티에는 자원 연결과 비근무(휴일)가 없어 확장이 함께 필요하고, operato-twin 의 의존성 추가는
106
+ * 앱 아키텍처 결정이다(커버리지 §4-7 에 결선 지점으로 기록).
107
+ */
108
+ workCalendar?: WorkCalendarEntry[]
109
+ /**
110
+ * 설비 속성(속도 등) — 없으면 **이동시간을 거리에서 계산할 수 없다**(속도 없이 거리만으로는 시간이
111
+ * 안 나온다). 그때는 커널 상수로 떨어지고 그 사실이 `specCoverage()` 에 남는다.
112
+ */
113
+ properties?: RefResourceProperty[]
114
+ /** 적격을 검증한 시험 명세들 — 표준 `TestSpecificationID`(교정 등). */
115
+ testSpecificationIds?: string[]
116
+ /**
117
+ * 유효 기간 — **표준 `EffectiveStartDate` / `EffectiveEndDate`**(개체 타입 셋 모두에 있다).
118
+ *
119
+ * 없으면 **폐기한 것이 영구히 살아 있다**: 배정 대상에 남고, 가용 수에 들어가고, 화면에 뜬다.
120
+ * 도입 예정인 것은 반대로 오늘부터 있는 것처럼 보인다. 기간 밖이면 커널이 그 시각의 모델에서
121
+ * 빼고 **이유를 달아 남긴다**(`effectivity: 'not-yet' | 'expired'`) — 조용히 지우지 않는다.
122
+ */
123
+ effectiveStart?: string
124
+ effectiveEnd?: string
125
+ }
126
+
127
+ /**
128
+ * 공정 모수 — 커널 `OperationParameter`(= ISA-95 `ParameterSpecification`/`ParameterType`) 와 **같은 모양**.
129
+ * 이름을 바꾸지 않는다: 인제스트 경계에서 어휘가 갈리면 그 자리에서 방언이 생긴다.
130
+ * ID 어휘의 정본은 커널 `OP_PARAM`(표준이 파라미터 ID 를 정하지 않으므로 우리가 한 곳에서 정한다).
131
+ */
132
+ export interface RefOperationParameter {
133
+ id: string
134
+ /** ISA-95 `Value.ValueString` — 표준이 문자열로 싣는다. 기간 모수는 ISO 8601(`PT8M`). */
135
+ value: string
136
+ /** ISA-95 `Value.UnitOfMeasure`. 무차원(비율)이면 생략. */
137
+ uom?: string
138
+ }
139
+
140
+ /**
141
+ * 공정의 자재 요구·산출 — **ISA-95 `OperationsSegment.MaterialSpecification`** 과 같은 모양.
142
+ * 인원·설비·자산 명세와 같은 자리에 있어야 4대 자원이 대칭이 된다(자재만 빠져 있었다).
143
+ */
144
+ export interface RefOpMaterialSpecification {
145
+ id?: string
146
+ /** 품목 등급으로 요구 — 표준 `MaterialClassID`. */
147
+ materialClass?: string
148
+ /** 특정 품목으로 요구 — 표준 `MaterialDefinitionID`. */
149
+ materialDefinition?: string
150
+ /** 표준 `MaterialUse` — 이 공정에서의 쓰임. */
151
+ use: 'consumed' | 'produced' | 'consumable'
152
+ /** 표준 `Quantity`. 단위 미지정이면 개수. */
153
+ quantity: number
154
+ uom?: string
155
+ }
156
+
157
+ /** 소요시간 변동 — **표준 밖 확장**(ISA-95 Duration 은 스칼라). 커널 `DurationVariability` 와 동형. */
158
+ export interface RefOperationVariability {
159
+ distribution: 'constant' | 'exponential' | 'uniform' | 'triangular'
160
+ min?: string
161
+ max?: string
162
+ mode?: string
163
+ }
164
+
165
+ /**
166
+ * 오퍼레이션 명세 — ISA-95 `OperationsSegment`. 커널 `OperationDef` 와 필드 이름이 1:1이다.
167
+ *
168
+ * **이것이 없으면 시뮬레이션의 시간은 우리가 코드에 박아 둔 상수다.** 원 시스템(또는 가상 레퍼런스)이
169
+ * 공정별 소요·변동·수율을 말해 줘야 예측이 그 현장의 예측이 된다. 주지 않으면 커널 기본값으로
170
+ * 굴러가고, **기본값을 썼다는 사실은 커널 `specCoverage()` 가 밝힌다**(조용히 넘어가지 않는다).
171
+ */
172
+ export interface RefOperation {
173
+ /** 작업 종류 키 — 커널 `FlowTask.kind` 와 같은 값이어야 명세가 그 작업에 붙는다. */
174
+ key: string
175
+ label: string
176
+ intent: 'transport' | 'process' | 'dwell'
177
+ locationType?: string
178
+ resourceType?: string
179
+ bizStep?: string
180
+ /** ISA-95 `OperationsSegment.Duration` — ISO 8601 기간(`PT12M`). */
181
+ duration?: string
182
+ variability?: RefOperationVariability
183
+ parameters?: RefOperationParameter[]
184
+ /**
185
+ * 필요 인원 — ISA-95 `PersonnelSpecification`(`PersonnelClassID` + `Quantity`) 1:1.
186
+ * 없으면 사람 없이 도는 공정이다(설비만 잡는다).
187
+ */
188
+ personnelSpecification?: { personnelClass?: string; quantity: number }[]
189
+ /** 필요 물리 자산 — ISA-95 `PhysicalAssetSpecification`(등급 + 수량). 빈 팔레트가 없으면 못 나간다. */
190
+ physicalAssetSpecification?: { assetClass?: string; quantity: number }[]
191
+ /**
192
+ * 필요·산출 자재 — ISA-95 `MaterialSpecification`. **4대 자원 중 자재만 이 자리가 없었다** —
193
+ * 그래서 부품이 떨어져 라인이 서는 상황이 예측에 나타나지 않았다.
194
+ * BOM 의 "몇 개" 는 여기다(품목 정의의 조립 구조에는 수량이 없다 — 1차 출처 확인).
195
+ */
196
+ materialSpecification?: RefOpMaterialSpecification[]
197
+ }
198
+
199
+ /**
200
+ * 사람 — **ISA-95 `Person`.** 설비와 별개 자원이다(고장·설비효율이 아니라 등급·교대로 산다).
201
+ * `personnelClass` 가 ISA-95 `PersonnelClassID` — 공정은 특정인이 아니라 **등급으로** 인원을 요구한다.
202
+ */
203
+ export interface RefPerson {
204
+ id: string
205
+ name?: string
206
+ /**
207
+ * 속한 등급들 — **표준 `Person.PersonnelClassID`, `maxOccurs="unbounded"`**.
208
+ * 복수인 것이 표준이고 그것이 자격 표현이다: 한 사람이 용접 자격과 지게차 자격을 함께 갖는다.
209
+ */
210
+ personnelClassIds?: string[]
211
+ /** 교대 — 설비와 같은 모양. 미지정=24시간 가용. */
212
+ window?: { startHour: number; endHour: number }
213
+ /** 근무 캘린더 — 표준 `WorkCalendarEntry`(복수). 2교대·휴게·휴일을 함께 표현한다. */
214
+ workCalendar?: WorkCalendarEntry[]
215
+ /**
216
+ * 기본 위치 — 표준 `Person.OperationalLocation`. 없으면 "이 라인에 몇 명 있나" 를 물을 수 없다.
217
+ * 사람은 움직이므로 이 값은 **출발점**이고, 실제 위치는 관측(person.status)이 갱신한다.
218
+ */
219
+ homeLocationId?: string
220
+ /**
221
+ * 자원 속성 — 표준 `PersonProperty`. **자격증·숙련도가 여기 들어간다.**
222
+ *
223
+ * 표준에는 자격(Qualification)이라는 별도 요소가 없다(`B2MML-Personnel.xsd` 확인). 자격은
224
+ * ① 등급 복수 소속으로(`personnelClassIds: ['welder-certified','forklift-licensed']`) ②
225
+ * 증빙은 이 속성으로(자격증 번호·만료일), ③ 검증 시험은 표준 `TestSpecificationID`(아직 미구현).
226
+ */
227
+ properties?: RefResourceProperty[]
228
+ /** 자격을 검증한 시험 명세들 — 표준 `TestSpecificationID`(참조만; 시험 명세·결과 모델은 아직 없다). */
229
+ testSpecificationIds?: string[]
230
+ /** 유효 기간 — 사람에게는 **입사 전·퇴사 후**다(자격 만료는 등급 쪽 기간이다: 사람은 남고 자격만 끊긴다). */
231
+ effectiveStart?: string
232
+ effectiveEnd?: string
233
+ }
234
+
235
+ /**
236
+ * 물리 자산(반복사용) — **ISA-95 `PhysicalAsset`, GS1 `GRAI`.**
237
+ * 팔레트·랙·용기처럼 **나갔다 돌아오는** 것. 물류단위(SSCC)와 다르다: SSCC 는 그 팔레트에 실린 화물
238
+ * 한 덩어리이고, GRAI 는 팔레트 자체다(오늘은 이 화물, 내일은 저 화물을 싣는다).
239
+ */
240
+ export interface RefAsset {
241
+ id: string
242
+ name?: string
243
+ /**
244
+ * 속한 등급들 — 표준 `PhysicalAssetClassID`(복수). 팔레트가 'euro-pallet' 이면서 'food-grade' 일 수 있다.
245
+ * 공정의 요구는 등급 하나 + 수량이다(표준 `PhysicalAssetSpecification`).
246
+ */
247
+ assetClassIds?: string[]
248
+ /** 기본 위치. */
249
+ homeLocationId?: string
250
+ /** 정식 GS1 정체성 — 반복사용 자산=GRAI. */
251
+ gs1Id?: string
252
+ /** 자원 속성 — 표준 `PhysicalAssetProperty`(적재 한도·규격 등). */
253
+ properties?: RefResourceProperty[]
254
+ /** 적격을 검증한 시험 명세들 — 표준 `TestSpecificationID`. */
255
+ testSpecificationIds?: string[]
256
+ /** 유효 기간 — 자산에서는 **폐기한 팔레트가 풀에서 빠지는 것**이다(회수 대상 수가 부풀지 않게). */
257
+ effectiveStart?: string
258
+ effectiveEnd?: string
259
+ }
260
+
261
+ /**
262
+ * 품목 정의 — **ISA-95 `MaterialDefinition`.**
263
+ *
264
+ * 없으면 로트만 관측으로 들어오고 **품목이 무엇인지는 아무 데도 없다.** 그래서 같은 로트를 100개로
265
+ * 받아도 "몇 kg 인가" 에 답할 수 없다(커널 `quantityIn` 이 환산을 거부하는 근거).
266
+ *
267
+ * **표준에 단위 환산 요소는 없다**(1차 출처 전수 확인) — 속성 주머니뿐이다. 그래서 환산 계수는
268
+ * 커널이 정한 속성 이름(`perBaseUnit`, `uom` 이 대상 단위)으로 `properties` 에 싣는다.
269
+ */
270
+ export interface RefMaterialDefinition {
271
+ /** 표준 `ID` — GTIN(`urn:epc:idpat:sgtin:…`)을 쓴다(정체성의 정본이 EPCIS 다). */
272
+ id: string
273
+ name?: string
274
+ /** 속한 등급들 — 표준 `MaterialClassID`(복수). */
275
+ materialClassIds?: string[]
276
+ /** 품목 속성 — 표준 `MaterialDefinitionProperty`. 환산 계수가 여기 들어간다. */
277
+ properties?: RefResourceProperty[]
278
+ testSpecificationIds?: string[]
279
+ effectiveStart?: string
280
+ effectiveEnd?: string
28
281
  }
29
282
 
30
283
  export interface ReferenceMaster {
@@ -32,9 +285,46 @@ export interface ReferenceMaster {
32
285
  system: 'wms' | 'yms' | 'mes'
33
286
  siteName: string
34
287
  /* spaceId 명시 시 그 공간을 참조(여러 트윈이 한 공간 공유, N:1). 없으면 source 에서 파생(1:1). */
35
- space: { spaceId?: string; width: number; depth: number; unit?: string; geo?: { lat: number; lon: number; rotation?: number }; representations?: any[]; primaryId?: string }
288
+ /**
289
+ * 현장(공간). **시각 기준(`timezone`)이 여기 있는 이유**: 한 테넌트가 Rosarito(태평양)와 한국
290
+ * 공장을 함께 가질 수 있다. 테넌트 단위(`Domain.timezone`)로 두면 **둘 중 하나는 반드시 틀린다.**
291
+ * 시각 기준은 **물리 현장의 성질**이므로 공간이 그것을 갖는다(공간 중심 원칙).
292
+ *
293
+ * `timezone` 은 IANA 이름(`'America/Tijuana'`) — 오프셋 숫자가 아니다. 숫자로 두면 일광절약시간을
294
+ * 표현할 수 없고, 나중에 절대 구간으로 옮길 때도 이름이 있어야 계산할 수 있다.
295
+ */
296
+ space: { spaceId?: string; width: number; depth: number; unit?: string; timezone?: string; geo?: { lat: number; lon: number; rotation?: number }; representations?: any[]; primaryId?: string }
36
297
  locations: RefLocation[]
37
298
  equipment?: RefEquipment[]
299
+ /** 공정 명세(소요·변동·수율) — 없으면 커널 기본값으로 굴러간다(그 사실은 specCoverage 가 보고). */
300
+ operations?: RefOperation[]
301
+ /**
302
+ * **생산 정의**(레시피·라우트·자재 바인딩) — 커널의 정의-구동 MES 모드 입구.
303
+ *
304
+ * 없으면 커널은 레거시(하드코딩) 흐름으로 돈다. 즉 **현장 레시피를 적어도 이 자리에 실리지 않으면
305
+ * 커널은 그것을 보지 못한다** — 선언과 실행이 갈라지는 지점이라 자리를 연다.
306
+ */
307
+ mesSpec?: unknown
308
+ /**
309
+ * 사람 — 선언하지 않으면 **인원 제약이 없는 트윈**이 된다(설비만 있으면 언제나 돌아가는 공장).
310
+ * 공정이 인원을 요구하는데 사람이 없으면 그 작업은 시작되지 않는다 — 그것이 사실이므로 그대로 둔다.
311
+ */
312
+ persons?: RefPerson[]
313
+ /**
314
+ * 물리 자산(반복사용) — 선언하지 않으면 자산 제약이 없는 트윈이다.
315
+ * 공정이 자산을 요구하는데 없으면 그 작업은 시작되지 않는다 — 그것이 사실이므로 그대로 둔다.
316
+ */
317
+ assets?: RefAsset[]
318
+ /**
319
+ * 등급 정의 — 표준 `PersonnelClass`·`EquipmentClass`·`PhysicalAssetClass`.
320
+ * 안 실어도 트윈은 돈다(소속 문자열 그대로 판정). 실으면 상속·유효기간이 살아난다.
321
+ */
322
+ personnelClasses?: RefResourceClass[]
323
+ equipmentClasses?: RefResourceClass[]
324
+ assetClasses?: RefResourceClass[]
325
+ /** 품목 정의·등급 — 표준 `MaterialDefinition` / `MaterialClass`. 없으면 단위 환산을 할 수 없다. */
326
+ materialDefinitions?: RefMaterialDefinition[]
327
+ materialClasses?: RefResourceClass[]
38
328
  }
39
329
 
40
330
  export interface IngestedTwin {
@@ -47,27 +337,110 @@ export interface IngestedTwin {
47
337
  * 마스터 → 트윈 구조 매핑(순수). 노드타입을 카탈로그로 검증(모르는 타입은 warning, 버리진 않음 — 반영 우선).
48
338
  * @param catalog DOMAIN_CATALOG (커널 SSOT). 없으면 검증 생략.
49
339
  */
340
+ /**
341
+ * IANA 시간대 이름 → **그 시각의** UTC 오프셋(분). 의존성 없이 `Intl` 로 푼다.
342
+ *
343
+ * `Intl.DateTimeFormat` 의 `longOffset` 은 `"GMT-07:00"` 처럼 나오고, 이것은 **일광절약시간을 반영한**
344
+ * 값이다. 그래서 8월의 `America/Tijuana` 는 −420(PDT), 1월은 −480(PST)이 된다.
345
+ *
346
+ * **한계를 밝힌다**: 여기서 나오는 것은 **한 시점의** 오프셋이다. 커널은 고정 오프셋으로 하루 중 분을
347
+ * 읽으므로, 보드를 만든 계절과 다른 계절을 길게 예측하면 한 시간 어긋난다. 표준에 충실한 끝 모습은
348
+ * 호스트가 절대 구간(`StartDateTime`/`FinishDateTime`)을 계산해 넣는 것이고, 그때 이 함수는 사라진다.
349
+ *
350
+ * 모르는 이름에는 **0 을 답하지 않는다**(`undefined`) — 0 은 "UTC 라고 선언했다" 는 뜻이고, 모르는 것을
351
+ * 그렇게 바꾸면 판정이 조용히 틀린다.
352
+ */
353
+ export function utcOffsetOf(timezone?: string, at: Date = new Date()): number | undefined {
354
+ if (!timezone) return undefined
355
+ try {
356
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'longOffset' }).formatToParts(at)
357
+ const name = parts.find(p => p.type === 'timeZoneName')?.value ?? ''
358
+ if (name === 'GMT') return 0 // UTC 계열은 오프셋 표기가 없다
359
+ const m = /^GMT([+-])(\d{1,2})(?::(\d{2}))?$/.exec(name)
360
+ if (!m) return undefined
361
+ const sign = m[1] === '-' ? -1 : 1
362
+ return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0))
363
+ } catch {
364
+ return undefined // 알 수 없는 이름 — Intl 이 던진다
365
+ }
366
+ }
367
+
50
368
  export function masterToTwin(master: ReferenceMaster, catalog?: any): IngestedTwin {
51
369
  const warnings: string[] = []
52
- const known: string[] = catalog?.[master.system]?.nodeTypes ?? []
370
+ const known: string[] = catalog?.[master.system]?.locationTypes ?? []
53
371
 
54
- const nodeLocs = master.locations.filter(l => l.role === 'node' || l.role === 'both')
372
+ const locationEntries = master.locations.filter(l => l.role === 'location' || l.role === 'both')
55
373
  const areaLocs = master.locations.filter(l => l.role === 'area' || l.role === 'both')
56
374
 
57
- const nodes = nodeLocs.map(l => {
58
- if (known.length && !known.includes(l.type)) warnings.push(`unknown ${master.system} node type "${l.type}" (${l.id}) — 카탈로그에 없음`)
375
+ const locations = locationEntries.map(l => {
376
+ if (known.length && !known.includes(l.type)) warnings.push(`unknown ${master.system} location type "${l.type}" (${l.id}) — 카탈로그에 없음`)
59
377
  // gs1Id(정식 정체성)를 보드 노드로 통과 — 결합(tag)/추적이 GS1 로(§J). 로컬 id 는 그대로 유지(name/xref).
60
- return { id: l.id, type: l.type, capacity: l.capacity ?? 0, parentId: l.parentId, gs1Id: l.gs1Id }
378
+ return { id: l.id, type: l.type, capacity: l.capacity ?? 0, ...(l.parallelism !== undefined ? { parallelism: l.parallelism } : {}), ...(l.level ? { level: l.level } : {}), parentId: l.parentId, gs1Id: l.gs1Id }
61
379
  })
62
380
 
63
- const movers = (master.equipment ?? []).map(e => ({ id: e.id, kind: e.kind, homeNode: e.homeLocationId, mtbfMs: e.mtbfMs, mttrMs: e.mttrMs, gs1Id: e.gs1Id }))
381
+ const equipment = (master.equipment ?? []).map(e => ({
382
+ id: e.id, kind: e.kind, homeLocation: e.homeLocationId, mtbfMs: e.mtbfMs, mttrMs: e.mttrMs, gs1Id: e.gs1Id,
383
+ ...(e.window ? { window: e.window } : {}),
384
+ /* 속성은 손대지 않고 통과 — 이동시간 추정기가 kind 별 속도를 여기서 읽는다(단위 코드 그대로). */
385
+ ...(e.properties?.length ? { properties: e.properties } : {}), ...(e.workCalendar?.length ? { workCalendar: e.workCalendar } : {}), ...(e.testSpecificationIds?.length ? { testSpecificationIds: e.testSpecificationIds } : {}),
386
+ /* 유효 기간도 손대지 않고 통과 — 판정은 커널이 자기 시계로 한다(호스트가 미리 자르지 않는다). */
387
+ ...(e.effectiveStart ? { effectiveStart: e.effectiveStart } : {}), ...(e.effectiveEnd ? { effectiveEnd: e.effectiveEnd } : {})
388
+ }))
64
389
 
65
390
  // 좌표(layout) — footprint 중심. 없으면 생략(coordinate-free 유지).
66
391
  const layout: Record<string, { x: number; y: number }> = {}
67
- for (const l of nodeLocs) if (l.footprint) layout[l.id] = { x: l.footprint.x + l.footprint.w / 2, y: l.footprint.y + l.footprint.h / 2 }
392
+ for (const l of locationEntries) if (l.footprint) layout[l.id] = { x: l.footprint.x + l.footprint.w / 2, y: l.footprint.y + l.footprint.h / 2 }
68
393
 
69
394
  const spaceId = master.space.spaceId ?? `${master.source}-site`
70
- const board = { nodes, movers, layout, spaceId }
395
+ /*
396
+ * 시각 기준을 커널이 읽을 형태로 푼다 — 커널은 zero-dep 이라 시간대 데이터베이스를 들이지 않고
397
+ * **분 오프셋**만 받는다(계약 `BoardDef.utcOffsetMinutes`). 푸는 일은 호스트의 몫이다.
398
+ *
399
+ * 선언이 없으면 **필드를 만들지 않는다** — 그러면 커널이 UTC 로 읽고, 그 기본값은 계약에 밝혀져 있다.
400
+ * 여기서 0 을 넣으면 "UTC 라고 선언했다" 와 "선언하지 않았다" 가 구별되지 않는다.
401
+ */
402
+ const utcOffsetMinutes = utcOffsetOf(master.space.timezone)
403
+ if (master.space.timezone && utcOffsetMinutes === undefined) {
404
+ warnings.push(`space timezone "${master.space.timezone}" is not a known IANA zone — the twin will read times as UTC`)
405
+ }
406
+ /*
407
+ * 오퍼레이션 명세를 board 에 통과시킨다 — 커널이 `loadOperations` 로 소비한다(BoardDef 확장 필드,
408
+ * 모르는 커널은 무시). 여기서 값을 손대지 않는다: 기간은 표준 표기(ISO 8601)로 커널까지 그대로 간다.
409
+ */
410
+ const operations = master.operations
411
+ if (operations?.length) {
412
+ for (const o of operations) {
413
+ if (!o.duration && !o.parameters?.length) warnings.push(`operation "${o.key}" has no duration or parameters — kernel defaults will be used`)
414
+ }
415
+ }
416
+ /* 사람도 손대지 않고 통과 — 등급·교대 그대로 커널까지 간다(경계에서 어휘를 갈지 않는다). */
417
+ const persons = master.persons?.map(p => ({ id: p.id, personnelClassIds: p.personnelClassIds, ...(p.window ? { window: p.window } : {}), ...(p.workCalendar?.length ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocationId ? { homeLocation: p.homeLocationId } : {}), ...(p.properties?.length ? { properties: p.properties } : {}), ...(p.testSpecificationIds?.length ? { testSpecificationIds: p.testSpecificationIds } : {}), ...(p.effectiveStart ? { effectiveStart: p.effectiveStart } : {}), ...(p.effectiveEnd ? { effectiveEnd: p.effectiveEnd } : {}) }))
418
+ /* 자산도 손대지 않고 통과 — 등급·자리 그대로 커널까지. */
419
+ const assets = master.assets?.map(a => ({ id: a.id, assetClassIds: a.assetClassIds, homeLocation: a.homeLocationId, gs1Id: a.gs1Id, ...(a.properties?.length ? { properties: a.properties } : {}), ...(a.testSpecificationIds?.length ? { testSpecificationIds: a.testSpecificationIds } : {}), ...(a.effectiveStart ? { effectiveStart: a.effectiveStart } : {}), ...(a.effectiveEnd ? { effectiveEnd: a.effectiveEnd } : {}) }))
420
+ /* 품목 정의·등급도 손대지 않고 통과 — 경계에서 어휘를 갈지 않는다(환산 계수는 커널이 읽는다). */
421
+ const materialDefinitions = master.materialDefinitions?.map(d => ({
422
+ id: d.id,
423
+ ...(d.name ? { description: d.name } : {}),
424
+ ...(d.materialClassIds?.length ? { materialClassIds: d.materialClassIds } : {}),
425
+ ...(d.properties?.length ? { properties: d.properties } : {}),
426
+ ...(d.testSpecificationIds?.length ? { testSpecificationIds: d.testSpecificationIds } : {}),
427
+ ...(d.effectiveStart ? { effectiveStart: d.effectiveStart } : {}),
428
+ ...(d.effectiveEnd ? { effectiveEnd: d.effectiveEnd } : {})
429
+ }))
430
+ const board = {
431
+ locations, equipment, layout, spaceId,
432
+ /* 생산 정의도 손대지 않고 통과 — 경계에서 어휘를 갈지 않는다(커널이 그대로 소비한다). */
433
+ ...(master.mesSpec ? { mesSpec: master.mesSpec } : {}),
434
+ ...(materialDefinitions?.length ? { materialDefinitions } : {}),
435
+ ...(master.materialClasses?.length ? { materialClasses: master.materialClasses } : {}),
436
+ ...(utcOffsetMinutes !== undefined ? { utcOffsetMinutes } : {}),
437
+ ...(operations?.length ? { operations } : {}),
438
+ ...(master.personnelClasses?.length ? { personnelClasses: master.personnelClasses } : {}),
439
+ ...(master.equipmentClasses?.length ? { equipmentClasses: master.equipmentClasses } : {}),
440
+ ...(master.assetClasses?.length ? { assetClasses: master.assetClasses } : {}),
441
+ ...(persons?.length ? { persons } : {}),
442
+ ...(assets?.length ? { assets } : {})
443
+ }
71
444
 
72
445
  const areas = areaLocs.map(l => ({
73
446
  id: l.id, type: l.type, name: l.name ?? l.id, parentId: l.parentId,
@@ -27,9 +27,9 @@ export class TwinReferenceResolver {
27
27
  source: m.source,
28
28
  system: m.system,
29
29
  siteName: m.siteName,
30
- nodeCount: m.locations.filter(l => l.role === 'node' || l.role === 'both').length,
30
+ locationCount: m.locations.filter(l => l.role === 'location' || l.role === 'both').length,
31
31
  areaCount: m.locations.filter(l => l.role === 'area' || l.role === 'both').length,
32
- moverCount: (m.equipment ?? []).length,
32
+ equipmentCount: (m.equipment ?? []).length,
33
33
  imported: !!reg,
34
34
  running: !!TwinEngine.instances[m.source]
35
35
  })
@@ -144,7 +144,7 @@ export class TwinReferenceResolver {
144
144
  }
145
145
 
146
146
  /** 한 Connection 그룹 노드 상세 — 그 아래 생성된 트윈 인스턴스(라이브 대조·상태·기동여부). */
147
- @Query(returns => ScalarObject, { nullable: true, description: 'One connection grouping node with its produced twin instances (live-checked). (reference-management §2.3)' })
147
+ @Query(returns => ScalarObject, { nullable: true, description: 'One connection grouping location with its produced twin instances (live-checked). (reference-management §2.3)' })
148
148
  async twinConnection(@Arg('source') source: string, @Ctx() context: ResolverContext): Promise<any | null> {
149
149
  const domainId = context.state.domain.id
150
150
  const r = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source } })
@@ -31,7 +31,7 @@ export interface TemplateDetail {
31
31
  phases?: { key: string; label: string; system?: string; note?: string }[]
32
32
  /** 공정 라우트(오퍼레이션 시퀀스). */
33
33
  route?: { op: string; label?: string; resource?: string; intent?: string; bizStep?: string }[]
34
- nodeTypes?: { key: string; label?: string; standardClass?: string; capabilities?: string[] }[]
34
+ locationTypes?: { key: string; label?: string; standardClass?: string; capabilities?: string[] }[]
35
35
  resourceTypes?: { key: string; label?: string; standardClass?: string; capabilities?: string[] }[]
36
36
  /** BOM/레시피 요약. */
37
37
  recipe?: {
@@ -7,7 +7,7 @@ import { TwinEngine } from '../../engine/index.js'
7
7
  /*
8
8
  * 주목 신호(Attention) 질의 — 현재 스냅샷의 요약된 "봐야 할 것".
9
9
  * 커널 computeAttentions() 파생: severity(ISA-18.2/IEC 62682) · state(OPC UA A&C) ·
10
- * anchor(nodeId/moverId/orderId = 공간 위치) · suggestedAction(조치 제안). kind 는 도메인 소유(무방언).
10
+ * anchor(locationId/moverId/orderId = 공간 위치) · suggestedAction(조치 제안). kind 는 도메인 소유(무방언).
11
11
  * full StateSnapshot 대신 이 경량 목록을 AI read 도구·insight UI 가 소싱(대용량 덤프 회피).
12
12
  * (실시간 스트림은 twinState subscription 이 attentions 를 포함. 이건 one-shot '질의' 표면.)
13
13
  */
@@ -30,7 +30,7 @@ export class TwinControlMutation {
30
30
  const ack = inst.runtime.dispatch({ ...command, tenantId: domainId })
31
31
  /* 라이브 구조 변이(resource.add)는 저장 board 에도 반영 — 프로비저닝 편집기·재기동이 추가분을 잃지 않게. */
32
32
  if (ack?.accepted && command?.type === 'resource.add') {
33
- await TwinEngine.syncBoardMovers(domainId, instanceId).catch(err => console.error('twin syncBoardMovers fail', err))
33
+ await TwinEngine.syncBoardEquipment(domainId, instanceId).catch(err => console.error('twin syncBoardEquipment fail', err))
34
34
  }
35
35
  return { accepted: !!ack?.accepted, error: ack?.error }
36
36
  }
@@ -85,7 +85,7 @@ export function locationOf(envelope: any): string | undefined {
85
85
  * EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 "이 지게차가 오늘 무엇을 했나" 를
86
86
  * 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 훑는 수밖에 없다.
87
87
  */
88
- export function moverOf(envelope: any): string | undefined {
88
+ export function equipmentIdOf(envelope: any): string | undefined {
89
89
  const d = envelope?.data ?? envelope ?? {}
90
90
  return d.moverId ?? undefined
91
91
  }
@@ -97,6 +97,6 @@ export function twinEventKeys(envelope: any): TwinEventKeys {
97
97
  epc: clip(epcOf(envelope), 'epc'),
98
98
  orderId: clip(orderOf(envelope), 'orderId'),
99
99
  locationId: clip(locationOf(envelope), 'locationId'),
100
- moverId: clip(moverOf(envelope), 'moverId')
100
+ moverId: clip(equipmentIdOf(envelope), 'moverId')
101
101
  }
102
102
  }
@@ -61,6 +61,20 @@ export class TwinEvent {
61
61
  @Field(type => Int, { nullable: true, description: 'Monotonic state revision at which the event was emitted.' })
62
62
  revision?: number
63
63
 
64
+ /**
65
+ * 이 사실이 **어느 공장에서** 일어났나 — `TwinStructure.rev`.
66
+ *
67
+ * 이것이 없던 시절에는 구조가 바뀌면 저널을 통째로 지우는 것이 유일한 길이었다(안 지우면 옛
68
+ * 이벤트를 새 공장에 대고 접어 이력이 거짓말을 한다). 이 한 칸이 셋째 길을 연다 — 재생이
69
+ * 구조가 바뀌는 지점에서 갈아탄 뒤 이어 접는다.
70
+ *
71
+ * **비어 있을 수 있다**: 컬럼이 생기기 전에 쓰인 행이다. 그 행들은 **가장 오래된 리비전**에
72
+ * 속한다(0 을 채워 넣지 않는다 — 0 은 "모름" 이 아니라 유효한 번호처럼 보인다).
73
+ */
74
+ @Column({ type: 'int', nullable: true })
75
+ @Field(type => Int, { nullable: true, description: 'Structure revision (TwinStructure.rev) in force when this event happened. Absent on rows written before structure revisions existed; those belong to the oldest revision.' })
76
+ structureRev?: number
77
+
64
78
  @Column({ nullable: true })
65
79
  @Field({ nullable: true, description: 'Simulation event time (ISO 8601).' })
66
80
  eventTime?: string
@@ -90,7 +104,7 @@ export class TwinEvent {
90
104
  locationId?: string
91
105
 
92
106
  @Column({ length: 255, nullable: true })
93
- @Field({ nullable: true, description: 'Equipment or mover identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server.' })
107
+ @Field({ nullable: true, description: 'Equipment identifier carried by operational deltas, promoted from the payload so one machine history can be asked for on the server. The column keeps the legacy name `moverId`: the journal is append-only history and mixing two names for the same fact across time is worse than an outdated name. Renaming belongs with event-schema versioning.' })
94
108
  moverId?: string
95
109
 
96
110
  @Column({ type: 'simple-json', nullable: true })