@things-factory/headless-twin 10.0.5 → 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 (161) 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 +45 -53
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +16 -0
  12. package/dist-server/engine/kpi-query.js +129 -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 +181 -18
  39. package/dist-server/engine/twin-engine.js +545 -96
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +39 -0
  42. package/dist-server/engine/warm-start.js +37 -0
  43. package/dist-server/engine/warm-start.js.map +1 -0
  44. package/dist-server/index.js +8 -0
  45. package/dist-server/index.js.map +1 -1
  46. package/dist-server/service/index.d.ts +4 -2
  47. package/dist-server/service/index.js +21 -14
  48. package/dist-server/service/index.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +2 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +307 -1
  52. package/dist-server/service/reference/reference-master.js +96 -8
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +3 -3
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  59. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  60. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  61. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  62. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  63. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  64. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  65. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  66. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  67. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  68. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  69. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  70. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  71. package/dist-server/service/twin-event/twin-event.d.ts +16 -0
  72. package/dist-server/service/twin-event/twin-event.js +50 -0
  73. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  74. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  75. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  76. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  77. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  78. package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
  79. package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
  80. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  81. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  82. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  83. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  84. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  85. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  86. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  87. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  89. package/dist-server/service/twin-space/twin-space.js +5 -0
  90. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  91. package/dist-server/service/twin-structure/index.d.ts +2 -0
  92. package/dist-server/service/twin-structure/index.js +6 -0
  93. package/dist-server/service/twin-structure/index.js.map +1 -0
  94. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  95. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  96. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  97. package/dist-server/service/twin-target/index.d.ts +4 -0
  98. package/dist-server/service/twin-target/index.js +8 -0
  99. package/dist-server/service/twin-target/index.js.map +1 -0
  100. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  101. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  102. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  103. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  104. package/dist-server/service/twin-target/twin-target.js +112 -0
  105. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  106. package/dist-server/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +6 -6
  108. package/server/engine/entity-delta.ts +135 -15
  109. package/server/engine/index.ts +4 -0
  110. package/server/engine/kpi-fold.ts +62 -59
  111. package/server/engine/kpi-query.ts +140 -19
  112. package/server/engine/kpi-target.ts +226 -0
  113. package/server/engine/live-attentions.ts +12 -2
  114. package/server/engine/measured-estimator.ts +91 -0
  115. package/server/engine/model-basis.ts +94 -0
  116. package/server/engine/oee-accumulator.ts +5 -5
  117. package/server/engine/spec-coverage.ts +85 -0
  118. package/server/engine/structure-diff.ts +88 -0
  119. package/server/engine/travel-estimator.ts +133 -0
  120. package/server/engine/twin-engine.ts +587 -106
  121. package/server/engine/warm-start.ts +53 -0
  122. package/server/index.ts +9 -0
  123. package/server/service/index.ts +7 -0
  124. package/server/service/reference/reference-live.ts +2 -1
  125. package/server/service/reference/reference-master.ts +383 -10
  126. package/server/service/reference/reference-resolver.ts +3 -3
  127. package/server/service/reference/template-registry.ts +1 -1
  128. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  129. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  130. package/server/service/twin-event/backfill-keys.ts +72 -0
  131. package/server/service/twin-event/twin-event-keys.ts +102 -0
  132. package/server/service/twin-event/twin-event-type.ts +27 -0
  133. package/server/service/twin-event/twin-event.ts +62 -0
  134. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  135. package/server/service/twin-instance/twin-instance.ts +1 -1
  136. package/server/service/twin-journal/twin-journal-query.ts +129 -5
  137. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  138. package/server/service/twin-space/twin-space-area.ts +1 -1
  139. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  140. package/server/service/twin-space/twin-space.ts +14 -0
  141. package/server/service/twin-structure/index.ts +3 -0
  142. package/server/service/twin-structure/twin-structure.ts +93 -0
  143. package/server/service/twin-target/index.ts +5 -0
  144. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  145. package/server/service/twin-target/twin-target.ts +97 -0
  146. package/test/capability-mapping.test.ts +5 -5
  147. package/test/duration-estimators.test.ts +144 -0
  148. package/test/entity-delta.test.ts +150 -19
  149. package/test/ingest-bench.test.ts +9 -9
  150. package/test/kpi-fold.test.ts +254 -3
  151. package/test/live-mirror-parity.test.ts +53 -19
  152. package/test/master-to-twin.test.ts +87 -13
  153. package/test/model-basis.test.ts +86 -0
  154. package/test/oee-accumulator.test.ts +9 -9
  155. package/test/scale-twin-bench.test.ts +22 -22
  156. package/test/spec-coverage.test.ts +113 -0
  157. package/test/streamline-e2e.test.ts +10 -10
  158. package/test/structure-revision-db.test.ts +310 -0
  159. package/test/twin-event-keys.test.ts +108 -0
  160. package/test/vocabulary-guard.test.ts +43 -0
  161. package/test/warm-start.test.ts +78 -0
