@things-factory/headless-twin 10.0.3 → 10.0.5

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.
@@ -0,0 +1,149 @@
1
+ import { type CrossedGroup, type KpiGroupBy, type KpiResult } from './kpi-fold.js';
2
+ export interface TwinKpiInput {
3
+ domainId: string;
4
+ /** 한 트윈. `spaceId` 와 둘 중 하나는 있어야 한다. */
5
+ instanceId?: string;
6
+ /**
7
+ * 한 **공간** — 그 공간에서 가동 중인 트윈 전부를 **한 번에 접는다**.
8
+ *
9
+ * 트윈별로 접어 평균을 다시 평균하면 분위수가 거짓이 된다(p90 의 평균은 p90 이 아니다).
10
+ * 그래서 이벤트를 모아 **한 번** 접는다. 사용자는 "부산 창고" 를 보고, 그 안에 트윈이 몇 개든
11
+ * 합쳐서 본다(헤더 트윈 셀렉터를 없앤 결정과 같은 규율).
12
+ */
13
+ spaceId?: string;
14
+ /** 창의 끝(ISO). 미지정이면 **그 트윈의 마지막 기록 시각**. */
15
+ toTime?: string;
16
+ /** 창의 시작(ISO). 미지정이면 끝에서 windowMinutes 만큼 뒤로. */
17
+ fromTime?: string;
18
+ /** 창 길이(분). fromTime 이 있으면 무시. 기본 60, 1~1440. */
19
+ windowMinutes?: number;
20
+ /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60, 0~1440. */
21
+ lookbackMinutes?: number;
22
+ /**
23
+ * 창을 N 등분해 **구간별 값도** 함께 낸다(추세용). 1~48, 기본 0(쪼개지 않음).
24
+ * 조회는 한 번이고 접기만 N 번 반복한다 — 폴드가 순수하므로 값싸다.
25
+ */
26
+ buckets?: number;
27
+ /**
28
+ * 같은 사실을 **다른 관점으로** 쪼갠다: 자원별·작업종류별·도착지점별·오더별·구역별.
29
+ *
30
+ * 고정된 한 렌즈만 낼 수 있으면 "구역별로는 어떤가" 라는 질문에 화면도 AI 도 답할 수 없다.
31
+ * 축은 조회 인자 하나이고, 접기는 순수 함수라 축을 하나 더 보는 값이 거의 없다.
32
+ */
33
+ groupBy?: KpiGroupBy;
34
+ /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 함께 알린다. */
35
+ groupLimit?: number;
36
+ /**
37
+ * **무엇과 비교할 것인가** — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday).
38
+ *
39
+ * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다("29초" 가 개선인지 악화인지). 창만 옮겨 한 번 더
40
+ * 접으면 되므로(폴드는 순수) 값싸다. 비교 구간에 기록이 없으면 **delta 를 만들지 않는다** —
41
+ * 없는 것을 0% 로 그리면 "변화 없음" 이라는 거짓이 된다.
42
+ */
43
+ compareTo?: 'previous' | 'yesterday';
44
+ }
45
+ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
46
+ /** 사람이 읽는 창(ISO) + 기준. 폴드의 raw 창(ms)은 여기에 흡수한다. */
47
+ window: {
48
+ fromTime: string;
49
+ toTime: string;
50
+ minutes: number;
51
+ /** 끝 시각을 어디서 얻었나 — 'given'(호출부 지정) | 'latest-event'(트윈의 마지막 기록). */
52
+ basis: 'given' | 'latest-event';
53
+ };
54
+ /**
55
+ * 조회가 **상한에 걸렸는가**(구간이 너무 커서 일부만 읽었을 수 있다).
56
+ *
57
+ * true 면 이 구간의 숫자는 **하한**이다 — 실제로는 더 있었을 수 있다. 조용히 잘라내면 숫자가 조용히
58
+ * 작아지고 사용자는 그것을 성과 하락으로 읽는다. 구간을 좁혀 다시 물어야 한다.
59
+ */
60
+ eventsCapped?: boolean;
61
+ /**
62
+ * 이 구간에 **측정할 기록이 있었는가**. false 는 "처리량 0" 이 아니라 **"기록이 없다"** 는 뜻이다 —
63
+ * 두 상황은 완전히 다르고, 섞으면 사용자가 잘못된 결론을 낸다.
64
+ */
65
+ measured: boolean;
66
+ note?: string;
67
+ /** 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록을 밝힌다(숫자의 출처를 숨기지 않는다). */
68
+ /**
69
+ * 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록.
70
+ *
71
+ * `realityModes` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) ·
72
+ * sim-world(가상 세계) · sim-experiment(실험 사본). 같은 "345건" 이 현실의 실적일 수도, 시뮬레이션의
73
+ * 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다.
74
+ */
75
+ scope: {
76
+ spaceId?: string;
77
+ instanceIds: string[];
78
+ realityModes: string[];
79
+ };
80
+ /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */
81
+ buckets?: {
82
+ fromTime: string;
83
+ toTime: string;
84
+ tasks: number;
85
+ orders: number;
86
+ workP50Ms: number;
87
+ }[];
88
+ /**
89
+ * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.
90
+ *
91
+ * `measured:false` 면 그 구간에 기록이 없다는 뜻이고 `delta` 는 없다(0 이 아니다).
92
+ * delta 는 **현재 − 비교구간** 이다. 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록 좋고 소요는
93
+ * 작을수록 좋다는 해석은 소비처(화면·AI)의 몫이고, 계산 층이 판단을 섞으면 목표 없이 판정하게 된다.
94
+ */
95
+ comparison?: {
96
+ basis: 'previous' | 'yesterday';
97
+ window: {
98
+ fromTime: string;
99
+ toTime: string;
100
+ };
101
+ measured: boolean;
102
+ throughput?: {
103
+ tasks: number;
104
+ orders: number;
105
+ };
106
+ workTime?: KpiResult['workTime'];
107
+ leadTime?: KpiResult['leadTime'];
108
+ waitTime?: KpiResult['waitTime'];
109
+ delta?: {
110
+ tasks: number;
111
+ orders: number;
112
+ workP50Ms?: number;
113
+ leadP50Ms?: number;
114
+ waitP50Ms?: number;
115
+ };
116
+ };
117
+ /**
118
+ * 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이
119
+ * 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다.
120
+ */
121
+ groups?: {
122
+ by: KpiGroupBy;
123
+ items: (KpiGroupOut & {
124
+ label?: string;
125
+ })[];
126
+ /**
127
+ * 비교 구간에는 일이 있었는데 **지금은 없는** 축. 표에서 그냥 빠지면 멈춘 구역·놀고 있는 자원이
128
+ * 눈에 보이지 않는다 — 운영에서는 이것이 가장 중요한 신호일 수 있다.
129
+ */
130
+ disappeared?: {
131
+ key: string;
132
+ label?: string;
133
+ tasks: number;
134
+ }[];
135
+ /** 상한 때문에 빠진 축의 수와, 그 축들이 안고 있던 건수(둘 다 알려야 꼬리를 놓치지 않는다). */
136
+ truncated?: number;
137
+ truncatedTasks?: number;
138
+ note?: string;
139
+ };
140
+ }
141
+ /** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */
142
+ type KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] & Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>;
143
+ /**
144
+ * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
145
+ *
146
+ * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).
147
+ */
148
+ export declare function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput>;
149
+ export {};
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeTwinKpi = computeTwinKpi;
4
+ /*
5
+ * 업무 KPI 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.
6
+ *
7
+ * 왜 한 곳인가: 처음에 GraphQL 질의와 AI 도구가 각각 같은 조회를 적었다. 그러면 창 규칙이 두 곳에서
8
+ * 갈라지고(한쪽만 고치면 화면과 AI 가 다른 숫자를 말한다) 조회 상한·테넌트 조건도 복제된다.
9
+ * 계산은 `kpi-fold`(순수)가 소유하고, 이 파일은 **무엇을 읽을지**만 정한다.
10
+ *
11
+ * ── 창의 기준 시각은 "트윈의 시계" 다 ──────────────────────────────────────
12
+ * 시뮬 트윈은 자기 클록으로 산다(예: 저널의 eventTime 이 2026-01-01T00:25Z 인데 실제 지금은 7월).
13
+ * 그런 트윈에 "최근 1시간" 을 **실제 시각**으로 물으면 그 구간에는 아무 기록이 없어 항상
14
+ * "측정된 기록 없음" 이 된다 — 폴드는 정상인데 답이 쓸모없다.
15
+ *
16
+ * 그래서 호출부가 끝 시각을 주지 않으면 **그 트윈의 마지막 기록 시각**을 끝으로 잡는다. 커널의 시각
17
+ * 상수를 앱에서 재현하지 않고(무방언), 라이브 트윈에서도 자연스럽다(마지막 기록 ≈ 지금).
18
+ * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.
19
+ */
20
+ const typeorm_1 = require("typeorm");
21
+ const shell_1 = require("@things-factory/shell");
22
+ const twin_event_js_1 = require("../service/twin-event/twin-event.js");
23
+ const twin_instance_js_1 = require("../service/twin-instance/twin-instance.js");
24
+ const twin_area_js_1 = require("../service/twin-space/twin-area.js");
25
+ const twin_space_js_1 = require("../service/twin-space/twin-space.js");
26
+ const twin_engine_js_1 = require("./twin-engine.js");
27
+ const kpi_fold_js_1 = require("./kpi-fold.js");
28
+ /** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */
29
+ const BUSINESS_EVENTS = ['task.status', 'order.status'];
30
+ /** 한 번에 접을 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */
31
+ const MAX_EVENTS = 20000;
32
+ /**
33
+ * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가
34
+ * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
35
+ *
36
+ * 구역 이름은 `TwinArea` 에 따로 있다 — id 만 보여주면 사용자가 무엇인지 모른다.
37
+ */
38
+ async function nodeAreaMap(domainId, instanceIds, spaceId) {
39
+ const nodeArea = {};
40
+ if (instanceIds.length) {
41
+ const rows = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({
42
+ where: { domain: { id: domainId }, instanceId: (0, typeorm_1.In)(instanceIds) }
43
+ });
44
+ for (const row of rows) {
45
+ for (const n of (row.board?.nodes ?? [])) {
46
+ if (n?.id && n?.parentId)
47
+ nodeArea[String(n.id)] = String(n.parentId);
48
+ }
49
+ }
50
+ }
51
+ const labels = {};
52
+ const sid = spaceId;
53
+ if (sid) {
54
+ const space = await (0, shell_1.getRepository)(twin_space_js_1.TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } });
55
+ if (space) {
56
+ const areas = await (0, shell_1.getRepository)(twin_area_js_1.TwinArea).find({
57
+ where: { domain: { id: domainId }, space: { id: space.id } }
58
+ });
59
+ for (const a of areas)
60
+ if (a.areaId && a.name)
61
+ labels[a.areaId] = a.name;
62
+ }
63
+ }
64
+ return { nodeArea, labels };
65
+ }
66
+ /** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */
67
+ async function latestEventTime(domainId, instanceIds) {
68
+ if (instanceIds.length === 0)
69
+ return null;
70
+ const row = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).findOne({
71
+ where: { domain: { id: domainId }, instanceId: (0, typeorm_1.In)(instanceIds), eventType: (0, typeorm_1.In)(BUSINESS_EVENTS) },
72
+ order: { revision: 'DESC' }
73
+ });
74
+ return row?.eventTime ?? null;
75
+ }
76
+ /**
77
+ * 창 구간의 업무 이벤트 — 창보다 `lookbackMs` 만큼 앞까지 읽는다(완료된 작업의 시작이 창 밖일 수 있다).
78
+ *
79
+ * eventTime 은 ISO 문자열 컬럼이라 사전식 비교가 성립한다(같은 파일군의 시각 커서와 동일 전제).
80
+ * 비교 구간도 같은 규칙으로 읽어야 하므로 함수로 둔다 — 두 곳에 적으면 규칙이 갈라진다.
81
+ */
82
+ async function fetchEvents(domainId, instanceIds, fromMs, toMs, lookbackMs) {
83
+ if (instanceIds.length === 0)
84
+ return { events: [], capped: false };
85
+ const rows = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).find({
86
+ where: {
87
+ domain: { id: domainId },
88
+ instanceId: (0, typeorm_1.In)(instanceIds),
89
+ eventType: (0, typeorm_1.In)(BUSINESS_EVENTS),
90
+ eventTime: (0, typeorm_1.Between)(new Date(fromMs - lookbackMs).toISOString(), new Date(toMs).toISOString())
91
+ },
92
+ order: { revision: 'ASC' },
93
+ take: MAX_EVENTS
94
+ });
95
+ /* 상한에 정확히 닿으면 **더 있었을 수 있다** — 그 사실을 돌려준다. 조용히 잘라내면 숫자가 조용히
96
+ * 작아지고, 사용자는 그것을 성과 하락으로 읽는다(가장 나쁜 종류의 거짓이다). */
97
+ return {
98
+ events: rows.map(r => ({ eventType: r.eventType, eventTime: r.eventTime, payload: r.payload })),
99
+ capped: rows.length >= MAX_EVENTS
100
+ };
101
+ }
102
+ /**
103
+ * 집계 대상 트윈 목록.
104
+ *
105
+ * 공간이 주어지면 **가동 중이고 운영 목적인** 것만 모은다 — 벤치 사본은 사용자가 보는 현장이 아니다.
106
+ * 트윈이 지정되면 그것 하나. 둘 다 없으면 무엇을 재야 할지 알 수 없으므로 명시 실패한다.
107
+ */
108
+ async function resolveTargets(input) {
109
+ if (!input.instanceId && !input.spaceId)
110
+ throw new Error('either instanceId or spaceId is required');
111
+ const list = await twin_engine_js_1.TwinEngine.list(input.domainId);
112
+ const rows = input.instanceId
113
+ ? list.filter((x) => x?.instanceId === input.instanceId)
114
+ : list.filter((x) => x?.running && x.spaceId === input.spaceId && x.purpose !== 'bench' && x.instanceId);
115
+ /* 목록에서 못 찾아도(정지·비등록) 지정된 트윈은 그대로 조회한다 — 저널은 남아 있을 수 있다. */
116
+ const instanceIds = rows.length ? rows.map((x) => x.instanceId) : input.instanceId ? [input.instanceId] : [];
117
+ const realityModes = [...new Set(rows.map((x) => String(x.realityMode ?? 'unknown')))];
118
+ return { instanceIds, realityModes };
119
+ }
120
+ /**
121
+ * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
122
+ *
123
+ * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).
124
+ */
125
+ async function computeTwinKpi(input) {
126
+ const minutes = Math.max(1, Math.min(24 * 60, Number(input.windowMinutes) || 60));
127
+ const lookbackMs = Math.max(0, Math.min(24 * 60, input.lookbackMinutes ?? 60)) * 60_000;
128
+ const { instanceIds, realityModes } = await resolveTargets(input);
129
+ const scope = { spaceId: input.spaceId, instanceIds, realityModes };
130
+ /* 끝 시각 — 지정이 없으면 대상들의 마지막 기록. 그것도 없으면 측정할 것이 없다. */
131
+ let basis = 'given';
132
+ let toMs = input.toTime ? Date.parse(input.toTime) : NaN;
133
+ if (!Number.isFinite(toMs)) {
134
+ const latest = await latestEventTime(input.domainId, instanceIds);
135
+ if (!latest) {
136
+ const now = Date.now();
137
+ return {
138
+ window: {
139
+ fromTime: new Date(now - minutes * 60_000).toISOString(),
140
+ toTime: new Date(now).toISOString(),
141
+ minutes,
142
+ basis: 'latest-event'
143
+ },
144
+ measured: false,
145
+ scope,
146
+ note: instanceIds.length === 0
147
+ ? 'no running twin in this space — nothing to measure.'
148
+ : 'no operational events in the journal yet — there is nothing to measure (this is NOT "zero throughput").'
149
+ };
150
+ }
151
+ toMs = Date.parse(latest);
152
+ basis = 'latest-event';
153
+ }
154
+ const fromMs = input.fromTime ? Date.parse(input.fromTime) : toMs - minutes * 60_000;
155
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) {
156
+ throw new Error('invalid window: fromTime must be a valid time before toTime');
157
+ }
158
+ const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs);
159
+ /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
160
+ const areaMap = input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} };
161
+ const kpi = (0, kpi_fold_js_1.foldKpi)(events, { fromMs, toMs }, {
162
+ groupBy: input.groupBy,
163
+ nodeArea: areaMap.nodeArea,
164
+ groupLimit: input.groupLimit
165
+ });
166
+ const window = {
167
+ fromTime: new Date(fromMs).toISOString(),
168
+ toTime: new Date(toMs).toISOString(),
169
+ minutes: Math.round((toMs - fromMs) / 60_000),
170
+ basis
171
+ };
172
+ if (kpi.eventsInWindow === 0) {
173
+ return {
174
+ window,
175
+ measured: false,
176
+ scope,
177
+ note: 'no operational events in this window — nothing to measure (this is NOT "zero throughput").'
178
+ };
179
+ }
180
+ /* 구간별 값 — 쪼개기 규칙은 순수 모듈이 소유한다(테스트 가능). 여기서는 시각을 ISO 로 옮기기만. */
181
+ const rawBuckets = (0, kpi_fold_js_1.foldKpiBuckets)(events, { fromMs, toMs }, Number(input.buckets) || 0);
182
+ const buckets = rawBuckets.length
183
+ ? rawBuckets.map(b => ({
184
+ fromTime: new Date(b.fromMs).toISOString(),
185
+ toTime: new Date(b.toMs).toISOString(),
186
+ tasks: b.tasks,
187
+ orders: b.orders,
188
+ workP50Ms: b.workP50Ms
189
+ }))
190
+ : undefined;
191
+ /* 비교 구간 — **같은 길이·같은 규칙**으로 한 번 더 접는다. 기록이 없으면 delta 를 만들지 않는다
192
+ * (없는 것을 0 으로 그리면 "변화 없음" 이라는 거짓이 된다). */
193
+ let comparison;
194
+ /* 관점 × 비교 교차 — 축별 delta 와 **사라진 축**(전에 있었고 지금 없는 것). */
195
+ let crossed;
196
+ /* 비교 구간 조회도 상한에 걸릴 수 있다 — 그 경우 delta 가 실제보다 작아 보인다. */
197
+ let prevCappedFlag = false;
198
+ if (input.compareTo === 'previous' || input.compareTo === 'yesterday') {
199
+ const w = (0, kpi_fold_js_1.shiftWindow)(fromMs, toMs, input.compareTo);
200
+ const { events: prevEvents, capped: prevCapped } = await fetchEvents(input.domainId, instanceIds, w.fromMs, w.toMs, lookbackMs);
201
+ /* 축이 요청됐으면 비교 구간도 **같은 축**으로 접는다 — 교차("구역별로 어제 대비")를 내기 위해.
202
+ * 상한은 축이 서로 어긋나지 않게 넉넉히 둔다(현재 축이 상한 안에 있는데 비교 축에서 밀려나면
203
+ * 있는데 없다고 말하게 된다). */
204
+ const prev = (0, kpi_fold_js_1.foldKpi)(prevEvents, w, {
205
+ groupBy: input.groupBy,
206
+ nodeArea: areaMap.nodeArea,
207
+ groupLimit: input.groupBy ? 100 : undefined
208
+ });
209
+ prevCappedFlag = prevCapped;
210
+ if (input.groupBy && kpi.groups && prev.groups && prev.eventsInWindow > 0) {
211
+ crossed = (0, kpi_fold_js_1.crossGroups)(kpi.groups.items, prev.groups.items);
212
+ }
213
+ const base = {
214
+ basis: input.compareTo,
215
+ window: { fromTime: new Date(w.fromMs).toISOString(), toTime: new Date(w.toMs).toISOString() }
216
+ };
217
+ comparison =
218
+ prev.eventsInWindow === 0
219
+ ? { ...base, measured: false }
220
+ : {
221
+ ...base,
222
+ measured: true,
223
+ throughput: prev.throughput,
224
+ workTime: prev.workTime,
225
+ leadTime: prev.leadTime,
226
+ waitTime: prev.waitTime,
227
+ delta: {
228
+ tasks: kpi.throughput.tasks - prev.throughput.tasks,
229
+ orders: kpi.throughput.orders - prev.throughput.orders,
230
+ workP50Ms: (0, kpi_fold_js_1.deltaP50)(kpi.workTime, prev.workTime),
231
+ leadP50Ms: (0, kpi_fold_js_1.deltaP50)(kpi.leadTime, prev.leadTime),
232
+ waitP50Ms: (0, kpi_fold_js_1.deltaP50)(kpi.waitTime, prev.waitTime)
233
+ }
234
+ };
235
+ }
236
+ /* 관점 값은 시각 변환이 없어 그대로 통과한다. 붙이는 것은 ① 사람이 읽는 이름과 ② 축이 통째로
237
+ * 비었을 때의 이유다 — "구역별 0건" 과 "노드에 구역이 안 붙어 있다" 는 완전히 다른 상황이다. */
238
+ const groups = kpi.groups
239
+ ? {
240
+ by: kpi.groups.by,
241
+ items: (crossed?.items ?? kpi.groups.items).map(g => ({
242
+ ...g,
243
+ ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})
244
+ })),
245
+ ...(crossed?.disappeared.length
246
+ ? {
247
+ disappeared: crossed.disappeared.map(d => ({
248
+ ...d,
249
+ ...(areaMap.labels[d.key] ? { label: areaMap.labels[d.key] } : {})
250
+ }))
251
+ }
252
+ : {}),
253
+ ...(kpi.groups.truncated
254
+ ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }
255
+ : {}),
256
+ ...(input.groupBy === 'area' &&
257
+ Object.keys(areaMap.nodeArea).length === 0 &&
258
+ kpi.groups.items.some(g => g.key === 'unknown')
259
+ ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
260
+ : {})
261
+ }
262
+ : undefined;
263
+ /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */
264
+ const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi;
265
+ return {
266
+ window,
267
+ measured: true,
268
+ scope,
269
+ ...(capped || prevCappedFlag ? { eventsCapped: true } : {}),
270
+ ...rest,
271
+ ...(buckets ? { buckets } : {}),
272
+ ...(groups ? { groups } : {}),
273
+ ...(comparison ? { comparison } : {})
274
+ };
275
+ }
276
+ //# sourceMappingURL=kpi-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kpi-query.js","sourceRoot":"","sources":["../../server/engine/kpi-query.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 업무 KPI 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.\n *\n * 왜 한 곳인가: 처음에 GraphQL 질의와 AI 도구가 각각 같은 조회를 적었다. 그러면 창 규칙이 두 곳에서\n * 갈라지고(한쪽만 고치면 화면과 AI 가 다른 숫자를 말한다) 조회 상한·테넌트 조건도 복제된다.\n * 계산은 `kpi-fold`(순수)가 소유하고, 이 파일은 **무엇을 읽을지**만 정한다.\n *\n * ── 창의 기준 시각은 \"트윈의 시계\" 다 ──────────────────────────────────────\n * 시뮬 트윈은 자기 클록으로 산다(예: 저널의 eventTime 이 2026-01-01T00:25Z 인데 실제 지금은 7월).\n * 그런 트윈에 \"최근 1시간\" 을 **실제 시각**으로 물으면 그 구간에는 아무 기록이 없어 항상\n * \"측정된 기록 없음\" 이 된다 — 폴드는 정상인데 답이 쓸모없다.\n *\n * 그래서 호출부가 끝 시각을 주지 않으면 **그 트윈의 마지막 기록 시각**을 끝으로 잡는다. 커널의 시각\n * 상수를 앱에서 재현하지 않고(무방언), 라이브 트윈에서도 자연스럽다(마지막 기록 ≈ 지금).\n * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.\n */\nimport { Between, In } from 'typeorm'\n\nimport { getRepository } from '@things-factory/shell'\n\nimport { TwinEvent } from '../service/twin-event/twin-event.js'\nimport { TwinInstance } from '../service/twin-instance/twin-instance.js'\nimport { TwinArea } from '../service/twin-space/twin-area.js'\nimport { TwinSpace } from '../service/twin-space/twin-space.js'\nimport { TwinEngine } from './twin-engine.js'\nimport {\n crossGroups,\n deltaP50,\n foldKpi,\n foldKpiBuckets,\n shiftWindow,\n type CrossedGroup,\n type KpiEvent,\n type KpiGroupBy,\n type KpiResult\n} from './kpi-fold.js'\n\n/** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */\nconst BUSINESS_EVENTS = ['task.status', 'order.status']\n\n/** 한 번에 접을 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */\nconst MAX_EVENTS = 20000\n\nexport interface TwinKpiInput {\n domainId: string\n /** 한 트윈. `spaceId` 와 둘 중 하나는 있어야 한다. */\n instanceId?: string\n /**\n * 한 **공간** — 그 공간에서 가동 중인 트윈 전부를 **한 번에 접는다**.\n *\n * 트윈별로 접어 평균을 다시 평균하면 분위수가 거짓이 된다(p90 의 평균은 p90 이 아니다).\n * 그래서 이벤트를 모아 **한 번** 접는다. 사용자는 \"부산 창고\" 를 보고, 그 안에 트윈이 몇 개든\n * 합쳐서 본다(헤더 트윈 셀렉터를 없앤 결정과 같은 규율).\n */\n spaceId?: string\n /** 창의 끝(ISO). 미지정이면 **그 트윈의 마지막 기록 시각**. */\n toTime?: string\n /** 창의 시작(ISO). 미지정이면 끝에서 windowMinutes 만큼 뒤로. */\n fromTime?: string\n /** 창 길이(분). fromTime 이 있으면 무시. 기본 60, 1~1440. */\n windowMinutes?: number\n /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60, 0~1440. */\n lookbackMinutes?: number\n /**\n * 창을 N 등분해 **구간별 값도** 함께 낸다(추세용). 1~48, 기본 0(쪼개지 않음).\n * 조회는 한 번이고 접기만 N 번 반복한다 — 폴드가 순수하므로 값싸다.\n */\n buckets?: number\n /**\n * 같은 사실을 **다른 관점으로** 쪼갠다: 자원별·작업종류별·도착지점별·오더별·구역별.\n *\n * 고정된 한 렌즈만 낼 수 있으면 \"구역별로는 어떤가\" 라는 질문에 화면도 AI 도 답할 수 없다.\n * 축은 조회 인자 하나이고, 접기는 순수 함수라 축을 하나 더 보는 값이 거의 없다.\n */\n groupBy?: KpiGroupBy\n /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 함께 알린다. */\n groupLimit?: number\n /**\n * **무엇과 비교할 것인가** — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday).\n *\n * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다(\"29초\" 가 개선인지 악화인지). 창만 옮겨 한 번 더\n * 접으면 되므로(폴드는 순수) 값싸다. 비교 구간에 기록이 없으면 **delta 를 만들지 않는다** —\n * 없는 것을 0% 로 그리면 \"변화 없음\" 이라는 거짓이 된다.\n */\n compareTo?: 'previous' | 'yesterday'\n}\n\nexport interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {\n /** 사람이 읽는 창(ISO) + 기준. 폴드의 raw 창(ms)은 여기에 흡수한다. */\n window: {\n fromTime: string\n toTime: string\n minutes: number\n /** 끝 시각을 어디서 얻었나 — 'given'(호출부 지정) | 'latest-event'(트윈의 마지막 기록). */\n basis: 'given' | 'latest-event'\n }\n /**\n * 조회가 **상한에 걸렸는가**(구간이 너무 커서 일부만 읽었을 수 있다).\n *\n * true 면 이 구간의 숫자는 **하한**이다 — 실제로는 더 있었을 수 있다. 조용히 잘라내면 숫자가 조용히\n * 작아지고 사용자는 그것을 성과 하락으로 읽는다. 구간을 좁혀 다시 물어야 한다.\n */\n eventsCapped?: boolean\n /**\n * 이 구간에 **측정할 기록이 있었는가**. false 는 \"처리량 0\" 이 아니라 **\"기록이 없다\"** 는 뜻이다 —\n * 두 상황은 완전히 다르고, 섞으면 사용자가 잘못된 결론을 낸다.\n */\n measured: boolean\n note?: string\n /** 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록을 밝힌다(숫자의 출처를 숨기지 않는다). */\n /**\n * 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록.\n *\n * `realityModes` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) ·\n * sim-world(가상 세계) · sim-experiment(실험 사본). 같은 \"345건\" 이 현실의 실적일 수도, 시뮬레이션의\n * 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다.\n */\n scope: { spaceId?: string; instanceIds: string[]; realityModes: string[] }\n /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */\n buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number }[]\n /**\n * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.\n *\n * `measured:false` 면 그 구간에 기록이 없다는 뜻이고 `delta` 는 없다(0 이 아니다).\n * delta 는 **현재 − 비교구간** 이다. 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록 좋고 소요는\n * 작을수록 좋다는 해석은 소비처(화면·AI)의 몫이고, 계산 층이 판단을 섞으면 목표 없이 판정하게 된다.\n */\n comparison?: {\n basis: 'previous' | 'yesterday'\n window: { fromTime: string; toTime: string }\n measured: boolean\n throughput?: { tasks: number; orders: number }\n workTime?: KpiResult['workTime']\n leadTime?: KpiResult['leadTime']\n waitTime?: KpiResult['waitTime']\n delta?: { tasks: number; orders: number; workP50Ms?: number; leadP50Ms?: number; waitP50Ms?: number }\n }\n /**\n * 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이\n * 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다.\n */\n groups?: {\n by: KpiGroupBy\n items: (KpiGroupOut & { label?: string })[]\n /**\n * 비교 구간에는 일이 있었는데 **지금은 없는** 축. 표에서 그냥 빠지면 멈춘 구역·놀고 있는 자원이\n * 눈에 보이지 않는다 — 운영에서는 이것이 가장 중요한 신호일 수 있다.\n */\n disappeared?: { key: string; label?: string; tasks: number }[]\n /** 상한 때문에 빠진 축의 수와, 그 축들이 안고 있던 건수(둘 다 알려야 꼬리를 놓치지 않는다). */\n truncated?: number\n truncatedTasks?: number\n note?: string\n }\n}\n\n/** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */\ntype KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] &\n Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>\n\n/**\n * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가\n * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.\n *\n * 구역 이름은 `TwinArea` 에 따로 있다 — id 만 보여주면 사용자가 무엇인지 모른다.\n */\nasync function nodeAreaMap(\n domainId: string,\n instanceIds: string[],\n spaceId?: string\n): Promise<{ nodeArea: Record<string, string>; labels: Record<string, string> }> {\n const nodeArea: Record<string, string> = {}\n if (instanceIds.length) {\n const rows = await getRepository(TwinInstance).find({\n where: { domain: { id: domainId }, instanceId: In(instanceIds) }\n })\n for (const row of rows) {\n for (const n of ((row.board as any)?.nodes ?? []) as any[]) {\n if (n?.id && n?.parentId) nodeArea[String(n.id)] = String(n.parentId)\n }\n }\n }\n\n const labels: Record<string, string> = {}\n const sid = spaceId\n if (sid) {\n const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } })\n if (space) {\n const areas = await getRepository(TwinArea).find({\n where: { domain: { id: domainId } as any, space: { id: space.id } as any }\n })\n for (const a of areas) if (a.areaId && a.name) labels[a.areaId] = a.name\n }\n }\n return { nodeArea, labels }\n}\n\n/** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */\nasync function latestEventTime(domainId: string, instanceIds: string[]): Promise<string | null> {\n if (instanceIds.length === 0) return null\n const row = await getRepository(TwinEvent).findOne({\n where: { domain: { id: domainId }, instanceId: In(instanceIds), eventType: In(BUSINESS_EVENTS) },\n order: { revision: 'DESC' }\n })\n return row?.eventTime ?? null\n}\n\n/**\n * 창 구간의 업무 이벤트 — 창보다 `lookbackMs` 만큼 앞까지 읽는다(완료된 작업의 시작이 창 밖일 수 있다).\n *\n * eventTime 은 ISO 문자열 컬럼이라 사전식 비교가 성립한다(같은 파일군의 시각 커서와 동일 전제).\n * 비교 구간도 같은 규칙으로 읽어야 하므로 함수로 둔다 — 두 곳에 적으면 규칙이 갈라진다.\n */\nasync function fetchEvents(domainId: string, instanceIds: string[], fromMs: number, toMs: number, lookbackMs: number) {\n if (instanceIds.length === 0) return { events: [] as KpiEvent[], capped: false }\n const rows = await getRepository(TwinEvent).find({\n where: {\n domain: { id: domainId },\n instanceId: In(instanceIds),\n eventType: In(BUSINESS_EVENTS),\n eventTime: Between(new Date(fromMs - lookbackMs).toISOString(), new Date(toMs).toISOString())\n },\n order: { revision: 'ASC' },\n take: MAX_EVENTS\n })\n /* 상한에 정확히 닿으면 **더 있었을 수 있다** — 그 사실을 돌려준다. 조용히 잘라내면 숫자가 조용히\n * 작아지고, 사용자는 그것을 성과 하락으로 읽는다(가장 나쁜 종류의 거짓이다). */\n return {\n events: rows.map(r => ({ eventType: r.eventType, eventTime: r.eventTime, payload: r.payload })),\n capped: rows.length >= MAX_EVENTS\n }\n}\n\n/**\n * 집계 대상 트윈 목록.\n *\n * 공간이 주어지면 **가동 중이고 운영 목적인** 것만 모은다 — 벤치 사본은 사용자가 보는 현장이 아니다.\n * 트윈이 지정되면 그것 하나. 둘 다 없으면 무엇을 재야 할지 알 수 없으므로 명시 실패한다.\n */\nasync function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: string[]; realityModes: string[] }> {\n if (!input.instanceId && !input.spaceId) throw new Error('either instanceId or spaceId is required')\n const list = await TwinEngine.list(input.domainId)\n const rows = input.instanceId\n ? list.filter((x: any) => x?.instanceId === input.instanceId)\n : list.filter((x: any) => x?.running && x.spaceId === input.spaceId && x.purpose !== 'bench' && x.instanceId)\n /* 목록에서 못 찾아도(정지·비등록) 지정된 트윈은 그대로 조회한다 — 저널은 남아 있을 수 있다. */\n const instanceIds = rows.length ? rows.map((x: any) => x.instanceId as string) : input.instanceId ? [input.instanceId] : []\n const realityModes = [...new Set(rows.map((x: any) => String(x.realityMode ?? 'unknown')))]\n return { instanceIds, realityModes }\n}\n\n/**\n * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.\n *\n * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).\n */\nexport async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput> {\n const minutes = Math.max(1, Math.min(24 * 60, Number(input.windowMinutes) || 60))\n const lookbackMs = Math.max(0, Math.min(24 * 60, input.lookbackMinutes ?? 60)) * 60_000\n\n const { instanceIds, realityModes } = await resolveTargets(input)\n const scope = { spaceId: input.spaceId, instanceIds, realityModes }\n\n /* 끝 시각 — 지정이 없으면 대상들의 마지막 기록. 그것도 없으면 측정할 것이 없다. */\n let basis: 'given' | 'latest-event' = 'given'\n let toMs = input.toTime ? Date.parse(input.toTime) : NaN\n if (!Number.isFinite(toMs)) {\n const latest = await latestEventTime(input.domainId, instanceIds)\n if (!latest) {\n const now = Date.now()\n return {\n window: {\n fromTime: new Date(now - minutes * 60_000).toISOString(),\n toTime: new Date(now).toISOString(),\n minutes,\n basis: 'latest-event'\n },\n measured: false,\n scope,\n note:\n instanceIds.length === 0\n ? 'no running twin in this space — nothing to measure.'\n : 'no operational events in the journal yet — there is nothing to measure (this is NOT \"zero throughput\").'\n }\n }\n toMs = Date.parse(latest)\n basis = 'latest-event'\n }\n\n const fromMs = input.fromTime ? Date.parse(input.fromTime) : toMs - minutes * 60_000\n if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) {\n throw new Error('invalid window: fromTime must be a valid time before toTime')\n }\n\n const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs)\n\n /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */\n const areaMap =\n input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} }\n const kpi = foldKpi(events, { fromMs, toMs }, {\n groupBy: input.groupBy,\n nodeArea: areaMap.nodeArea,\n groupLimit: input.groupLimit\n })\n const window = {\n fromTime: new Date(fromMs).toISOString(),\n toTime: new Date(toMs).toISOString(),\n minutes: Math.round((toMs - fromMs) / 60_000),\n basis\n }\n if (kpi.eventsInWindow === 0) {\n return {\n window,\n measured: false,\n scope,\n note: 'no operational events in this window — nothing to measure (this is NOT \"zero throughput\").'\n }\n }\n\n /* 구간별 값 — 쪼개기 규칙은 순수 모듈이 소유한다(테스트 가능). 여기서는 시각을 ISO 로 옮기기만. */\n const rawBuckets = foldKpiBuckets(events, { fromMs, toMs }, Number(input.buckets) || 0)\n const buckets = rawBuckets.length\n ? rawBuckets.map(b => ({\n fromTime: new Date(b.fromMs).toISOString(),\n toTime: new Date(b.toMs).toISOString(),\n tasks: b.tasks,\n orders: b.orders,\n workP50Ms: b.workP50Ms\n }))\n : undefined\n\n /* 비교 구간 — **같은 길이·같은 규칙**으로 한 번 더 접는다. 기록이 없으면 delta 를 만들지 않는다\n * (없는 것을 0 으로 그리면 \"변화 없음\" 이라는 거짓이 된다). */\n let comparison: TwinKpiOutput['comparison']\n /* 관점 × 비교 교차 — 축별 delta 와 **사라진 축**(전에 있었고 지금 없는 것). */\n let crossed: ReturnType<typeof crossGroups> | undefined\n /* 비교 구간 조회도 상한에 걸릴 수 있다 — 그 경우 delta 가 실제보다 작아 보인다. */\n let prevCappedFlag = false\n if (input.compareTo === 'previous' || input.compareTo === 'yesterday') {\n const w = shiftWindow(fromMs, toMs, input.compareTo)\n const { events: prevEvents, capped: prevCapped } = await fetchEvents(\n input.domainId,\n instanceIds,\n w.fromMs,\n w.toMs,\n lookbackMs\n )\n /* 축이 요청됐으면 비교 구간도 **같은 축**으로 접는다 — 교차(\"구역별로 어제 대비\")를 내기 위해.\n * 상한은 축이 서로 어긋나지 않게 넉넉히 둔다(현재 축이 상한 안에 있는데 비교 축에서 밀려나면\n * 있는데 없다고 말하게 된다). */\n const prev = foldKpi(prevEvents, w, {\n groupBy: input.groupBy,\n nodeArea: areaMap.nodeArea,\n groupLimit: input.groupBy ? 100 : undefined\n })\n prevCappedFlag = prevCapped\n if (input.groupBy && kpi.groups && prev.groups && prev.eventsInWindow > 0) {\n crossed = crossGroups(kpi.groups.items, prev.groups.items)\n }\n const base = {\n basis: input.compareTo,\n window: { fromTime: new Date(w.fromMs).toISOString(), toTime: new Date(w.toMs).toISOString() }\n }\n comparison =\n prev.eventsInWindow === 0\n ? { ...base, measured: false }\n : {\n ...base,\n measured: true,\n throughput: prev.throughput,\n workTime: prev.workTime,\n leadTime: prev.leadTime,\n waitTime: prev.waitTime,\n delta: {\n tasks: kpi.throughput.tasks - prev.throughput.tasks,\n orders: kpi.throughput.orders - prev.throughput.orders,\n workP50Ms: deltaP50(kpi.workTime, prev.workTime),\n leadP50Ms: deltaP50(kpi.leadTime, prev.leadTime),\n waitP50Ms: deltaP50(kpi.waitTime, prev.waitTime)\n }\n }\n }\n\n /* 관점 값은 시각 변환이 없어 그대로 통과한다. 붙이는 것은 ① 사람이 읽는 이름과 ② 축이 통째로\n * 비었을 때의 이유다 — \"구역별 0건\" 과 \"노드에 구역이 안 붙어 있다\" 는 완전히 다른 상황이다. */\n const groups = kpi.groups\n ? {\n by: kpi.groups.by,\n items: (crossed?.items ?? kpi.groups.items).map(g => ({\n ...g,\n ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})\n })),\n ...(crossed?.disappeared.length\n ? {\n disappeared: crossed.disappeared.map(d => ({\n ...d,\n ...(areaMap.labels[d.key] ? { label: areaMap.labels[d.key] } : {})\n }))\n }\n : {}),\n ...(kpi.groups.truncated\n ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }\n : {}),\n ...(input.groupBy === 'area' &&\n Object.keys(areaMap.nodeArea).length === 0 &&\n kpi.groups.items.some(g => g.key === 'unknown')\n ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT \"no work in areas\".' }\n : {})\n }\n : undefined\n\n /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */\n const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi\n return {\n window,\n measured: true,\n scope,\n ...(capped || prevCappedFlag ? { eventsCapped: true } : {}),\n ...rest,\n ...(buckets ? { buckets } : {}),\n ...(groups ? { groups } : {}),\n ...(comparison ? { comparison } : {})\n }\n}\n"]}
@@ -1,6 +1,30 @@
1
1
  import { TwinEvent } from '../twin-event/twin-event.js';
