@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.
- package/README.md +37 -24
- package/dist-server/engine/entity-delta.d.ts +13 -1
- package/dist-server/engine/entity-delta.js +130 -16
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +4 -0
- package/dist-server/engine/index.js +4 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +20 -3
- package/dist-server/engine/kpi-fold.js +21 -4
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +9 -0
- package/dist-server/engine/kpi-query.js +118 -19
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/kpi-target.d.ts +141 -0
- package/dist-server/engine/kpi-target.js +137 -0
- package/dist-server/engine/kpi-target.js.map +1 -0
- package/dist-server/engine/live-attentions.d.ts +4 -2
- package/dist-server/engine/live-attentions.js +5 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/measured-estimator.d.ts +50 -0
- package/dist-server/engine/measured-estimator.js +78 -0
- package/dist-server/engine/measured-estimator.js.map +1 -0
- package/dist-server/engine/model-basis.d.ts +23 -0
- package/dist-server/engine/model-basis.js +100 -0
- package/dist-server/engine/model-basis.js.map +1 -0
- package/dist-server/engine/oee-accumulator.d.ts +2 -2
- package/dist-server/engine/oee-accumulator.js +4 -4
- package/dist-server/engine/oee-accumulator.js.map +1 -1
- package/dist-server/engine/spec-coverage.d.ts +49 -0
- package/dist-server/engine/spec-coverage.js +60 -0
- package/dist-server/engine/spec-coverage.js.map +1 -0
- package/dist-server/engine/structure-diff.d.ts +25 -0
- package/dist-server/engine/structure-diff.js +63 -0
- package/dist-server/engine/structure-diff.js.map +1 -0
- package/dist-server/engine/travel-estimator.d.ts +57 -0
- package/dist-server/engine/travel-estimator.js +120 -0
- package/dist-server/engine/travel-estimator.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +153 -17
- package/dist-server/engine/twin-engine.js +473 -81
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +5 -5
- package/dist-server/engine/warm-start.js +4 -4
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/service/index.d.ts +4 -2
- package/dist-server/service/index.js +21 -14
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/reference-live.js +2 -1
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +307 -1
- package/dist-server/service/reference/reference-master.js +96 -8
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.js +3 -3
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/template-registry.d.ts +1 -1
- package/dist-server/service/reference/template-registry.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
- package/dist-server/service/twin-event/twin-event-keys.js +3 -3
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +11 -0
- package/dist-server/service/twin-event/twin-event.js +6 -1
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +1 -1
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +9 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +54 -3
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-area.js +1 -1
- package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-space/twin-space.d.ts +11 -0
- package/dist-server/service/twin-space/twin-space.js +5 -0
- package/dist-server/service/twin-space/twin-space.js.map +1 -1
- package/dist-server/service/twin-structure/index.d.ts +2 -0
- package/dist-server/service/twin-structure/index.js +6 -0
- package/dist-server/service/twin-structure/index.js.map +1 -0
- package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
- package/dist-server/service/twin-structure/twin-structure.js +97 -0
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
- package/dist-server/service/twin-target/index.d.ts +4 -0
- package/dist-server/service/twin-target/index.js +8 -0
- package/dist-server/service/twin-target/index.js.map +1 -0
- package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
- package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
- package/dist-server/service/twin-target/twin-target.d.ts +25 -0
- package/dist-server/service/twin-target/twin-target.js +112 -0
- package/dist-server/service/twin-target/twin-target.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/entity-delta.ts +135 -15
- package/server/engine/index.ts +4 -0
- package/server/engine/kpi-fold.ts +40 -8
- package/server/engine/kpi-query.ts +129 -19
- package/server/engine/kpi-target.ts +226 -0
- package/server/engine/live-attentions.ts +12 -2
- package/server/engine/measured-estimator.ts +91 -0
- package/server/engine/model-basis.ts +94 -0
- package/server/engine/oee-accumulator.ts +5 -5
- package/server/engine/spec-coverage.ts +85 -0
- package/server/engine/structure-diff.ts +88 -0
- package/server/engine/travel-estimator.ts +133 -0
- package/server/engine/twin-engine.ts +498 -84
- package/server/engine/warm-start.ts +8 -8
- package/server/service/index.ts +7 -0
- package/server/service/reference/reference-live.ts +2 -1
- package/server/service/reference/reference-master.ts +383 -10
- package/server/service/reference/reference-resolver.ts +3 -3
- package/server/service/reference/template-registry.ts +1 -1
- package/server/service/twin-attention/twin-attention-query.ts +1 -1
- package/server/service/twin-control/twin-control-mutation.ts +1 -1
- package/server/service/twin-event/twin-event-keys.ts +2 -2
- package/server/service/twin-event/twin-event.ts +15 -1
- package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
- package/server/service/twin-instance/twin-instance.ts +1 -1
- package/server/service/twin-journal/twin-journal-query.ts +52 -4
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
- package/server/service/twin-space/twin-space-area.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +3 -3
- package/server/service/twin-space/twin-space.ts +14 -0
- package/server/service/twin-structure/index.ts +3 -0
- package/server/service/twin-structure/twin-structure.ts +93 -0
- package/server/service/twin-target/index.ts +5 -0
- package/server/service/twin-target/twin-target-resolver.ts +124 -0
- package/server/service/twin-target/twin-target.ts +97 -0
- package/test/capability-mapping.test.ts +5 -5
- package/test/duration-estimators.test.ts +144 -0
- package/test/entity-delta.test.ts +150 -19
- package/test/ingest-bench.test.ts +9 -9
- package/test/kpi-fold.test.ts +232 -3
- package/test/live-mirror-parity.test.ts +53 -19
- package/test/master-to-twin.test.ts +87 -13
- package/test/model-basis.test.ts +86 -0
- package/test/oee-accumulator.test.ts +9 -9
- package/test/scale-twin-bench.test.ts +22 -22
- package/test/spec-coverage.test.ts +113 -0
- package/test/streamline-e2e.test.ts +10 -10
- package/test/structure-revision-db.test.ts +310 -0
- package/test/twin-event-keys.test.ts +2 -2
- package/test/vocabulary-guard.test.ts +43 -0
- 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.
|
|
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.
|
|
29
|
-
"@things-factory/auth-base": "^10.0.
|
|
30
|
-
"@things-factory/cache-service": "^10.0.
|
|
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.
|
|
32
|
+
"@things-factory/shell": "^10.0.7"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "fffd94e886b67ed9019993a572a632054e812fa3"
|
|
35
35
|
}
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능).
|
|
3
3
|
* publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
18
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
out.push({ tag:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
46
|
-
data: {
|
|
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
|
}
|
package/server/engine/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
64
|
+
* location 도착 지점별(어디서) order 오더별(무엇을 위해)
|
|
60
65
|
* area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)
|
|
66
|
+
* shift 교대별(언제) — 완료 시각 + 현장의 시각 기준에서 **계산한다**(이벤트에 없다)
|
|
61
67
|
*/
|
|
62
|
-
export type KpiGroupBy = 'resource' | 'taskKind' | '
|
|
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
|
-
|
|
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 '
|
|
151
|
-
return f.
|
|
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.
|
|
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
|
-
* 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `
|
|
163
|
-
* 마스터 계층에서 온
|
|
174
|
+
* 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `locations[].parentId` 가
|
|
175
|
+
* 마스터 계층에서 온 소속이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
|
|
176
|
+
*
|
|
177
|
+
* **한 홉만 보면 안 된다.** `parentId` 는 구역일 수도 있고 중간 단(라인·셀)일 수도 있다. 라인이 끼면
|
|
178
|
+
* 한 홉짜리 코드는 "구역이 LINE-1" 이라고 답하고, 그 id 는 구역 목록에 없으므로 그 아래 스테이션이
|
|
179
|
+
* **집계에서 조용히 빠진다**(에러 없이 틀린 숫자). 그래서 커널 `hierarchyOf` 로 사슬을 끝까지 걷는다 —
|
|
180
|
+
* 걷는 규칙은 커널에 한 벌뿐이고 소비처는 그것만 부른다.
|
|
181
|
+
*/
|
|
182
|
+
/**
|
|
183
|
+
* 교대 축의 재료 — **교대 선언과 현장의 시각 기준.**
|
|
164
184
|
*
|
|
165
|
-
*
|
|
185
|
+
* 저널에는 시각만 있고 교대가 없다(일부러 그렇게 뒀다 — 교대는 파생이라 찍어 두면 나중에 교대 시간을
|
|
186
|
+
* 고쳤을 때 과거가 옛 구분으로 굳는다). 그래서 폴드가 계산할 재료를 여기서 모은다.
|
|
187
|
+
*
|
|
188
|
+
* 선언의 출처는 **보드의 설비**다. 여러 설비가 서로 다른 교대를 가질 수 있지만, 축은 하나여야 하므로
|
|
189
|
+
* **가장 많이 쓰이는 선언 한 벌**을 쓴다 — 3교대 공장은 전 설비가 같은 교대이므로 이것이 곧 정답이고,
|
|
190
|
+
* 자원마다 교대가 다른 현장에서는 이 축이 거칠다는 뜻이다(그 사실을 여기 적어 둔다).
|
|
191
|
+
*
|
|
192
|
+
* 시각 기준은 보드에 실려 있다(`utcOffsetMinutes` — 공간이 갖는 값을 기동 시 호스트가 풀어 넣는다).
|
|
193
|
+
* 없으면 UTC 로 읽고, 그 기본값은 커널 계약에 밝혀져 있다.
|
|
166
194
|
*/
|
|
167
|
-
|
|
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<{
|
|
172
|
-
const
|
|
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
|
-
|
|
179
|
-
|
|
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 {
|
|
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
|
-
/*
|
|
390
|
+
/* 구역·교대 축은 **이벤트 밖의 지식**을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
|
|
309
391
|
const areaMap =
|
|
310
|
-
input.groupBy === 'area' ? await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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.
|
|
515
|
+
Object.keys(areaMap.locationArea).length === 0 &&
|
|
417
516
|
kpi.groups.items.some(g => g.key === 'unknown')
|
|
418
|
-
? { note: '
|
|
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
|
}
|