@@ -1,51 +1,182 @@
1
1
  /*
2
2
  * 호스트 방출 매핑(buildEntityDeltas) 단위 검증 — 씬 컴포넌트가 소비하는 data(tag) payload 계약.
3
- * held/oee(정수 퍼센트)/오더 집약 등 최근 추가분이 정확히 실리는지(회귀 방지).
3
+ *
4
+ * 지키는 것(2026-08-01 재편):
5
+ * ① **레퍼런스가 아는 것은 전부 나간다** — 필드를 손으로 골라 담지 않는다. 화이트리스트였을 때
6
+ * 노드 parentId·작업 전량·물품 전량이 조용히 누락됐다. 계약에 속성이 생기면 자동으로 도달해야 한다.
7
+ * ② **표식은 도메인 낱말을 훔치지 않는다** — 엔티티 종류 표식은 `entity`, `kind` 는 도메인 것
8
+ * (무버 cutter · 작업 putaway · 오더 workorder).
9
+ * ③ **조용히 자르지 않는다** — 물품 집약이 상한에 걸리면 total·truncated 로 밝힌다.
4
10
  */
5
11
  import { test } from 'node:test'
6
12
  import assert from 'node:assert/strict'
7
13
 
8
- import { buildEntityDeltas, ORDERS_TAG } from '../server/engine/entity-delta.ts'
14
+ import { buildEntityDeltas, ITEMS_AGGREGATE_LIMIT, ITEMS_TAG, ORDERS_TAG, PERSONS_TAG, TASKS_TAG } from '../server/engine/entity-delta.ts'
9
15
 
10
16
  const byTag = (arr: any[], tag: string) => arr.find(x => x.tag === tag)
11
17
 