2
2
  export declare class TwinJournalQuery {
3
3
  twinEvents(instanceId: string, fromRevision: number, toRevision: number, eventType: string, limit: number, context: ResolverContext, untilTime?: string): Promise<TwinEvent[]>;
4
+ /**
5
+ * 업무 KPI — 시간창 처리량·소요시간·자원 점유. 저널을 **접어서** 만든다(새 계측을 심지 않는다).
6
+ *
7
+ * `twinMetrics`(초당 이벤트 수 = 인프라 부하)와 **다른 것**이다: 이건 "지난 한 시간에 몇 건 처리했고
8
+ * 한 건에 얼마나 걸렸나" 다. 이름이 비슷해 헷갈리기 쉬우므로 설명에 명시한다.
9
+ *
10
+ * 완료된 작업의 **시작 이벤트가 창 앞에 있을 수 있어** 조회 구간을 창보다 앞으로 넓힌다(lookback).
11
+ * 그래도 못 찾은 것은 결과의 `unpaired` 로 드러난다 — 평균에서 조용히 빠지면 지표가 거짓이 된다.
12
+ */
13
+ twinKpi(context: ResolverContext, spaceId?: string, instanceId?: string,
14
+ /** 창 시작·끝(ISO). 미지정이면 최근 1시간. */
15
+ fromTime?: string, toTime?: string,
16
+ /** 창 길이(분). fromTime 이 있으면 무시. 기본 60. */
17
+ windowMinutes?: number,
18
+ /** 추세를 위해 창을 N 등분(1~48). 미지정이면 쪼개지 않는다. */
19
+ buckets?: number,
20
+ /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60. */
21
+ lookbackMinutes?: number,
22
+ /** 관점 축 — 같은 창을 자원별·작업종류별·도착지점별·오더별·구역별로 쪼갠다. */
23
+ groupBy?: string,
24
+ /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 알린다. */
25
+ groupLimit?: number,
26
+ /** 비교 기준 — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday). */
27
+ compareTo?: string): Promise<any>;
4
28
  twinReplay(instanceId: string, untilRevision: number, untilTime: string, context: ResolverContext): Promise<any>;
5
29
  twinTimeRange(spaceId: string, context: ResolverContext): Promise<{
6
30
  minTime: string | null;
@@ -7,6 +7,7 @@ const type_graphql_1 = require("type-graphql");
7
7
  const shell_1 = require("@things-factory/shell");
8
8
  const twin_event_js_1 = require("../twin-event/twin-event.js");
9
9
  const index_js_1 = require("../../engine/index.js");
10
+ const kpi_query_js_1 = require("../../engine/kpi-query.js");
10
11
  /*
11
12
  * 저널 읽기 채널 — 상향 query.
12
13
  * twinEvents : append-only 이벤트 저널 조회(Ledger/History/Entity360) — instanceId·revision 범위·eventType 필터.
@@ -30,6 +31,39 @@ let TwinJournalQuery = class TwinJournalQuery {
30
31
  /* 최신순(DESC) 기본 — Ledger/recent 뷰. 범위 필터로 시간여행 구간 조회도 가능. */
31
32
  return (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).find({ where, order: { revision: 'DESC' }, take: limit ?? 200 });
32
33
  }
34
+ /**
35
+ * 업무 KPI — 시간창 처리량·소요시간·자원 점유. 저널을 **접어서** 만든다(새 계측을 심지 않는다).
36
+ *
37
+ * `twinMetrics`(초당 이벤트 수 = 인프라 부하)와 **다른 것**이다: 이건 "지난 한 시간에 몇 건 처리했고
38
+ * 한 건에 얼마나 걸렸나" 다. 이름이 비슷해 헷갈리기 쉬우므로 설명에 명시한다.
39
+ *
40
+ * 완료된 작업의 **시작 이벤트가 창 앞에 있을 수 있어** 조회 구간을 창보다 앞으로 넓힌다(lookback).
41
+ * 그래도 못 찾은 것은 결과의 `unpaired` 로 드러난다 — 평균에서 조용히 빠지면 지표가 거짓이 된다.
42
+ */
43
+ async twinKpi(context, spaceId, instanceId, fromTime, toTime, windowMinutes, buckets, lookbackMinutes, groupBy, groupLimit, compareTo) {
44
+ /* 테넌트 격리 — 트윈 지정이면 소속을 확인한다. 공간 지정이면 대상 해소가 도메인 목록에서
45
+ * 이뤄지므로(computeTwinKpi) 남의 트윈이 섞이지 않는다. 조용한 빈 결과가 아니라 명시 실패. */
46
+ if (instanceId && !index_js_1.TwinEngine.owns(context.state.domain.id, instanceId)) {
47
+ throw new Error(`twin instance not found in this tenant: ${instanceId}`);
48
+ }
49
+ if (!instanceId && !spaceId)
50
+ throw new Error('either spaceId or instanceId is required');
51
+ /* 창 해소·조회·폴드는 `computeTwinKpi` 가 소유한다 — 같은 규칙을 AI 도구와 공유해야 한다
52
+ * (두 곳에 적으면 화면과 AI 가 다른 숫자를 말한다). */
53
+ return (0, kpi_query_js_1.computeTwinKpi)({
54
+ domainId: context.state.domain.id,
55
+ instanceId,
56
+ spaceId,
57
+ fromTime,
58
+ toTime,
59
+ windowMinutes,
60
+ buckets,
61
+ lookbackMinutes,
62
+ groupBy: groupBy,
63
+ groupLimit,
64
+ compareTo: compareTo
65
+ });
66
+ }
33
67
  async twinReplay(instanceId, untilRevision, untilTime, context) {
34
68
  return index_js_1.TwinEngine.recover(context.state.domain.id, instanceId, untilRevision ?? undefined, untilTime ?? undefined);
35
69
  }
@@ -53,6 +87,25 @@ tslib_1.__decorate([
53
87
  tslib_1.__metadata("design:paramtypes", [String, Number, Number, String, Number, Object, String]),
54
88
  tslib_1.__metadata("design:returntype", Promise)
55
89
  ], TwinJournalQuery.prototype, "twinEvents", null);
90
+ tslib_1.__decorate([
91
+ (0, type_graphql_1.Query)(returns => shell_1.ScalarObject, {
92
+ description: 'Windowed business KPI folded from the event journal, for one space (every running twin in it, folded together — averaging per-twin percentiles would be wrong) or one twin: throughput (completed tasks / orders), lead / work / wait time distributions (p50·p90), and per-resource busy ratio. Distinct from twinMetrics, which reports infrastructure throughput (events per second). When toTime is omitted the window ends at the twin\'s own last recorded event time, not wall clock — a simulated twin lives on its own clock, so asking for \'the last hour\' in real time would always find nothing. The basis used is reported in window.basis. Pass groupBy (resource | taskKind | node | order | area) to break the same window down by that perspective — per-group numbers use the same rules as the totals, so group counts sum to the total, and groups beyond groupLimit are reported as groups.truncated (how many groups) and groups.truncatedTasks (how much work those groups held) rather than silently dropped. Pass compareTo (previous | yesterday) to fold the same-length window shifted back and get comparison.delta (current minus that window); when the comparison window has no records, comparison.measured is false and no delta is produced rather than reporting zero change. Passing groupBy together with compareTo crosses the two: each group carries prevTasks / deltaTasks / deltaWorkP50Ms, groups absent from the comparison window are flagged isNew (no delta, so growth is not implied), and groups that had work before but none now are returned in groups.disappeared so a stopped area is never silently missing from the table. Area breakdown needs board nodes to carry a parent area; when they do not, groups.note says so instead of implying there was no work. When the journal read hits its row cap, eventsCapped is true and every number is a lower bound — narrow the window and ask again rather than reading the smaller figures as a drop in performance. Completions are counted in the window they complete; starts are looked up before the window, and any completion whose start was not found is reported in `unpaired` rather than silently dropped.'
93
+ }),
94
+ tslib_1.__param(0, (0, type_graphql_1.Ctx)()),
95
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('spaceId', { nullable: true })),
96
+ tslib_1.__param(2, (0, type_graphql_1.Arg)('instanceId', { nullable: true })),
97
+ tslib_1.__param(3, (0, type_graphql_1.Arg)('fromTime', { nullable: true })),
98
+ tslib_1.__param(4, (0, type_graphql_1.Arg)('toTime', { nullable: true })),
99
+ tslib_1.__param(5, (0, type_graphql_1.Arg)('windowMinutes', type => type_graphql_1.Int, { nullable: true })),
100
+ tslib_1.__param(6, (0, type_graphql_1.Arg)('buckets', type => type_graphql_1.Int, { nullable: true })),
101
+ tslib_1.__param(7, (0, type_graphql_1.Arg)('lookbackMinutes', type => type_graphql_1.Int, { nullable: true })),
102
+ tslib_1.__param(8, (0, type_graphql_1.Arg)('groupBy', { nullable: true })),
103
+ tslib_1.__param(9, (0, type_graphql_1.Arg)('groupLimit', type => type_graphql_1.Int, { nullable: true })),
104
+ tslib_1.__param(10, (0, type_graphql_1.Arg)('compareTo', { nullable: true })),
105
+ tslib_1.__metadata("design:type", Function),
106
+ tslib_1.__metadata("design:paramtypes", [Object, String, String, String, String, Number, Number, Number, String, Number, String]),
107
+ tslib_1.__metadata("design:returntype", Promise)
108
+ ], TwinJournalQuery.prototype, "twinKpi", null);
56
109
  tslib_1.__decorate([
57
110
  (0, type_graphql_1.Query)(returns => shell_1.ScalarObject, {
58
111
  nullable: true,