@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.0.6",
3
+ "version": "10.0.7",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh <heartyoh@hatiolab.com>",
@@ -25,11 +25,11 @@
25
25
  "test": "node --test test/*.test.ts"
26
26
  },
27
27
  "dependencies": {
28
- "@operato/twin-kernel": "^0.1.0",
29
- "@things-factory/auth-base": "^10.0.6",
30
- "@things-factory/cache-service": "^10.0.6",
28
+ "@operato/twin-kernel": "^0.4.0",
29
+ "@things-factory/auth-base": "^10.0.7",
30
+ "@things-factory/cache-service": "^10.0.7",
31
31
  "@things-factory/env": "^10.0.0",
32
- "@things-factory/shell": "^10.0.6"
32
+ "@things-factory/shell": "^10.0.7"
33
33
  },
34
- "gitHead": "b929c16f83f70bfcc9a144ac6a50232efa56cd64"
34
+ "gitHead": "fffd94e886b67ed9019993a572a632054e812fa3"
35
35
  }
@@ -1,9 +1,28 @@
1
1
  /*
2
2
  * 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능).
3
3
  * publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다.
4
- * payload 계약은 씬 컴포넌트가 소비(twin-storage·mover·equipment·trend·order-tracker·resource-control):
5
- * node: occupancy/capacity/status/type · mover: location/motion/status/held/oee(정수 퍼센트) ·
6
- * order: status/progress/held · __orders__: 전체 오더 집약(Trackable 뷰).
4
+ *
5
+ * ── 규율: 레퍼런스가 아는 것은 **전부** 내보낸다 ────────────────────────────
6
+ * 트윈에서 사소한 속성은 없다. 소속 구역·소속 팔레트·수량·만료처럼 곁가지로 보이는 값이 실제로는
7
+ * 구역 롤업·적재 표현·로트 추적의 전제다. 그래서 여기서 **필드를 손으로 골라 담지 않는다.**
8
+ *
9
+ * 예전에는 엔티티마다 필요할 것 같은 필드만 열거했고, 그 결과 계약에 있는 것이 조용히 누락됐다
10
+ * (2026-08-01 감사: 노드 parentId 누락 · 작업 전량 누락 · 물품 전량 누락). 화이트리스트 방식은
11
+ * **계약이 자라도 화면이 따라오지 못한다** — 커널이 속성을 늘릴 때마다 이 파일을 고쳐야 하고, 고치는
12
+ * 것을 잊으면 아무 신호 없이 정보가 사라진다.
13
+ *
14
+ * 그래서 반대로 한다: **상태 객체가 가진 것을 그대로 통과**시키고, 통과시키지 **않을** 것만 이유와 함께
15
+ * 열거한다(EXCLUDED). 새 속성은 자동으로 화면에 도달하고, 빼려면 이유를 적어야 한다.
16
+ *
17
+ * ── 표식은 도메인 낱말을 훔치지 않는다 ─────────────────────────────────────
18
+ * 예전 payload 는 엔티티 종류 표식을 `kind` 로 실었다. 그런데 `kind` 는 **도메인의 낱말**이다 —
19
+ * 무버 kind=cutter, 작업 kind=putaway, 오더 kind=workorder. 표식이 그 이름을 차지하면 도메인 속성을
20
+ * 실을 자리가 없어진다(같은 병을 오늘 ox-timeline 의 `group` 에서도 고쳤다).
21
+ * 그래서 표식은 `entity`(우리 이름)로 옮기고 `kind` 는 도메인에 돌려준다.
22
+ *
23
+ * payload 계약(씬 컴포넌트가 소비): `entity`(표식) + 그 엔티티의 모든 속성(도메인 `kind` 포함).
24
+ * entity = location · equipment · person · asset · order · task · item,
25
+ * 그리고 집약 태그(__orders__ · __tasks__ · __items__ · __persons__ · __assets__).
7
26
  */
8
27
 