12
- test('node delta: occupancy/capacity/status/type', () => {
13
- const d = buildEntityDeltas({ nodes: [{ id: 'bin-1', occupancy: 3, capacity: 10, status: 'idle', type: 'storage' }] })
14
- assert.deepEqual(byTag(d, 'bin-1').data, { kind: 'node', occupancy: 3, capacity: 10, status: 'idle', type: 'storage' })
18
+ test('노드: 계약의 속성이 전부 나간다 — parentId(소속 구역)를 포함해', () => {
19
+ const d = buildEntityDeltas({
20
+ locations: [{ id: 'bin-1', occupancy: 3, capacity: 10, status: 'idle', type: 'storage', parentId: 'zone-a' }]
21
+ })
22
+ assert.deepEqual(byTag(d, 'bin-1').data, {
23
+ entity: 'location',
24
+ occupancy: 3,
25
+ capacity: 10,
26
+ status: 'idle',
27
+ type: 'storage',
28
+ parentId: 'zone-a'
29
+ })
15
30
  })
16
31
 
17
- test('mover delta: held(계획정지) + status + OEE 정수 퍼센트 정형화', () => {
32
+ test('표식은 entity, kind 도메인 무버 종류가 표식에 덮이지 않는다', () => {
18
33
  const d = buildEntityDeltas({
19
- movers: [{ id: 'cutter-1', kind: 'cutter', location: 'cut', status: 'down', held: true, oee: { overall: 0.8234, availability: 0.9, performance: 0.95, quality: 0.96, goodCount: 12, scrapCount: 3, idleMs: 99999 } }]
34
+ equipment: [{ id: 'cutter-1', kind: 'cutter', location: 'cut', status: 'down', held: true }]
20
35
  })
21
36
  const m = byTag(d, 'cutter-1').data
22
- assert.equal(m.kind, 'mover')
37
+ assert.equal(m.entity, 'equipment', '표식')
38
+ assert.equal(m.kind, 'cutter', '도메인 속성 — 예전엔 표식에 덮여 사라졌다')
23
39
  assert.equal(m.status, 'down')
24
40
  assert.equal(m.held, true)
25
- // 정수 퍼센트 + 카운트만(원시 누적 ms 는 제외 — churn 억제)
26
- assert.deepEqual(m.oee, { overall: 82, availability: 90, performance: 95, quality: 96, good: 12, scrap: 3 })
27
41
  })
28
42
 
29
- test('mover delta: oee 미측정이면 undefined', () => {
30
- const d = buildEntityDeltas({ movers: [{ id: 'fk-1', kind: 'forklift', status: 'idle' }] })
43
+ test('무버: OEE 정수 퍼센트로 정형화하고 원시 누적은 빼낸다(churn 억제)', () => {
44
+ const d = buildEntityDeltas({
45
+ equipment: [
46
+ {
47
+ id: 'cutter-1',
48
+ kind: 'cutter',
49
+ status: 'down',
50
+ oee: { overall: 0.8234, availability: 0.9, performance: 0.95, quality: 0.96, goodCount: 12, scrapCount: 3, idleMs: 99999 }
51
+ }
52
+ ]
53
+ })
54
+ assert.deepEqual(byTag(d, 'cutter-1').data.oee, {
55
+ overall: 82,
56
+ availability: 90,
57
+ performance: 95,
58
+ quality: 96,
59
+ good: 12,
60
+ scrap: 3
61
+ })
62
+ })
63
+
64
+ test('무버: oee 미측정이면 실리지 않는다', () => {
65
+ const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', status: 'idle' }] })
31
66
  assert.equal(byTag(d, 'fk-1').data.oee, undefined)
32
67
  })
33
68
 
34
- test('order delta + __orders__ 집약(전체 오더)', () => {
69
+ test('작업: 개별 + 집약으로 나간다 — 예전엔 아예 방출되지 않았다', () => {
70
+ const d = buildEntityDeltas({
71
+ tasks: [
72
+ { id: 't1', kind: 'putaway', status: 'in-progress', fromNode: 'dock', toNode: 'rack-1', resourceRef: 'fk-1', orderId: 'o1', progress: 0.4 },
73
+ { id: 't2', kind: 'pick', status: 'created' }
74
+ ]
75
+ })
76
+ assert.deepEqual(byTag(d, 't1').data, {
77
+ entity: 'task',
78
+ kind: 'putaway',
79
+ status: 'in-progress',
80
+ fromNode: 'dock',
81
+ toNode: 'rack-1',
82
+ resourceRef: 'fk-1',
83
+ orderId: 'o1',
84
+ progress: 0.4
85
+ })
86
+ const agg = byTag(d, TASKS_TAG).data
87
+ assert.equal(agg.entity, 'tasks')
88
+ assert.equal(agg.tasks.length, 2)
89
+ assert.equal(agg.tasks[0].id, 't1', '집약에는 식별자가 있다')
90
+ assert.equal(agg.tasks[0].resourceRef, 'fk-1', '집약도 속성을 다 담는다')
91
+ })
92
+
93
+ test('물품: 개별(태그=epc) + 집약으로 나간다 — 수량·소속 팔레트·만료까지', () => {
94
+ const d = buildEntityDeltas({
95
+ items: [{ epc: 'urn:epc:id:sgtin:1.1.1', gtin: 'g1', location: 'rack-1', disposition: 'sellable', parent: 'sscc-1', qty: 40, expiry: 1234 }]
96
+ })
97
+ assert.deepEqual(byTag(d, 'urn:epc:id:sgtin:1.1.1').data, {
98
+ entity: 'item',
99
+ gtin: 'g1',
100
+ location: 'rack-1',
101
+ disposition: 'sellable',
102
+ parent: 'sscc-1',
103
+ qty: 40,
104
+ expiry: 1234
105
+ })
106
+ const agg = byTag(d, ITEMS_TAG).data
107
+ assert.equal(agg.entity, 'items')
108
+ assert.equal(agg.items[0].epc, 'urn:epc:id:sgtin:1.1.1')
109
+ assert.equal(agg.items[0].qty, 40)
110
+ assert.equal(agg.total, 1)
111
+ assert.equal(agg.truncated, undefined, '상한 안이면 truncated 없음')
112
+ })
113
+
114
+ test('물품 집약: 상한을 넘으면 잘린 사실을 밝힌다 — 조용한 절단 금지', () => {
115
+ const items = Array.from({ length: ITEMS_AGGREGATE_LIMIT + 7 }, (_, i) => ({ epc: `e${i}`, location: 'rack-1' }))
116
+ const d = buildEntityDeltas({ items })
117
+ const agg = byTag(d, ITEMS_TAG).data
118
+ assert.equal(agg.items.length, ITEMS_AGGREGATE_LIMIT)
119
+ assert.equal(agg.total, ITEMS_AGGREGATE_LIMIT + 7)
120
+ assert.equal(agg.truncated, 7)
121
+ /* 개별 물품은 상한과 무관하게 전부 태그별로 나간다(시그니처 dedup 이 변한 것만 재방송한다). */
122
+ assert.equal(d.filter(x => x.data?.entity === 'item').length, ITEMS_AGGREGATE_LIMIT + 7)
123
+ })
124
+
125
+ test('오더: 개별 + 집약, 도메인 kind 유지', () => {
35
126
  const d = buildEntityDeltas({
36
127
  orders: [
37
128
  { id: 'o1', kind: 'workorder', status: 'picking', progress: 0.5, held: false },
38
- { id: 'o2', kind: 'workorder', status: 'created', progress: 0, held: true }
129
+ { id: 'o2', kind: 'outbound', status: 'created', progress: 0, held: true }
39
130
  ]
40
131
  })
41
- assert.ok(byTag(d, 'o1'), '개별 오더도 방출')
132
+ assert.equal(byTag(d, 'o1').data.kind, 'workorder')
42
133
  const agg = byTag(d, ORDERS_TAG).data
43
- assert.equal(agg.kind, 'orders')
134
+ assert.equal(agg.entity, 'orders')
44
135
  assert.equal(agg.orders.length, 2)
45
136
  assert.equal(agg.orders[1].held, true, '집약에 held 반영')
46
137
  })
47
138
 
48
- test(' 상태 집약 태그는 항상 방출(빈 배열)', () => {
49
- const agg = byTag(buildEntityDeltas({}), ORDERS_TAG).data
50
- assert.deepEqual(agg.orders, [])
139
+ test('계약에 속성이 생기면 손대지 않아도 도달한다 — 화이트리스트 재발 방지', () => {
140
+ /* 테스트가 실패하면 누군가 필드를 손으로 골라 담기 시작한 것이다. */
141
+ const d = buildEntityDeltas({ locations: [{ id: 'n1', occupancy: 0, temperatureC: -18, hazmatClass: '3' }] })
142
+ const n = byTag(d, 'n1').data
143
+ assert.equal(n.temperatureC, -18, '알지 못하는 새 속성도 통과해야 한다')
144
+ assert.equal(n.hazmatClass, '3')
145
+ })
146
+
147
+ test('undefined 값은 싣지 않는다 — 키 유무가 시그니처를 흔들지 않게', () => {
148
+ const d = buildEntityDeltas({ equipment: [{ id: 'fk-1', kind: 'forklift', location: undefined, status: 'idle' }] })
149
+ assert.equal('location' in byTag(d, 'fk-1').data, false)
150
+ })
151
+
152
+ test('빈 상태 — 집약 태그 셋은 항상 방출(빈 배열)', () => {
153
+ const d = buildEntityDeltas({})
154
+ assert.deepEqual(byTag(d, ORDERS_TAG).data.orders, [])
155
+ assert.deepEqual(byTag(d, TASKS_TAG).data.tasks, [])
156
+ assert.deepEqual(byTag(d, ITEMS_TAG).data.items, [])
157
+ assert.equal(byTag(d, ITEMS_TAG).data.total, 0)
158
+ })
159
+
160
+ /* ── 사람 ────────────────────────────────────────────────────────────────
161
+ *
162
+ * 인원은 설비와 별개 엔티티다(고장·설비효율이 아니라 등급·교대로 산다). 같은 통과 규칙을 쓰되
163
+ * 자기 태그로 나가고, 집약 태그도 따로 둔다 — 인원을 한눈에 보는 소비처가 있기 때문이다.
164
+ */
165
+ test('사람이 개별 태그로 나간다 — 등급·교대가 그대로', () => {
166
+ const deltas = buildEntityDeltas({
167
+ persons: [{ id: 'w1', personnelClass: 'welder-op', status: 'busy', taskId: 't1', offShift: undefined }]
168
+ })
169
+ const one = deltas.find(d => d.tag === 'w1')!
170
+ assert.equal(one.data.entity, 'person')
171
+ assert.equal(one.data.personnelClass, 'welder-op')
172
+ assert.equal(one.data.status, 'busy')
173
+ assert.equal(one.data.taskId, 't1')
174
+ })
175
+
176
+ test('사람 집약 태그가 항상 나간다 — 선언하지 않은 트윈은 빈 배열(없음과 모름을 구별)', () => {
177
+ const withNone = buildEntityDeltas({})
178
+ const agg = withNone.find(d => d.tag === PERSONS_TAG)!
179
+ assert.deepEqual(agg.data.persons, [])
180
+ const withTwo = buildEntityDeltas({ persons: [{ id: 'w1' }, { id: 'w2' }] })
181
+ assert.equal(withTwo.find(d => d.tag === PERSONS_TAG)!.data.persons.length, 2)
51
182
  })
@@ -12,11 +12,11 @@ import { buildEntityDeltas } from '../server/engine/entity-delta.ts'
12
12
 
13
13
  const T0 = '2026-07-19T00:00:00.000Z'
14
14
  const board = (nNodes: number) => ({
15
- nodes: Array.from({ length: nNodes }, (_, i) => ({ id: `bin-${i}`, type: 'storage', capacity: 100000 })),
16
- movers: []
15
+ locations: Array.from({ length: nNodes }, (_, i) => ({ id: `bin-${i}`, type: 'storage', capacity: 100000 })),
16
+ equipment: []
17
17
  })
18
- function envelope(epc: string, node: string): any {
19
- const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [epc], readPoint: node })
18
+ function envelope(epc: string, location: string): any {
19
+ const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [epc], readPoint: location })
20
20
  return { eventId: 'x', eventType: `epcis.${ev.type}`, eventTime: T0, tenantId: 't', data: ev }
21
21
  }
22
22
 
@@ -52,18 +52,18 @@ test('bench: buildEntityDeltas 비용 vs 엔티티 수 (전 엔티티 매번 방
52
52
  const t0 = performance.now()
53
53
  for (let k = 0; k < iters; k++) buildEntityDeltas(snap)
54
54
  const per = (performance.now() - t0) / iters
55
- console.log(` [buildEntityDeltas] @ ${N.toLocaleString()} nodes: ${per.toFixed(3)}ms/call`)
55
+ console.log(` [buildEntityDeltas] @ ${N.toLocaleString()} locations: ${per.toFixed(3)}ms/call`)
56
56
  }
57
57
  })
58
58
 
59
59
  test('bench: 방송 1회 총비용(snapshot + buildEntityDeltas) vs 상태 규모', () => {
60
- for (const [nodes, items] of [[200, 5000], [1000, 50000], [2000, 100000]]) {
61
- const proj = new StateProjector(board(nodes))
62
- for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i % nodes}`))
60
+ for (const [locations, items] of [[200, 5000], [1000, 50000], [2000, 100000]]) {
61
+ const proj = new StateProjector(board(locations))
62
+ for (let i = 0; i < items; i++) proj.apply(envelope(`urn:epc:id:sscc:0614141.${i}`, `bin-${i % locations}`))
63
63
  const iters = 100
64
64
  const t0 = performance.now()
65
65
  for (let k = 0; k < iters; k++) buildEntityDeltas(proj.snapshot())
66
66
  const per = (performance.now() - t0) / iters
67
- console.log(` [방송1회] ${nodes.toLocaleString()} nodes / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 방송/s`)
67
+ console.log(` [방송1회] ${locations.toLocaleString()} locations / ${items.toLocaleString()} items: ${per.toFixed(3)}ms → 최대 ${Math.round(1000 / per).toLocaleString()} 방송/s`)
68
68
  }
69
69
  })
@@ -11,6 +11,7 @@ import { test } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
 
13
13
  import { crossGroups, deltaP50, foldKpi, foldKpiBuckets, shiftWindow, type KpiEvent } from '../server/engine/kpi-fold.ts'
14
+ import { judgeTargets, judgeGroupTargets, mergeTargets, scopedTargets } from '../server/engine/kpi-target.ts'
14
15
 
15
16
  const T0 = Date.parse('2026-07-30T00:00:00Z')
16
17
  const at = (sec: number) => new Date(T0 + sec * 1000).toISOString()
@@ -154,6 +155,28 @@ test('payload 가 envelope 없이 델타만 와도 읽는다 — 저장 형태
154
155
  assert.equal(r.workTime.avgMs, 20000)
155
156
  })
156
157
 
158
+ test('완료가 재전송되면 한 건으로 센다 — 처리량·자원 점유가 부풀려지지 않게', () => {
159
+ /* 공정 타임라인과 KPI 가 같은 저널을 다르게 짝지어 다른 답을 냈다(타임라인 1건 / KPI 2건).
160
+ * 두 구현이 있었기 때문이고, 규칙은 하나여야 한다: **완료는 작업당 하나, 나중 것이 사실.** */
161
+ const r = foldKpi(
162
+ [
163
+ task('t1', 'created', 0),
164
+ task('t1', 'in-progress', 10, 'fk-1'),
165
+ task('t1', 'completed', 40, 'fk-1'),
166
+ task('t1', 'completed', 45, 'fk-1') // 재전송
167
+ ],
168
+ WINDOW
169
+ )
170
+ assert.equal(r.throughput.tasks, 1, '한 건이다')
171
+ assert.equal(r.workTime.count, 1)
172
+ assert.equal(r.workTime.avgMs, 35000, '나중 완료(45s) 기준')
173
+ assert.deepEqual(
174
+ r.utilization.map(u => [u.resourceId, u.busyMs]),
175
+ [['fk-1', 35000]],
176
+ '점유도 한 번만 더한다(예전엔 65초로 부풀려졌다)'
177
+ )
178
+ })
179
+
157
180
  test('같은 전이가 두 번 와도 착수는 처음 것, 생성은 마지막 것 — 재시도에 흔들리지 않게', () => {
158
181
  const r = foldKpi(
159
182
  [
@@ -264,7 +287,7 @@ test('축의 합이 전체 처리량과 같다 — 어느 축에서도 건이
264
287
  ...three('c', 300, { kind: 'pick', orderId: 'o2', toNode: 'rack-1' })
265
288
  ]
266
289
  const whole = foldKpi(events, WINDOW)
267
- for (const by of ['resource', 'taskKind', 'node', 'order'] as const) {
290
+ for (const by of ['resource', 'taskKind', 'location', 'order'] as const) {
268
291
  const r = foldKpi(events, WINDOW, { groupBy: by })
269
292
  const sum = r.groups!.items.reduce((a, g) => a + g.tasks, 0)
270
293
  assert.equal(sum, whole.throughput.tasks, `${by} 축의 합`)
@@ -296,7 +319,7 @@ test('노드 축은 가장 최근 도착지 — 진행에 따라 바뀌는 값
296
319
  taskAt('a', 'in-progress', 90, { fromNode: 'dock-1', toNode: 'rack-7' }),
297
320
  taskAt('a', 'completed', 100, { fromNode: 'dock-1', toNode: 'rack-7' })
298
321
  ]
299
- const r = foldKpi(events, WINDOW, { groupBy: 'node' })
322
+ const r = foldKpi(events, WINDOW, { groupBy: 'location' })
300
323
  assert.deepEqual(r.groups?.items.map(g => g.key), ['rack-7'])
301
324
  })
302
325
 
@@ -305,7 +328,7 @@ test('구역 축은 노드→구역 지도를 받아야 낸다 — 지도가 없
305
328
  const blind = foldKpi(events, WINDOW, { groupBy: 'area' })
306
329
  assert.deepEqual(blind.groups?.items.map(g => [g.key, g.tasks]), [['unknown', 2]], '지도 없으면 unknown 하나')
307
330
 
308
- const mapped = foldKpi(events, WINDOW, { groupBy: 'area', nodeArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } })
331
+ const mapped = foldKpi(events, WINDOW, { groupBy: 'area', locationArea: { 'rack-1': 'zone-a', 'rack-2': 'zone-a' } })
309
332
  assert.deepEqual(mapped.groups?.items.map(g => [g.key, g.tasks]), [['zone-a', 2]], '두 노드가 한 구역으로 모인다')
310
333
  })
311
334
 
@@ -445,3 +468,231 @@ test('교차: 비교 구간이 비면 전부 isNew 로 — 없던 것과 줄어
445
468
  assert.equal(c.items.every(x => x.isNew === true), true)
446
469
  assert.deepEqual(c.disappeared, [])
447
470
  })
471
+
472
+ /* ── 교대 축 ─────────────────────────────────────────────────────────────────
473
+ *
474
+ * 3교대처럼 하루를 끊김 없이 덮는 현장에서는 캘린더가 가용 시간을 줄이지 않는다. 그러면 교대 선언이
475
+ * 무엇을 주는가: **"언제" 를 묻는 축**이다. "어느 교대가 느린가" 는 3교대 공장에서 가장 먼저 나오는
476
+ * 질문인데, 그 축이 없으면 답이 없었다.
477
+ *
478
+ * **저널에는 교대가 없다 — 계산한다.** 이벤트에 교대 이름을 찍어 두면 나중에 교대 시간을 고쳤을 때
479
+ * **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 완료 시각 + 현장의 시각 기준 + 교대 선언에서 낸다.
480
+ */
481
+ const SHIFTS_3 = [
482
+ { id: 'A', fromTime: '06:00', toTime: '14:00' },
483
+ { id: 'B', fromTime: '14:00', toTime: '22:00' },
484
+ { id: 'C', fromTime: '22:00', toTime: '06:00', toDayOffset: 1 as const }
485
+ ]
486
+
487
+ /** 하루치 창 — 세 교대가 모두 들어오게. */
488
+ const DAY = { fromMs: T0, toMs: T0 + 24 * 3600 * 1000 }
489
+
490
+ /** `hour` 시(UTC)에 완료되는 작업 한 건. */
491
+ const doneAtHour = (id: string, hour: number): KpiEvent[] => [
492
+ task(id, 'created', hour * 3600 - 60),
493
+ task(id, 'in-progress', hour * 3600 - 30),
494
+ task(id, 'completed', hour * 3600)
495
+ ]
496
+
497
+ test('교대별로 쪼갠다 — 완료 시각에서 계산한다(저널에 없는 축)', () => {
498
+ const r = foldKpi(
499
+ [...doneAtHour('a1', 7), ...doneAtHour('a2', 9), ...doneAtHour('b1', 15), ...doneAtHour('c1', 23)],
500
+ DAY,
501
+ { groupBy: 'shift', shifts: SHIFTS_3 }
502
+ )
503
+ const by = Object.fromEntries((r.groups?.items ?? []).map(g => [g.key, g.tasks]))
504
+ assert.deepEqual(by, { A: 2, B: 1, C: 1 })
505
+ assert.equal(r.groups?.items.reduce((s, g) => s + g.tasks, 0), r.throughput.tasks, '축의 합이 전체와 같다')
506
+ })
507
+
508
+ test('현장의 시각 기준으로 읽는다 — UTC 로 조용히 가정하지 않는다', () => {
509
+ const events = [...doneAtHour('t1', 20)] // 절대 20시
510
+ const utc = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 })
511
+ const local = foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3, utcOffsetMinutes: -420 })
512
+ assert.equal(utc.groups?.items[0].key, 'B', 'UTC 20시는 B 교대')
513
+ assert.equal(local.groups?.items[0].key, 'A', 'Rosarito(UTC−7) 기준으로는 13시 — A 교대다')
514
+ })
515
+
516
+ test('교대를 선언하지 않은 현장은 unknown — 없는 구분을 지어내지 않는다', () => {
517
+ const r = foldKpi([...doneAtHour('t1', 7)], DAY, { groupBy: 'shift' })
518
+ assert.deepEqual((r.groups?.items ?? []).map(g => g.key), ['unknown'])
519
+ })
520
+
521
+ test('교대 정의를 고치면 과거가 다시 세어진다 — 저널에 찍어 뒀다면 굳었을 것', () => {
522
+ const events = [...doneAtHour('t1', 7)]
523
+ const twoShift = [
524
+ { id: 'DAY', fromTime: '07:00', toTime: '19:00' },
525
+ { id: 'NIGHT', fromTime: '19:00', toTime: '07:00', toDayOffset: 1 as const }
526
+ ]
527
+ assert.equal(foldKpi(events, DAY, { groupBy: 'shift', shifts: SHIFTS_3 }).groups?.items[0].key, 'A')
528
+ assert.equal(
529
+ foldKpi(events, DAY, { groupBy: 'shift', shifts: twoShift }).groups?.items[0].key,
530
+ 'DAY',
531
+ '같은 저널, 다른 교대 정의 → 다시 센다'
532
+ )
533
+ })
534
+
535
+ /* ── 목표 대비 판정 ───────────────────────────────────────────────────────────
536
+ *
537
+ * 화면과 AI 는 "처리량 345건" 을 정확히 말하지만 **그것이 좋은지 나쁜지는 말할 수 없었다.** 근거가 앞
538
+ * 구간 비교뿐이라 "어제보다 나아졌다" 가 한계이고, 어제도 나빴으면 쓸모가 없다.
539
+ *
540
+ * 판정은 **사실 계산과 섞지 않는다** — 폴드는 목표를 모르고, 판정은 순수 함수로 따로 있다.
541
+ */
542
+ const kpiOf = (events: KpiEvent[]) => foldKpi(events, DAY)
543
+
544
+ test('방향은 지표의 성질이다 — 사용자가 반대로 적을 수 없다', () => {
545
+ const kpi = kpiOf([...doneAtHour('t1', 7), ...doneAtHour('t2', 8)])
546
+ const [more] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 2 }])
547
+ assert.equal(more.direction, 'higher')
548
+ assert.equal(more.met, true, '처리량은 목표 이상이면 만족')
549
+
550
+ const [faster] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 60_000 }])
551
+ assert.equal(faster.direction, 'lower', '시간은 낮을수록 좋다 — 입력이 아니라 지표가 안다')
552
+ assert.equal(faster.met, true, '실제 30초 ≤ 목표 60초')
553
+ })
554
+
555
+ test('미달을 미달이라 말한다', () => {
556
+ const kpi = kpiOf([...doneAtHour('t1', 7)])
557
+ const [v] = judgeTargets(kpi, [{ metric: 'throughput.tasks', target: 5 }])
558
+ assert.equal(v.met, false)
559
+ assert.equal(v.actual, 1)
560
+ assert.equal(v.ratio, 0.2, '비율은 항상 "1 이상이면 좋다" 로 정규화된다')
561
+ })
562
+
563
+ test('비율은 방향이 달라도 같은 뜻이다 — 한 화면에서 비교할 수 있게', () => {
564
+ const kpi = kpiOf([...doneAtHour('t1', 7)])
565
+ const [slow] = judgeTargets(kpi, [{ metric: 'workTime.p50Ms', target: 15_000 }]) // 실제 30초, 목표 15초
566
+ assert.equal(slow.met, false)
567
+ assert.equal(slow.ratio, 0.5, '두 배 느리면 0.5 — 처리량이 절반일 때와 같은 숫자')
568
+ })
569
+
570
+ test('목표가 없으면 판정하지 않는다', () => {
571
+ assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), []), [])
572
+ assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), undefined), [])
573
+ })
574
+
575
+ test('값이 없는 지표는 판정하지 않는다 — 0 으로 놓으면 반대로 거짓말한다', () => {
576
+ const empty = foldKpi([], DAY)
577
+ /* 이 테스트가 실제로 결함을 잡았다: 분위수는 표본이 없으면 p50Ms: 0 으로 나온다. 그것을 값으로 읽으면
578
+ 시간 지표는 "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 —
579
+ 미달을 놓치는 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). */
580
+ assert.deepEqual(judgeTargets(empty, [{ metric: 'workTime.p50Ms', target: 15_000 }]), [])
581
+ assert.deepEqual(judgeTargets(empty, [{ metric: 'throughput.tasks', target: 10 }]), [], '기록 없음 ≠ 처리량 0')
582
+ })
583
+
584
+ test('모르는 지표 이름은 짐작해 매핑하지 않는다', () => {
585
+ assert.deepEqual(judgeTargets(kpiOf([...doneAtHour('t1', 7)]), [{ metric: 'cycleTime.p50' as never, target: 1 }]), [])
586
+ })
587
+
588
+ /* ── 목표의 범위 (공간 vs 인스턴스) ──────────────────────────────────────────
589
+ *
590
+ * 이 규칙은 원래 DB 접근 뒤에 숨어 있어 **화면을 눌러 봐야만** 확인할 수 있었다. 로그인 뒤 화면을
591
+ * 자동으로 검증할 방법이 없는 프로젝트라 그런 규칙은 조용히 미검증으로 쌓인다 — 그래서 밖으로 뺐다.
592
+ */
593
+ const row = (metric: string, target: number, scope: { spaceId?: string; instanceId?: string }) => ({
594
+ metric,
595
+ target,
596
+ spaceId: scope.spaceId ?? null,
597
+ instanceId: scope.instanceId ?? null
598
+ })
599
+
600
+ test('공간 목표가 그 공간의 모든 트윈에 걸린다', () => {
601
+ const got = mergeTargets([row('throughput.tasks', 100, { spaceId: 'site-a' })], { spaceId: 'site-a', instanceIds: ['t1', 't2'] })
602
+ assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 100 }])
603
+ })
604
+
605
+ test('인스턴스 목표가 공간 목표를 덮는다 — 예외를 표현할 수 있어야 한다', () => {
606
+ const got = mergeTargets(
607
+ [row('throughput.tasks', 100, { spaceId: 'site-a' }), row('throughput.tasks', 40, { instanceId: 't2' })],
608
+ { spaceId: 'site-a', instanceIds: ['t2'] }
609
+ )
610
+ assert.deepEqual(got, [{ metric: 'throughput.tasks', target: 40 }], '"이 라인만 다르다" 를 말할 수 있어야 한다')
611
+ })
612
+
613
+ test('다른 공간·다른 인스턴스의 목표는 새어 들어오지 않는다', () => {
614
+ const got = mergeTargets(
615
+ [row('throughput.tasks', 999, { spaceId: 'site-b' }), row('workTime.p50Ms', 999, { instanceId: 'other' })],
616
+ { spaceId: 'site-a', instanceIds: ['t1'] }
617
+ )
618
+ assert.deepEqual(got, [])
619
+ })
620
+
621
+ test('모르는 지표 이름·숫자 아닌 값은 목표로 세지 않는다', () => {
622
+ const got = mergeTargets(
623
+ [row('cycleTime.p50', 10, { spaceId: 'site-a' }), row('throughput.tasks', Number.NaN, { spaceId: 'site-a' })],
624
+ { spaceId: 'site-a', instanceIds: [] }
625
+ )
626
+ assert.deepEqual(got, [], '평가할 수 없는 목표는 "목표 없음" 으로 읽혀 갭을 감춘다 — 아예 세지 않는다')
627
+ })
628
+
629
+ test('공간을 안 고르고 트윈 하나만 볼 때도 그 인스턴스 목표는 걸린다', () => {
630
+ const got = mergeTargets([row('waitTime.p50Ms', 5000, { instanceId: 't1' })], { instanceIds: ['t1'] })
631
+ assert.deepEqual(got, [{ metric: 'waitTime.p50Ms', target: 5000 }])
632
+ })
633
+
634
+ /* ── 축별 목표 ───────────────────────────────────────────────────────────────
635
+ *
636
+ * 구역·교대마다 목표가 다를 수 있다("이 라인만 40건"). 그런데 축 목표는 **전체 목표를 덮지 않는다** —
637
+ * 다른 질문이기 때문이다: "현장 전체 100건" 과 "용접 구역 30건" 은 동시에 참일 수 있다. 덮게 만들면
638
+ * 축 하나에 목표를 거는 순간 전체 판정이 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다.
639
+ */
640
+ const arow = (metric: string, target: number, extra: Record<string, unknown> = {}) => ({
641
+ metric,
642
+ target,
643
+ spaceId: 'site-a',
644
+ instanceId: null,
645
+ axis: null,
646
+ axisKey: null,
647
+ ...extra
648
+ })
649
+
650
+ test('축 목표와 전체 목표가 함께 산다 — 덮지 않는다', () => {
651
+ const got = scopedTargets(
652
+ [arow('throughput.tasks', 100), arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })],
653
+ { spaceId: 'site-a', instanceIds: [], axis: 'area' }
654
+ )
655
+ assert.deepEqual(got.overall, [{ metric: 'throughput.tasks', target: 100 }], '전체 목표는 그대로 있다')
656
+ assert.deepEqual(got.byAxisKey.weld, [{ metric: 'throughput.tasks', target: 30 }])
657
+ })
658
+
659
+ test('지금 보는 축의 목표만 온다 — 다른 축의 목표가 섞이지 않는다', () => {
660
+ const got = scopedTargets(
661
+ [arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' }), arow('throughput.tasks', 50, { axis: 'shift', axisKey: 'A' })],
662
+ { spaceId: 'site-a', instanceIds: [], axis: 'shift' }
663
+ )
664
+ assert.deepEqual(Object.keys(got.byAxisKey), ['A'])
665
+ })
666
+
667
+ test('축을 안 보고 있으면 축 목표는 오지 않는다 — 전체만', () => {
668
+ const got = scopedTargets([arow('throughput.tasks', 30, { axis: 'area', axisKey: 'weld' })], {
669
+ spaceId: 'site-a',
670
+ instanceIds: []
671
+ })
672
+ assert.deepEqual(got.byAxisKey, {})
673
+ assert.deepEqual(got.overall, [])
674
+ })
675
+
676
+ test('축 항목별로 판정한다 — 값이 없는 지표는 판정하지 않는다', () => {
677
+ const group = {
678
+ key: 'weld',
679
+ tasks: 12,
680
+ leadTime: { count: 12, p50Ms: 90_000 },
681
+ workTime: { count: 12, p50Ms: 30_000 },
682
+ waitTime: { count: 0, p50Ms: 0 }
683
+ }
684
+ const v = judgeGroupTargets(group, [
685
+ { metric: 'throughput.tasks', target: 10 },
686
+ { metric: 'workTime.p50Ms', target: 20_000 },
687
+ { metric: 'waitTime.p50Ms', target: 5_000 },
688
+ { metric: 'utilization.ratio', target: 0.8 }
689
+ ])
690
+ assert.deepEqual(
691
+ v.map(x => [x.metric, x.met]),
692
+ [
693
+ ['throughput.tasks', true],
694
+ ['workTime.p50Ms', false]
695
+ ],
696
+ '대기시간은 표본이 없어 판정 안 함 · 자원 점유는 축 단위로 뜻이 없어 판정 안 함'
697
+ )
698
+ })