9
28
  export interface EntityDelta {
@@ -11,11 +30,26 @@ export interface EntityDelta {
11
30
  data: any
12
31
  }
13
32
 
14
- /** 오더 집약 고정 태그 — 클라 provider(ORDERS_TAG) 일치. */
33
+ /** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */
15
34
  export const ORDERS_TAG = '__orders__'
35
+ export const TASKS_TAG = '__tasks__'
36
+ export const ITEMS_TAG = '__items__'
37
+ export const PERSONS_TAG = '__persons__'
38
+ export const ASSETS_TAG = '__assets__'
16
39
 
17
- /** OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트. 원시 누적 ms(idleMs 등)는 매 delta 증가해
18
- * 무변화-생략을 무력화하므로 제외(idle 자원의 매초 재방송 억제). oee 미측정이면 undefined. */
40
+ /**
41
+ * 통과시키지 않는 필드 **빼는 데는 이유가 필요하다.**
42
+ *
43
+ * · `id`/`epc` — 태그로 이미 나가므로 개별 payload 에 중복해 싣지 않는다(집약 뷰에는 넣는다).
44
+ * · `oee` — 원시 누적(idleMs 등)이 매 delta 증가해 "무변화 생략" 을 무력화한다. shapeOee 로 정형화해 얹는다.
45
+ */
46
+ const EXCLUDED = new Set(['id', 'epc', 'oee'])
47
+
48
+ /**
49
+ * OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트.
50
+ * 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초 재방송 억제).
51
+ * oee 미측정이면 undefined.
52
+ */
19
53
  function shapeOee(o: any): any {
20
54
  if (!o) return undefined
21
55
  return {
@@ -28,22 +62,108 @@ function shapeOee(o: any): any {
28
62
  }
29
63
  }
30
64
 
65
+ /**
66
+ * 엔티티 하나의 payload — `entity` 표식 + 나머지 전부(도메인 `kind` 포함).
67
+ *
68
+ * `undefined` 값은 싣지 않는다(키의 유무가 시그니처를 흔들지 않게). 값이 없는 것과 키가 없는 것은
69
+ * 소비처에서 같으므로 안전하고, 무변화 생략이 잘 듣는다.
70
+ */
71
+ function payloadOf(entityKind: string, entity: any): any {
72
+ /* 표식은 `entity` — 도메인의 `kind` 를 덮지 않는다. */
73
+ const data: any = { entity: entityKind }
74
+ for (const [k, v] of Object.entries(entity ?? {})) {
75
+ if (EXCLUDED.has(k) || v === undefined) continue
76
+ data[k] = v
77
+ }
78
+ return data
79
+ }
80
+
81
+ /**
82
+ * 집약 뷰의 항목 — 개별 payload 와 같은 내용 + 식별자.
83
+ * 집약은 "전체를 한 컴포넌트가 추적" 하는 용도라 식별자가 있어야 한다.
84
+ */
85
+ function aggregateItem(entity: any, idKey: 'id' | 'epc'): any {
86
+ const out: any = { [idKey]: entity?.[idKey] }
87
+ for (const [k, v] of Object.entries(entity ?? {})) {
88
+ if (k === idKey || k === 'oee' || v === undefined) continue
89
+ out[k] = v
90
+ }
91
+ const shaped = shapeOee(entity?.oee)
92
+ if (shaped) out.oee = shaped
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 방송에 실으면 전달이 현장을 느리게 한다.
98
+ *
99
+ * **조용히 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가
100
+ * "이게 전부" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup
101
+ * 이 걸려 **변한 것만** 재방송된다.
102
+ */
103
+ export const ITEMS_AGGREGATE_LIMIT = 500
104
+
31
105
  export function buildEntityDeltas(state: any): EntityDelta[] {
32
106
  const out: EntityDelta[] = []
33
- for (const n of state?.nodes ?? []) {
34
- out.push({ tag: n.id, data: { kind: 'node', occupancy: n.occupancy, capacity: n.capacity, status: n.status, type: n.type } })
107
+
108
+ for (const n of state?.locations ?? []) {
109
+ out.push({ tag: n.id, data: payloadOf('location', n) })
110
+ }
111
+ for (const m of state?.equipment ?? []) {
112
+ /* Mobile(location/motion) + Operable(status/held) + Processable(oee) — 한 payload 로.
113
+ * oee 만 정형화가 필요해 따로 얹는다(나머지는 통과). */
114
+ const data = payloadOf('equipment', m)
115
+ const oee = shapeOee(m?.oee)
116
+ if (oee) data.oee = oee
117
+ out.push({ tag: m.id, data })
35
118
  }
36
- for (const m of state?.movers ?? []) {
37
- // Mobile(location/motion) + Operable(status/held) + Processable(oee) 를 한 payload 로.
38
- out.push({ tag: m.id, data: { kind: 'mover', location: m.location, motion: m.motion, status: m.status, held: m.held, oee: shapeOee(m.oee) } })
119
+ /* 사람 설비와 별개 엔티티다. 같은 통과 규칙(가진 것을 전부 내보낸다). */
120
+ for (const p of state?.persons ?? []) {
121
+ out.push({ tag: p.id, data: payloadOf('person', p) })
122
+ }
123
+ /* 물리 자산(반복사용) — 물품도 설비도 아닌 네 번째 자원. */
124
+ for (const a of state?.assets ?? []) {
125
+ out.push({ tag: a.id, data: payloadOf('asset', a) })
39
126
  }
40
127
  for (const o of state?.orders ?? []) {
41
- out.push({ tag: o.id, data: { kind: 'order', status: o.status, progress: o.progress, held: o.held } })
128
+ out.push({ tag: o.id, data: payloadOf('order', o) })
129
+ }
130
+ for (const t of state?.tasks ?? []) {
131
+ out.push({ tag: t.id, data: payloadOf('task', t) })
132
+ }
133
+ for (const it of state?.items ?? []) {
134
+ /* 물품의 태그는 epc(개체 식별자) — 노드·무버와 달리 id 가 아니다. */
135
+ if (it?.epc) out.push({ tag: it.epc, data: payloadOf('item', it) })
42
136
  }
43
- // 집약(Trackable) — 오더는 런타임 생성이라 개별 배치 불가 → 한 컴포넌트가 전체 추적(집약 N:M). 항상 방출.
137
+
138
+ /* ── 집약 뷰 ──────────────────────────────────────────────────────────────
139
+ * 런타임에 생기고 사라지는 엔티티(오더·작업·물품)는 보드에 개별 배치할 수 없다 → 한 컴포넌트가
140
+ * 전체를 추적하는 집약 태그를 항상 방출한다. */
141
+ const orders = state?.orders ?? []
142
+ out.push({ tag: ORDERS_TAG, data: { entity: 'orders', orders: orders.map((o: any) => aggregateItem(o, 'id')) } })
143
+
144
+ const tasks = state?.tasks ?? []
145
+ out.push({ tag: TASKS_TAG, data: { entity: 'tasks', tasks: tasks.map((t: any) => aggregateItem(t, 'id')) } })
146
+
147
+ /* 사람 집약 — 인원은 보드에 개별 배치할 수도 있지만(설비처럼), 전체를 한눈에 보는 소비처도 있다.
148
+ * 인원을 선언하지 않은 트윈에서는 빈 배열이 나간다(없음과 모름을 구별). */
149
+ const persons = state?.persons ?? []
150
+ out.push({ tag: PERSONS_TAG, data: { entity: 'persons', persons: persons.map((p: any) => aggregateItem(p, 'id')) } })
151
+
152
+ const assets = state?.assets ?? []
153
+ out.push({ tag: ASSETS_TAG, data: { entity: 'assets', assets: assets.map((a: any) => aggregateItem(a, 'id')) } })
154
+
155
+ const items = state?.items ?? []
156
+ const shown = items.slice(0, ITEMS_AGGREGATE_LIMIT)
44
157
  out.push({
45
- tag: ORDERS_TAG,
46
- data: { kind: 'orders', orders: (state?.orders ?? []).map((o: any) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.progress, held: o.held })) }
158
+ tag: ITEMS_TAG,
159
+ data: {
160
+ entity: 'items',
161
+ items: shown.map((it: any) => aggregateItem(it, 'epc')),
162
+ total: items.length,
163
+ /* 잘렸으면 그 사실을 말한다 — "이게 전부" 로 읽히면 재고를 과소 판단한다. */
164
+ ...(items.length > shown.length ? { truncated: items.length - shown.length } : {})
165
+ }
47
166
  })
167
+
48
168
  return out
49
169
  }
@@ -2,3 +2,7 @@ export * from './twin-engine.js'
2
2
  export * from './canonical-ingest.js'
3
3
  export * from './kpi-fold.js'
4
4
  export * from './kpi-query.js'
5
+ export * from './spec-coverage.js'
6
+ export * from './travel-estimator.js'
7
+ export * from './measured-estimator.js'
8
+ export * from './model-basis.js'
@@ -25,7 +25,7 @@
25
25
  * 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다).
26
26
  * 이 파일은 이제 "접힌 기록 → 창 지표" 만 한다.
27
27
  */
28
- import { foldTaskRecords, type TaskFacets } from '@operato/twin-kernel'
28
+ import { foldTaskRecords, activeShiftAt, type TaskFacets, type WorkCalendarEntry } from '@operato/twin-kernel'
29
29
 
30
30
  /** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
31
31
  export interface KpiEvent {
@@ -44,6 +44,11 @@ export interface KpiWindow {
44
44
  export interface DurationStats {
45
45
  /** 짝이 맞아 계산된 건수. */
46
46
  count: number
47
+ /**
48
+ * 하위 꼬리 — **분포를 양쪽에서 접지하기 위해** 낸다.
49
+ * p50·p90 만 있으면 아래쪽 퍼짐을 위쪽에서 거울처럼 베껴 쓸 수밖에 없다(발명). 관측이 말하게 한다.
50
+ */
51
+ p10Ms: number
47
52
  p50Ms: number
48
53
  p90Ms: number
49
54
  avgMs: number
@@ -56,10 +61,11 @@ export interface DurationStats {
56
61
  * 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다.
57
62
  *
58
63
  * resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나)
59
- * node 도착 지점별(어디서) order 오더별(무엇을 위해)
64
+ * location 도착 지점별(어디서) order 오더별(무엇을 위해)
60
65
  * area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)
66
+ * shift 교대별(언제) — 완료 시각 + 현장의 시각 기준에서 **계산한다**(이벤트에 없다)
61
67
  */
62
- export type KpiGroupBy = 'resource' | 'taskKind' | 'node' | 'order' | 'area'
68
+ export type KpiGroupBy = 'resource' | 'taskKind' | 'location' | 'order' | 'area' | 'shift'
63
69
 
64
70
  export interface KpiGroup {
65
71
  /** 축의 값(자원 id·작업 종류·노드 id·오더 id·구역 id). 값이 없던 기록은 'unknown'. */
@@ -115,7 +121,17 @@ export interface KpiFoldOptions {
115
121
  * 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라
116
122
  * 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(조용히 다른 축으로 바꾸지 않는다).
117
123
  */
118
- nodeArea?: Record<string, string>
124
+ locationArea?: Record<string, string>
125
+ /**
126
+ * 교대 선언 — **이벤트에는 교대가 없다**(시각만 있다). 현장 마스터가 아는 값이라 호출부가 넘긴다.
127
+ * 없으면 교대 축은 'unknown' 으로 모인다(다른 축으로 조용히 바꾸지 않는다).
128
+ */
129
+ shifts?: WorkCalendarEntry[]
130
+ /**
131
+ * 현장의 시각 기준(분) — 교대 축이 `HH:MM` 을 어느 기준으로 읽을지. **공간이 갖는 값**이다
132
+ * (테넌트가 아니다: 한 테넌트가 다른 시간대의 공장을 함께 가질 수 있다). 없으면 UTC.
133
+ */
134
+ utcOffsetMinutes?: number
119
135
  /**
120
136
  * 축의 상한(기본 20). 넘치면 잘라내고 **잘린 축의 수와 그 안의 건수를 함께 알린다**.
121
137
  *
@@ -147,14 +163,29 @@ function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
147
163
  return f.resource ?? 'unknown'
148
164
  case 'taskKind':
149
165
  return f.kind ?? 'unknown'
150
- case 'node':
151
- return f.node ?? 'unknown'
166
+ case 'location':
167
+ return f.location ?? 'unknown'
152
168
  case 'order':
153
169
  return f.order ?? 'unknown'
154
170
  case 'area':
155
171
  /* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가
156
172
  * 그것을 구역으로 오해한다). */
157
- return (f.node && options.nodeArea?.[f.node]) ?? 'unknown'
173
+ return (f.location && options.locationArea?.[f.location]) ?? 'unknown'
174
+ case 'shift':
175
+ /*
176
+ * 교대는 **저널에 찍혀 있지 않다 — 계산한다.**
177
+ *
178
+ * 이벤트에 교대 이름을 스탬프하면 두 가지가 무너진다: ① 같은 사실이 두 곳에 생기고,
179
+ * ② 나중에 교대 시간을 고치면 **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 교대는
180
+ * `완료 시각 + 현장의 시각 기준 + 교대 선언`에서 나오는 순수 파생이므로 여기서 낸다 —
181
+ * 라이브·히스토리·예측이 **커널과 같은 함수**(`activeShiftOf`)를 쓴다.
182
+ *
183
+ * 선언이 없으면 'unknown' 이다. 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.
184
+ */
185
+ if (!options.shifts?.length) return 'unknown'
186
+ /* 시각까지 넘긴다 — **휴일에 일어난 일은 어느 교대에도 속하지 않는다.** 되풀이만 보면 그날 서지도
187
+ 않은 교대에 집계돼, 교대별 성과가 조용히 거짓이 된다. */
188
+ return activeShiftAt(options.shifts, rec.at, options.utcOffsetMinutes) ?? 'unknown'
158
189
  default:
159
190
  return 'unknown'
160
191
  }
@@ -218,11 +249,12 @@ function quantile(sorted: number[], q: number): number {
218
249
  }
219
250
 
220
251
  function stats(values: number[]): DurationStats {
221
- if (values.length === 0) return { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
252
+ if (values.length === 0) return { count: 0, p10Ms: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
222
253
  const sorted = [...values].sort((a, b) => a - b)
223
254
  const sum = sorted.reduce((a, b) => a + b, 0)
224
255
  return {
225
256
  count: sorted.length,
257
+ p10Ms: quantile(sorted, 0.1),
226
258
  p50Ms: quantile(sorted, 0.5),
227
259
  p90Ms: quantile(sorted, 0.9),
228
260
  avgMs: Math.round(sum / sorted.length)
@@ -16,12 +16,16 @@
16
16
  */
17
17
  import { Between, In } from 'typeorm'
18
18
 
19
+ import { hierarchyOf } from '@operato/twin-kernel'
20
+
19
21
  import { getRepository } from '@things-factory/shell'
20
22
 
21
23
  import { TwinEvent } from '../service/twin-event/twin-event.js'
22
24
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
23
25
  import { TwinArea } from '../service/twin-space/twin-area.js'
24
26
  import { TwinSpace } from '../service/twin-space/twin-space.js'
27
+ import { TwinTarget } from '../service/twin-target/twin-target.js'
28
+ import { judgeTargets, judgeGroupTargets, scopedTargets, type ScopedTargets, type TargetVerdict } from './kpi-target.js'
25
29
  import { TwinEngine } from './twin-engine.js'
26
30
  import {
27
31
  crossGroups,
@@ -118,6 +122,14 @@ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
118
122
  scope: { spaceId?: string; instanceIds: string[]; realityModes: string[] }
119
123
  /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */
120
124
  buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number }[]
125
+ /**
126
+ * **목표 대비 판정** — 숫자를 판단으로 바꾸는 값(§kpi-target).
127
+ *
128
+ * 목표가 걸린 지표만 들어온다. 목표가 없거나 그 구간에 값이 없는 지표는 **여기 나타나지 않는다** —
129
+ * 전부 "목표 없음" 으로 채우면 화면이 회색 노이즈가 되고, 없는 값을 0 으로 놓으면 "목표 크게 미달"
130
+ * 이라는 없는 사실이 만들어진다.
131
+ */
132
+ targets?: TargetVerdict[]
121
133
  /**
122
134
  * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.
123
135
  *
@@ -159,24 +171,94 @@ type KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] &
159
171
  Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>
160
172
 
161
173
  /**
162
- * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가
163
- * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
174
+ * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `locations[].parentId` 가
175
+ * 마스터 계층에서 온 소속이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
176
+ *
177
+ * **한 홉만 보면 안 된다.** `parentId` 는 구역일 수도 있고 중간 단(라인·셀)일 수도 있다. 라인이 끼면
178
+ * 한 홉짜리 코드는 "구역이 LINE-1" 이라고 답하고, 그 id 는 구역 목록에 없으므로 그 아래 스테이션이
179
+ * **집계에서 조용히 빠진다**(에러 없이 틀린 숫자). 그래서 커널 `hierarchyOf` 로 사슬을 끝까지 걷는다 —
180
+ * 걷는 규칙은 커널에 한 벌뿐이고 소비처는 그것만 부른다.
181
+ */
182
+ /**
183
+ * 교대 축의 재료 — **교대 선언과 현장의 시각 기준.**
164
184
  *
165
- * 구역 이름은 `TwinArea` 따로 있다id 보여주면 사용자가 무엇인지 모른다.
185
+ * 저널에는 시각만 있고 교대가 없다(일부러 그렇게 뒀다 교대는 파생이라 찍어 두면 나중에 교대 시간을
186
+ * 고쳤을 때 과거가 옛 구분으로 굳는다). 그래서 폴드가 계산할 재료를 여기서 모은다.
187
+ *
188
+ * 선언의 출처는 **보드의 설비**다. 여러 설비가 서로 다른 교대를 가질 수 있지만, 축은 하나여야 하므로
189
+ * **가장 많이 쓰이는 선언 한 벌**을 쓴다 — 3교대 공장은 전 설비가 같은 교대이므로 이것이 곧 정답이고,
190
+ * 자원마다 교대가 다른 현장에서는 이 축이 거칠다는 뜻이다(그 사실을 여기 적어 둔다).
191
+ *
192
+ * 시각 기준은 보드에 실려 있다(`utcOffsetMinutes` — 공간이 갖는 값을 기동 시 호스트가 풀어 넣는다).
193
+ * 없으면 UTC 로 읽고, 그 기본값은 커널 계약에 밝혀져 있다.
166
194
  */
167
- async function nodeAreaMap(
195
+ /**
196
+ * 이 범위에 걸린 운영 목표 — **좁은 선언이 넓은 선언을 덮는다.**
197
+ *
198
+ * 공간 전체 목표 위에 인스턴스 목표를 얹을 수 있어야 예외를 표현할 수 있다("이 라인만 다르다").
199
+ * 같은 지표에 둘 다 있으면 인스턴스가 이긴다.
200
+ */
201
+ async function loadTargets(
202
+ domainId: string,
203
+ spaceId: string | undefined,
204
+ instanceIds: string[],
205
+ axis?: string
206
+ ): Promise<ScopedTargets> {
207
+ /* 조회는 **가져오기만** 한다 — 어느 목표가 이기는지는 순수 함수(`scopedTargets`)가 정한다.
208
+ 그 규칙이 DB 접근 뒤에 있으면 화면을 눌러 봐야만 확인된다(그리고 실제로 확인되지 않았다). */
209
+ const rows = await getRepository(TwinTarget).find({ where: { domain: { id: domainId } } }).catch(() => [])
210
+ return scopedTargets(rows, { spaceId, instanceIds, axis })
211
+ }
212
+
213
+ async function shiftContext(
214
+ domainId: string,
215
+ instanceIds: string[]
216
+ ): Promise<{ shifts?: any[]; utcOffsetMinutes?: number }> {
217
+ if (!instanceIds.length) return {}
218
+ const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, instanceId: In(instanceIds) } })
219
+ const tally = new Map<string, { decl: any[]; n: number }>()
220
+ let utcOffsetMinutes: number | undefined
221
+ for (const row of rows) {
222
+ const board = row.board as any
223
+ if (utcOffsetMinutes === undefined && typeof board?.utcOffsetMinutes === 'number') utcOffsetMinutes = board.utcOffsetMinutes
224
+ for (const e of (board?.equipment ?? []) as any[]) {
225
+ const decl = e?.workCalendar
226
+ if (!Array.isArray(decl) || !decl.length) continue
227
+ const key = JSON.stringify(decl)
228
+ const hit = tally.get(key)
229
+ if (hit) hit.n++
230
+ else tally.set(key, { decl, n: 1 })
231
+ }
232
+ }
233
+ let best: { decl: any[]; n: number } | undefined
234
+ for (const v of tally.values()) if (!best || v.n > best.n) best = v
235
+ return { ...(best ? { shifts: best.decl } : {}), ...(utcOffsetMinutes !== undefined ? { utcOffsetMinutes } : {}) }
236
+ }
237
+
238
+ async function locationAreaMap(
168
239
  domainId: string,
169
240
  instanceIds: string[],
170
241
  spaceId?: string
171
- ): Promise<{ nodeArea: Record<string, string>; labels: Record<string, string> }> {
172
- const nodeArea: Record<string, string> = {}
242
+ ): Promise<{ locationArea: Record<string, string>; labels: Record<string, string> }> {
243
+ const locationArea: Record<string, string> = {}
173
244
  if (instanceIds.length) {
174
245
  const rows = await getRepository(TwinInstance).find({
175
246
  where: { domain: { id: domainId }, instanceId: In(instanceIds) }
176
247
  })
177
248
  for (const row of rows) {
178
- for (const n of ((row.board as any)?.nodes ?? []) as any[]) {
179
- if (n?.id && n?.parentId) nodeArea[String(n.id)] = String(n.parentId)
249
+ const locations = (((row.board as any)?.locations ?? []) as any[]).filter(n => n?.id)
250
+ if (!locations.length) continue
251
+ /* 순환은 hierarchyOf 가 던진다 — 한 트윈의 잘못된 계층이 다른 트윈의 집계까지 죽이지 않게 여기서 가둔다. */
252
+ let h
253
+ try {
254
+ h = hierarchyOf({ locations })
255
+ } catch (e: any) {
256
+ console.warn(`[twin-kpi] "${row.instanceId}": location hierarchy is broken — area rollup skipped for this twin: ${e?.message}`)
257
+ continue
258
+ }
259
+ for (const n of locations) {
260
+ const area = h.rollupOf(String(n.id))
261
+ if (area) locationArea[String(n.id)] = area // 구역 미상은 넣지 않는다 — 없는 구역을 만들지 않는다
180
262
  }
181
263
  }
182
264
  }
@@ -192,7 +274,7 @@ async function nodeAreaMap(
192
274
  for (const a of areas) if (a.areaId && a.name) labels[a.areaId] = a.name
193
275
  }
194
276
  }
195
- return { nodeArea, labels }
277
+ return { locationArea, labels }
196
278
  }
197
279
 
198
280
  /** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */
@@ -305,14 +387,24 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
305
387
 
306
388
  const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs)
307
389
 
308
- /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
390
+ /* 구역·교대 축은 **이벤트 밖의 지식**을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
309
391
  const areaMap =
310
- input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} }
392
+ input.groupBy === 'area' ? await locationAreaMap(input.domainId, instanceIds, input.spaceId) : { locationArea: {}, labels: {} }
393
+ const shiftCtx = input.groupBy === 'shift' ? await shiftContext(input.domainId, instanceIds) : {}
311
394
  const kpi = foldKpi(events, { fromMs, toMs }, {
312
395
  groupBy: input.groupBy,
313
- nodeArea: areaMap.nodeArea,
396
+ locationArea: areaMap.locationArea,
397
+ ...shiftCtx,
314
398
  groupLimit: input.groupLimit
315
399
  })
400
+ /*
401
+ * 목표를 **폴드 뒤, 그룹 조립 앞**에 읽는다 — 전체 판정과 축별 판정이 같은 목록에서 나와야
402
+ * 두 곳의 임계가 갈라지지 않는다. 폴드 자체는 목표를 모른다(사실 층과 판정 층의 분리).
403
+ */
404
+ const scoped = await loadTargets(input.domainId, input.spaceId, instanceIds, input.groupBy)
405
+ const verdicts = judgeTargets(kpi, scoped.overall)
406
+ const groupTargets = scoped.byAxisKey
407
+
316
408
  const window = {
317
409
  fromTime: new Date(fromMs).toISOString(),
318
410
  toTime: new Date(toMs).toISOString(),
@@ -361,7 +453,8 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
361
453
  * 있는데 없다고 말하게 된다). */
362
454
  const prev = foldKpi(prevEvents, w, {
363
455
  groupBy: input.groupBy,
364
- nodeArea: areaMap.nodeArea,
456
+ locationArea: areaMap.locationArea,
457
+ ...shiftCtx, // 비교 구간도 **같은 교대 정의**로 접는다(정의가 다르면 축이 어긋난다)
365
458
  groupLimit: input.groupBy ? 100 : undefined
366
459
  })
367
460
  prevCappedFlag = prevCapped
@@ -397,10 +490,16 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
397
490
  const groups = kpi.groups
398
491
  ? {
399
492
  by: kpi.groups.by,
400
- items: (crossed?.items ?? kpi.groups.items).map(g => ({
401
- ...g,
402
- ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})
403
- })),
493
+ items: (crossed?.items ?? kpi.groups.items).map(g => {
494
+ /* 축의 이 값에 목표가 걸려 있으면 판정을 함께 낸다 — "이 구역만 목표가 다르다" 를
495
+ 표에서 바로 있게. 목표가 없는 축값에는 필드를 만들지 않는다. */
496
+ const gt = judgeGroupTargets(g as any, groupTargets[g.key])
497
+ return {
498
+ ...g,
499
+ ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {}),
500
+ ...(gt.length ? { targets: gt } : {})
501
+ }
502
+ }),
404
503
  ...(crossed?.disappeared.length
405
504
  ? {
406
505
  disappeared: crossed.disappeared.map(d => ({
@@ -413,13 +512,23 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
413
512
  ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }
414
513
  : {}),
415
514
  ...(input.groupBy === 'area' &&
416
- Object.keys(areaMap.nodeArea).length === 0 &&
515
+ Object.keys(areaMap.locationArea).length === 0 &&
417
516
  kpi.groups.items.some(g => g.key === 'unknown')
418
- ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
517
+ ? { note: 'locations carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
419
518
  : {})
420
519
  }
421
520
  : undefined
422
521
 
522
+ /*
523
+ * 목표 대비 판정 — **사실 계산 뒤에 얹는다.**
524
+ *
525
+ * 폴드는 목표를 모른다(사실만 낸다). 목표를 아는 순간 "세는 규칙" 과 "잘했나 판정하는 규칙" 이
526
+ * 한 함수에 섞이고, 목표가 바뀔 때마다 사실 계산을 건드리게 된다. 그래서 조회 층에서 목표를
527
+ * 실어 오고, 판정은 순수 함수(`judgeTargets`)가 한다.
528
+ *
529
+ * **목표가 없으면 판정하지 않는다** — 필드 자체를 만들지 않는다. 전부 "목표 없음" 으로 채우면
530
+ * 화면이 회색 노이즈가 되고, 목표가 있는 지표가 묻힌다.
531
+ */
423
532
  /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */
424
533
  const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi
425
534
  return {
@@ -430,6 +539,7 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
430
539
  ...rest,
431
540
  ...(buckets ? { buckets } : {}),
432
541
  ...(groups ? { groups } : {}),
542
+ ...(verdicts.length ? { targets: verdicts } : {}),
433
543
  ...(comparison ? { comparison } : {})
434
544
  }
435
545
  }