@things-factory/headless-twin 10.0.4 → 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.
- package/dist-server/engine/index.d.ts +2 -0
- package/dist-server/engine/index.js +2 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-fold.d.ts +179 -0
- package/dist-server/engine/kpi-fold.js +295 -0
- package/dist-server/engine/kpi-fold.js.map +1 -0
- package/dist-server/engine/kpi-query.d.ts +149 -0
- package/dist-server/engine/kpi-query.js +276 -0
- package/dist-server/engine/kpi-query.js.map +1 -0
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +24 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +53 -0
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/engine/index.ts +2 -0
- package/server/engine/kpi-fold.ts +446 -0
- package/server/engine/kpi-query.ts +424 -0
- package/server/service/twin-journal/twin-journal-query.ts +58 -0
- package/test/kpi-fold.test.ts +447 -0
|
@@ -3,4 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
4
|
tslib_1.__exportStar(require("./twin-engine.js"), exports);
|
|
5
5
|
tslib_1.__exportStar(require("./canonical-ingest.js"), exports);
|
|
6
|
+
tslib_1.__exportStar(require("./kpi-fold.js"), exports);
|
|
7
|
+
tslib_1.__exportStar(require("./kpi-query.js"), exports);
|
|
6
8
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/engine/index.ts"],"names":[],"mappings":";;;AAAA,2DAAgC;AAChC,gEAAqC","sourcesContent":["export * from './twin-engine.js'\nexport * from './canonical-ingest.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/engine/index.ts"],"names":[],"mappings":";;;AAAA,2DAAgC;AAChC,gEAAqC;AACrC,wDAA6B;AAC7B,yDAA8B","sourcesContent":["export * from './twin-engine.js'\nexport * from './canonical-ingest.js'\nexport * from './kpi-fold.js'\nexport * from './kpi-query.js'\n"]}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
|
|
2
|
+
export interface KpiEvent {
|
|
3
|
+
eventType?: string;
|
|
4
|
+
/** ISO 시각. 파싱 불가하면 그 줄은 버린다(조용히 0 으로 세지 않는다). */
|
|
5
|
+
eventTime?: string;
|
|
6
|
+
payload?: any;
|
|
7
|
+
}
|
|
8
|
+
export interface KpiWindow {
|
|
9
|
+
/** 창의 시작·끝(epoch ms). 완료 시각이 이 안에 든 것만 성과로 센다. */
|
|
10
|
+
fromMs: number;
|
|
11
|
+
toMs: number;
|
|
12
|
+
}
|
|
13
|
+
export interface DurationStats {
|
|
14
|
+
/** 짝이 맞아 계산된 건수. */
|
|
15
|
+
count: number;
|
|
16
|
+
p50Ms: number;
|
|
17
|
+
p90Ms: number;
|
|
18
|
+
avgMs: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 관점 축 — **같은 사실을 다른 각으로** 본다.
|
|
22
|
+
*
|
|
23
|
+
* 고정된 한 렌즈(자원별)만 낼 수 있으면 사용자가 "구역별로는?" 이라고 물어도 답이 없다. 사실 계산이
|
|
24
|
+
* 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다.
|
|
25
|
+
*
|
|
26
|
+
* resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나)
|
|
27
|
+
* node 도착 지점별(어디서) order 오더별(무엇을 위해)
|
|
28
|
+
* area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)
|
|
29
|
+
*/
|
|
30
|
+
export type KpiGroupBy = 'resource' | 'taskKind' | 'node' | 'order' | 'area';
|
|
31
|
+
export interface KpiGroup {
|
|
32
|
+
/** 축의 값(자원 id·작업 종류·노드 id·오더 id·구역 id). 값이 없던 기록은 'unknown'. */
|
|
33
|
+
key: string;
|
|
34
|
+
/** 이 축에서 완료된 건수. */
|
|
35
|
+
tasks: number;
|
|
36
|
+
leadTime: DurationStats;
|
|
37
|
+
workTime: DurationStats;
|
|
38
|
+
waitTime: DurationStats;
|
|
39
|
+
/** 이 축에 붙어 있던 시간의 합(자원 축에서 특히 의미 있다). */
|
|
40
|
+
busyMs: number;
|
|
41
|
+
}
|
|
42
|
+
export interface KpiResult {
|
|
43
|
+
window: {
|
|
44
|
+
fromMs: number;
|
|
45
|
+
toMs: number;
|
|
46
|
+
};
|
|
47
|
+
/** 창 안에 **완료된** 건수 — 성과는 완료 시점으로 센다(시작 시점으로 세면 진행 중인 것이 섞인다). */
|
|
48
|
+
throughput: {
|
|
49
|
+
tasks: number;
|
|
50
|
+
orders: number;
|
|
51
|
+
};
|
|
52
|
+
/** 리드타임 — 생성(created)에서 완료까지. 사용자가 체감하는 소요 시간. */
|
|
53
|
+
leadTime: DurationStats;
|
|
54
|
+
/** 작업시간 — 착수(in-progress)에서 완료까지. 대기를 뺀 실작업. */
|
|
55
|
+
workTime: DurationStats;
|
|
56
|
+
/** 대기시간 — 생성에서 착수까지. 자원이 부족하면 여기가 늘어난다. */
|
|
57
|
+
waitTime: DurationStats;
|
|
58
|
+
/** 자원별 점유 — 작업에 붙어 있던 시간의 합과 창 대비 비율(작업으로 설명되는 부분만). */
|
|
59
|
+
utilization: {
|
|
60
|
+
resourceId: string;
|
|
61
|
+
busyMs: number;
|
|
62
|
+
ratio: number;
|
|
63
|
+
}[];
|
|
64
|
+
/**
|
|
65
|
+
* **셈에서 빠진 것** — 완료는 창 안이지만 시작 이벤트를 못 찾은 건수.
|
|
66
|
+
* 조회 구간이 짧아 시작이 잘렸거나 저널이 유실된 경우다. 이 값을 숨기면 평균이 조용히 거짓이 된다.
|
|
67
|
+
*/
|
|
68
|
+
unpaired: {
|
|
69
|
+
leadTime: number;
|
|
70
|
+
workTime: number;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* 소비한 이벤트 수 — **넘겨받은 배열 전체**(창 앞 lookback 포함). 진단용이다.
|
|
74
|
+
*
|
|
75
|
+
* "측정된 기록이 있는가" 의 판단에는 쓰지 말 것 — 호출부는 창보다 앞까지 읽어 넘기므로(완료된 작업의
|
|
76
|
+
* 시작이 창 밖일 수 있다) 창 안이 비어 있어도 이 값은 0 이 아니다. 그 판단은 `eventsInWindow` 로 한다.
|
|
77
|
+
*/
|
|
78
|
+
eventsSeen: number;
|
|
79
|
+
/**
|
|
80
|
+
* **창 안에** 들어온 이벤트 수 — "이 구간에 측정할 기록이 있었는가" 의 정답.
|
|
81
|
+
*
|
|
82
|
+
* 이것이 0 이면 "처리량 0" 이 아니라 **"기록이 없다"** 다. 두 상황을 섞으면 사용자가 잘못된 결론을
|
|
83
|
+
* 낸다(앞 구간의 기록만 읽어 놓고 "0건 처리" 라고 말하는 일이 실제로 가능했다).
|
|
84
|
+
*/
|
|
85
|
+
eventsInWindow: number;
|
|
86
|
+
/** 요청한 관점으로 쪼갠 값(요청했을 때만). */
|
|
87
|
+
groups?: {
|
|
88
|
+
by: KpiGroupBy;
|
|
89
|
+
items: KpiGroup[];
|
|
90
|
+
truncated?: number;
|
|
91
|
+
truncatedTasks?: number;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** 폴드 옵션 — 관점 축과, 그 축을 만들기 위해 이벤트 밖에서 와야 하는 지도. */
|
|
95
|
+
export interface KpiFoldOptions {
|
|
96
|
+
groupBy?: KpiGroupBy;
|
|
97
|
+
/**
|
|
98
|
+
* 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라
|
|
99
|
+
* 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(조용히 다른 축으로 바꾸지 않는다).
|
|
100
|
+
*/
|
|
101
|
+
nodeArea?: Record<string, string>;
|
|
102
|
+
/**
|
|
103
|
+
* 축의 상한(기본 20). 넘치면 잘라내고 **잘린 축의 수와 그 안의 건수를 함께 알린다**.
|
|
104
|
+
*
|
|
105
|
+
* 축의 수만 알리면 부족하다 — "그 외 89개" 로는 그것이 3건인지 300건인지 모른다. 보여준 축이 전부인
|
|
106
|
+
* 것처럼 읽히면 사용자가 꼬리를 놓친다.
|
|
107
|
+
*/
|
|
108
|
+
groupLimit?: number;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 이벤트를 접어 시간창 KPI 를 만든다.
|
|
112
|
+
*
|
|
113
|
+
* **호출부 규약**: `events` 는 창보다 **넉넉히 앞까지** 담아 넘긴다(예: 창 시작 − 리드타임 상한).
|
|
114
|
+
* 완료된 작업의 시작 이벤트가 창 밖에 있을 수 있기 때문이다. 못 찾은 것은 `unpaired` 로 알린다 —
|
|
115
|
+
* 평균에서 조용히 빠지면 지표가 거짓이 된다.
|
|
116
|
+
*
|
|
117
|
+
* 시간축은 `eventTime`(ISO) 차분이다. sim 클록이 실제 시각과 오프셋이 있어도 **차분은 정확**하다
|
|
118
|
+
* (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
|
|
119
|
+
*/
|
|
120
|
+
export declare function foldKpi(events: KpiEvent[], window: KpiWindow, options?: KpiFoldOptions): KpiResult;
|
|
121
|
+
/**
|
|
122
|
+
* 관점 × 비교 교차 — **"구역별로 어제 대비 어떤가"**.
|
|
123
|
+
*
|
|
124
|
+
* 축(무엇을)과 비교(언제 대비)를 따로 보면 사용자가 머릿속에서 두 표를 대조해야 한다. 운영에서 가장
|
|
125
|
+
* 값 있는 질문은 그 교차다: 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다.
|
|
126
|
+
*
|
|
127
|
+
* 정직성 규율:
|
|
128
|
+
* · 두 구간에 **모두 있는** 축만 delta 를 낸다.
|
|
129
|
+
* · 지금만 있는 축은 `isNew`(이전에 없던 일) — delta 를 건수와 같게 적으면 "늘었다" 로 오독된다.
|
|
130
|
+
* · **사라진 축**(전에 있었고 지금 없는 것)은 따로 돌려준다. 표에서 그냥 빠지면 **멈춘 구역이
|
|
131
|
+
* 눈에 보이지 않는다** — 운영에서는 이것이 가장 중요한 신호일 수 있다.
|
|
132
|
+
*/
|
|
133
|
+
export interface CrossedGroup extends KpiGroup {
|
|
134
|
+
/** 비교 구간의 건수(두 구간에 모두 있을 때만). */
|
|
135
|
+
prevTasks?: number;
|
|
136
|
+
/** 현재 − 비교구간 건수(두 구간에 모두 있을 때만). */
|
|
137
|
+
deltaTasks?: number;
|
|
138
|
+
/** 비교 구간 대비 작업시간 중앙값 차이(둘 다 측정된 때만). */
|
|
139
|
+
deltaWorkP50Ms?: number;
|
|
140
|
+
/** 비교 구간에는 없던 축 — delta 대신 이 표식을 준다. */
|
|
141
|
+
isNew?: boolean;
|
|
142
|
+
}
|
|
143
|
+
export declare function crossGroups(now: KpiGroup[], prev: KpiGroup[]): {
|
|
144
|
+
items: CrossedGroup[];
|
|
145
|
+
disappeared: {
|
|
146
|
+
key: string;
|
|
147
|
+
tasks: number;
|
|
148
|
+
}[];
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* 비교 구간의 창 — 같은 **길이**로 옮긴다.
|
|
152
|
+
*
|
|
153
|
+
* previous: 바로 앞 구간(경계가 맞물리게 — 겹치거나 벌어지면 구간의 합이 어긋난다).
|
|
154
|
+
* yesterday: 정확히 24시간 전 같은 자리. 시뮬 트윈의 클록이 실제 시각과 달라도 **차분은 정확**하다.
|
|
155
|
+
*/
|
|
156
|
+
export declare function shiftWindow(fromMs: number, toMs: number, basis: 'previous' | 'yesterday'): KpiWindow;
|
|
157
|
+
/**
|
|
158
|
+
* 중앙값 차이(현재 − 비교구간) — **둘 다 측정된 것만** 낸다.
|
|
159
|
+
*
|
|
160
|
+
* 한쪽이 결측이면 차이는 **없는 것**이다(0 이 아니다). 0 으로 내면 "변화 없음" 이라는 거짓이 된다.
|
|
161
|
+
* 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록, 소요는 작을수록 좋다는 해석은 소비처의 몫이다.
|
|
162
|
+
*/
|
|
163
|
+
export declare function deltaP50(now: DurationStats, prev: DurationStats): number | undefined;
|
|
164
|
+
/** 구간 하나의 최소 요약 — 추세를 그릴 정보만(전체 KPI 를 구간마다 실어 보내지 않는다). */
|
|
165
|
+
export interface KpiBucket {
|
|
166
|
+
fromMs: number;
|
|
167
|
+
toMs: number;
|
|
168
|
+
tasks: number;
|
|
169
|
+
orders: number;
|
|
170
|
+
/** 그 구간의 작업시간 중앙값 — 추세에서 "느려지고 있는가" 를 보는 값. */
|
|
171
|
+
workP50Ms: number;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 창을 N 등분해 구간별로 접는다 — **같은 이벤트를 창만 바꿔** 다시 접는다(조회 추가 없음).
|
|
175
|
+
*
|
|
176
|
+
* 마지막 구간은 나머지를 흡수한다(부동소수 나눗셈으로 끝이 밀리면 마지막 완료가 빠진다).
|
|
177
|
+
* `count <= 1` 이면 쪼갤 것이 없으므로 빈 배열 — 호출부가 "추세 없음" 으로 다룬다.
|
|
178
|
+
*/
|
|
179
|
+
export declare function foldKpiBuckets(events: KpiEvent[], window: KpiWindow, count: number): KpiBucket[];
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* 업무 KPI 폴드 — **이벤트 저널을 접어** 시간창 지표를 만든다(순수).
|
|
4
|
+
*
|
|
5
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
6
|
+
* 지금 트윈이 답할 수 있는 것은 "이 순간의 값"(점유 92%)과 "인프라 부하"(초당 이벤트 수)뿐이다.
|
|
7
|
+
* 운영이 묻는 것은 다르다 — **지난 한 시간에 몇 건 처리했나, 한 건에 얼마나 걸리나, 자원은 얼마나
|
|
8
|
+
* 일했나.** 그 값이 없으면 AI 는 "점유 92%" 는 말해도 "목표보다 나쁜가" 는 답할 수 없다.
|
|
9
|
+
*
|
|
10
|
+
* ── 원료는 이미 있다 ────────────────────────────────────────────────────────
|
|
11
|
+
* 저널(`TwinEvent`)에 `task.status`·`order.status`·`equipment.status` 전이가 `eventTime`(ISO)과 함께
|
|
12
|
+
* append-only 로 쌓인다. 그래서 새 계측을 심을 필요가 없다 — **접기만 하면 된다.**
|
|
13
|
+
* 같은 모양의 선례가 이 폴더에 있다(`oee-accumulator.ts` — equipment 전이를 접어 OEE 카운터 생산).
|
|
14
|
+
*
|
|
15
|
+
* ── 이 파일이 하지 않는 것 ──────────────────────────────────────────────────
|
|
16
|
+
* · 목표와 비교하지 않는다. "95% 목표 대비" 는 목표를 저장하는 별개 층(경영 KPI)의 몫이고,
|
|
17
|
+
* 이 층은 **사실만** 생산한다. 두 층을 섞으면 목표를 정의하지 않은 현장은 사실조차 못 본다.
|
|
18
|
+
* · 체류시간(dwell)은 아직 내지 않는다. 물건이 노드에 머문 시간은 item 단위 이벤트(epcis.*)를
|
|
19
|
+
* 짝지어야 하고, 그 짝맞춤 규칙이 도메인마다 다르다. **없는 것을 0 으로 꾸미지 않는다.**
|
|
20
|
+
* · DB·커널을 모른다. 입력은 평범한 배열이고 출력은 평범한 객체다(테스트가 값싸다).
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.foldKpi = foldKpi;
|
|
24
|
+
exports.crossGroups = crossGroups;
|
|
25
|
+
exports.shiftWindow = shiftWindow;
|
|
26
|
+
exports.deltaP50 = deltaP50;
|
|
27
|
+
exports.foldKpiBuckets = foldKpiBuckets;
|
|
28
|
+
/**
|
|
29
|
+
* 이 작업에 대해 본 축의 값을 기억한다 — **처음 본 값을 남긴다**.
|
|
30
|
+
*
|
|
31
|
+
* 왜 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.
|
|
32
|
+
* 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.
|
|
33
|
+
*/
|
|
34
|
+
function rememberFacets(store, id, d) {
|
|
35
|
+
const prev = store.get(id) ?? {};
|
|
36
|
+
const node = d.toNode ?? d.fromNode;
|
|
37
|
+
store.set(id, {
|
|
38
|
+
resource: prev.resource ?? (d.resourceRef || undefined),
|
|
39
|
+
taskKind: prev.taskKind ?? (d.kind || undefined),
|
|
40
|
+
order: prev.order ?? (d.orderId || undefined),
|
|
41
|
+
node: node || prev.node
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */
|
|
45
|
+
function facetKey(rec, options) {
|
|
46
|
+
const f = rec.facets;
|
|
47
|
+
switch (options.groupBy) {
|
|
48
|
+
case 'resource':
|
|
49
|
+
return f.resource ?? 'unknown';
|
|
50
|
+
case 'taskKind':
|
|
51
|
+
return f.taskKind ?? 'unknown';
|
|
52
|
+
case 'node':
|
|
53
|
+
return f.node ?? 'unknown';
|
|
54
|
+
case 'order':
|
|
55
|
+
return f.order ?? 'unknown';
|
|
56
|
+
case 'area':
|
|
57
|
+
/* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가
|
|
58
|
+
* 그것을 구역으로 오해한다). */
|
|
59
|
+
return (f.node && options.nodeArea?.[f.node]) ?? 'unknown';
|
|
60
|
+
default:
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 기록을 축으로 묶어 축별 통계를 낸다 — **전체와 같은 규칙**으로(같은 stats 함수, 같은 창).
|
|
66
|
+
*
|
|
67
|
+
* 축의 값이 아주 많을 수 있다(오더 축은 수천 개). 그래서 건수 큰 것부터 상한까지만 돌려주고
|
|
68
|
+
* **잘라낸 수를 함께 알린다** — 조용한 절단은 "이게 전부" 라는 거짓을 만든다.
|
|
69
|
+
*/
|
|
70
|
+
function groupRecords(records, options) {
|
|
71
|
+
const by = options.groupBy;
|
|
72
|
+
const bins = new Map();
|
|
73
|
+
for (const rec of records) {
|
|
74
|
+
const key = facetKey(rec, options);
|
|
75
|
+
const bin = bins.get(key);
|
|
76
|
+
if (bin)
|
|
77
|
+
bin.push(rec);
|
|
78
|
+
else
|
|
79
|
+
bins.set(key, [rec]);
|
|
80
|
+
}
|
|
81
|
+
const all = [...bins.entries()]
|
|
82
|
+
.map(([key, rows]) => ({
|
|
83
|
+
key,
|
|
84
|
+
tasks: rows.length,
|
|
85
|
+
leadTime: stats(rows.map(r => r.leadMs).filter((v) => v !== undefined)),
|
|
86
|
+
workTime: stats(rows.map(r => r.workMs).filter((v) => v !== undefined)),
|
|
87
|
+
waitTime: stats(rows.map(r => r.waitMs).filter((v) => v !== undefined)),
|
|
88
|
+
busyMs: rows.reduce((a, r) => a + (r.workMs ?? 0), 0)
|
|
89
|
+
}))
|
|
90
|
+
.sort((a, b) => b.tasks - a.tasks || (a.key < b.key ? -1 : 1));
|
|
91
|
+
const limit = Math.max(1, Math.min(100, options.groupLimit ?? 20));
|
|
92
|
+
const items = all.slice(0, limit);
|
|
93
|
+
const dropped = all.slice(limit);
|
|
94
|
+
/* 축별로 "창 대비 비율" 은 내지 않는다 — 자원 아닌 축(오더·작업종류)에서 그 비율은 의미가 없고,
|
|
95
|
+
* 의미 없는 숫자를 내면 사용자가 그것으로 판단한다. 자원 점유는 `utilization` 이 이미 낸다. */
|
|
96
|
+
return {
|
|
97
|
+
by,
|
|
98
|
+
items,
|
|
99
|
+
...(dropped.length
|
|
100
|
+
? { truncated: dropped.length, truncatedTasks: dropped.reduce((a, g) => a + g.tasks, 0) }
|
|
101
|
+
: {})
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function ms(value) {
|
|
105
|
+
if (!value)
|
|
106
|
+
return null;
|
|
107
|
+
const t = Date.parse(value);
|
|
108
|
+
return Number.isFinite(t) ? t : null;
|
|
109
|
+
}
|
|
110
|
+
/** 분위수 — 정렬된 값에서 선형 보간 없이 가장 가까운 아래 값(작은 표본에서 과장하지 않는다). */
|
|
111
|
+
function quantile(sorted, q) {
|
|
112
|
+
if (sorted.length === 0)
|
|
113
|
+
return 0;
|
|
114
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(q * sorted.length)));
|
|
115
|
+
return sorted[idx];
|
|
116
|
+
}
|
|
117
|
+
function stats(values) {
|
|
118
|
+
if (values.length === 0)
|
|
119
|
+
return { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 };
|
|
120
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
121
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
122
|
+
return {
|
|
123
|
+
count: sorted.length,
|
|
124
|
+
p50Ms: quantile(sorted, 0.5),
|
|
125
|
+
p90Ms: quantile(sorted, 0.9),
|
|
126
|
+
avgMs: Math.round(sum / sorted.length)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 이벤트를 접어 시간창 KPI 를 만든다.
|
|
131
|
+
*
|
|
132
|
+
* **호출부 규약**: `events` 는 창보다 **넉넉히 앞까지** 담아 넘긴다(예: 창 시작 − 리드타임 상한).
|
|
133
|
+
* 완료된 작업의 시작 이벤트가 창 밖에 있을 수 있기 때문이다. 못 찾은 것은 `unpaired` 로 알린다 —
|
|
134
|
+
* 평균에서 조용히 빠지면 지표가 거짓이 된다.
|
|
135
|
+
*
|
|
136
|
+
* 시간축은 `eventTime`(ISO) 차분이다. sim 클록이 실제 시각과 오프셋이 있어도 **차분은 정확**하다
|
|
137
|
+
* (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
|
|
138
|
+
*/
|
|
139
|
+
function foldKpi(events, window, options = {}) {
|
|
140
|
+
/* 작업별 이정표 시각 — 마지막 값을 남긴다(재시도로 같은 전이가 두 번 오면 나중 것이 사실). */
|
|
141
|
+
const created = new Map();
|
|
142
|
+
const started = new Map();
|
|
143
|
+
/* 축의 값은 **어느 전이에서든** 올 수 있다(완료 이벤트에 kind 가 빠져 있고 생성에만 있는 구현이 있다).
|
|
144
|
+
* 그래서 작업별로 한 번 본 값을 기억한다 — 축이 이벤트 모양에 따라 통째로 비는 것을 막는다. */
|
|
145
|
+
const facets = new Map();
|
|
146
|
+
const completedTasks = [];
|
|
147
|
+
let completedOrders = 0;
|
|
148
|
+
let seen = 0;
|
|
149
|
+
let inWindow = 0;
|
|
150
|
+
for (const e of events) {
|
|
151
|
+
const at = ms(e.eventTime);
|
|
152
|
+
if (at === null)
|
|
153
|
+
continue;
|
|
154
|
+
seen++;
|
|
155
|
+
if (at >= window.fromMs && at <= window.toMs)
|
|
156
|
+
inWindow++;
|
|
157
|
+
const d = e.payload?.data ?? e.payload ?? {};
|
|
158
|
+
if (e.eventType === 'task.status') {
|
|
159
|
+
const id = d.taskId;
|
|
160
|
+
if (!id)
|
|
161
|
+
continue;
|
|
162
|
+
rememberFacets(facets, id, d);
|
|
163
|
+
if (d.status === 'created')
|
|
164
|
+
created.set(id, at);
|
|
165
|
+
else if (d.status === 'in-progress' && !started.has(id))
|
|
166
|
+
started.set(id, at);
|
|
167
|
+
else if (d.status === 'completed')
|
|
168
|
+
completedTasks.push({ id, at, resourceId: d.resourceRef });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (e.eventType === 'order.status') {
|
|
172
|
+
/* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고
|
|
173
|
+
* 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */
|
|
174
|
+
const status = String(d.status ?? '').toLowerCase();
|
|
175
|
+
if (at >= window.fromMs && at <= window.toMs && (status === 'completed' || status === 'fulfilled' || status === 'done')) {
|
|
176
|
+
completedOrders++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const leads = [];
|
|
181
|
+
const works = [];
|
|
182
|
+
const waits = [];
|
|
183
|
+
const busyByResource = new Map();
|
|
184
|
+
/* 창 안에 완료된 건들의 **기록** — 통계를 낸 뒤에도 버리지 않는다. 관점 축은 같은 기록을 다시
|
|
185
|
+
* 묶는 것일 뿐이므로, 기록을 남기면 축을 하나 더 붙이는 값이 거의 0 이 된다. */
|
|
186
|
+
const records = [];
|
|
187
|
+
let tasksInWindow = 0;
|
|
188
|
+
let unpairedLead = 0;
|
|
189
|
+
let unpairedWork = 0;
|
|
190
|
+
for (const t of completedTasks) {
|
|
191
|
+
if (t.at < window.fromMs || t.at > window.toMs)
|
|
192
|
+
continue; // 성과는 완료 시점으로 센다
|
|
193
|
+
tasksInWindow++;
|
|
194
|
+
const c = created.get(t.id);
|
|
195
|
+
const s = started.get(t.id);
|
|
196
|
+
const rec = { taskId: t.id, at: t.at, facets: facets.get(t.id) ?? {} };
|
|
197
|
+
if (t.resourceId)
|
|
198
|
+
rec.facets = { ...rec.facets, resource: t.resourceId };
|
|
199
|
+
if (c !== undefined && t.at >= c)
|
|
200
|
+
leads.push((rec.leadMs = t.at - c));
|
|
201
|
+
else
|
|
202
|
+
unpairedLead++;
|
|
203
|
+
if (s !== undefined && t.at >= s) {
|
|
204
|
+
const busy = t.at - s;
|
|
205
|
+
works.push((rec.workMs = busy));
|
|
206
|
+
if (t.resourceId)
|
|
207
|
+
busyByResource.set(t.resourceId, (busyByResource.get(t.resourceId) ?? 0) + busy);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
unpairedWork++;
|
|
211
|
+
}
|
|
212
|
+
if (c !== undefined && s !== undefined && s >= c)
|
|
213
|
+
waits.push((rec.waitMs = s - c));
|
|
214
|
+
records.push(rec);
|
|
215
|
+
}
|
|
216
|
+
const span = Math.max(1, window.toMs - window.fromMs);
|
|
217
|
+
const utilization = [...busyByResource.entries()]
|
|
218
|
+
.map(([resourceId, busyMs]) => ({ resourceId, busyMs, ratio: Math.min(1, busyMs / span) }))
|
|
219
|
+
.sort((a, b) => b.busyMs - a.busyMs);
|
|
220
|
+
return {
|
|
221
|
+
window: { fromMs: window.fromMs, toMs: window.toMs },
|
|
222
|
+
throughput: { tasks: tasksInWindow, orders: completedOrders },
|
|
223
|
+
leadTime: stats(leads),
|
|
224
|
+
workTime: stats(works),
|
|
225
|
+
waitTime: stats(waits),
|
|
226
|
+
utilization,
|
|
227
|
+
unpaired: { leadTime: unpairedLead, workTime: unpairedWork },
|
|
228
|
+
eventsSeen: seen,
|
|
229
|
+
eventsInWindow: inWindow,
|
|
230
|
+
...(options.groupBy ? { groups: groupRecords(records, options) } : {})
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function crossGroups(now, prev) {
|
|
234
|
+
const before = new Map(prev.map(g => [g.key, g]));
|
|
235
|
+
const items = now.map(g => {
|
|
236
|
+
const p = before.get(g.key);
|
|
237
|
+
if (!p)
|
|
238
|
+
return { ...g, isNew: true };
|
|
239
|
+
return {
|
|
240
|
+
...g,
|
|
241
|
+
prevTasks: p.tasks,
|
|
242
|
+
deltaTasks: g.tasks - p.tasks,
|
|
243
|
+
...(deltaP50(g.workTime, p.workTime) !== undefined ? { deltaWorkP50Ms: deltaP50(g.workTime, p.workTime) } : {})
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
const present = new Set(now.map(g => g.key));
|
|
247
|
+
const disappeared = prev.filter(g => !present.has(g.key)).map(g => ({ key: g.key, tasks: g.tasks }));
|
|
248
|
+
return { items, disappeared };
|
|
249
|
+
}
|
|
250
|
+
/* ── 시간 비교 ──────────────────────────────────────────────────────────────
|
|
251
|
+
* 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다("29초" 가 개선인지 악화인지). 같은 길이의 창을 옮겨
|
|
252
|
+
* 한 번 더 접으면 되므로(폴드는 순수) 값싸다. 창 옮기기와 차이 계산은 계산 규칙이라 여기 있다 —
|
|
253
|
+
* 조회 층에 두면 테스트가 DB 를 끌고 와야 한다.
|
|
254
|
+
*/
|
|
255
|
+
/**
|
|
256
|
+
* 비교 구간의 창 — 같은 **길이**로 옮긴다.
|
|
257
|
+
*
|
|
258
|
+
* previous: 바로 앞 구간(경계가 맞물리게 — 겹치거나 벌어지면 구간의 합이 어긋난다).
|
|
259
|
+
* yesterday: 정확히 24시간 전 같은 자리. 시뮬 트윈의 클록이 실제 시각과 달라도 **차분은 정확**하다.
|
|
260
|
+
*/
|
|
261
|
+
function shiftWindow(fromMs, toMs, basis) {
|
|
262
|
+
const span = toMs - fromMs;
|
|
263
|
+
const back = basis === 'yesterday' ? 24 * 60 * 60_000 : span;
|
|
264
|
+
return { fromMs: fromMs - back, toMs: toMs - back };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 중앙값 차이(현재 − 비교구간) — **둘 다 측정된 것만** 낸다.
|
|
268
|
+
*
|
|
269
|
+
* 한쪽이 결측이면 차이는 **없는 것**이다(0 이 아니다). 0 으로 내면 "변화 없음" 이라는 거짓이 된다.
|
|
270
|
+
* 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록, 소요는 작을수록 좋다는 해석은 소비처의 몫이다.
|
|
271
|
+
*/
|
|
272
|
+
function deltaP50(now, prev) {
|
|
273
|
+
return now.count > 0 && prev.count > 0 ? now.p50Ms - prev.p50Ms : undefined;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* 창을 N 등분해 구간별로 접는다 — **같은 이벤트를 창만 바꿔** 다시 접는다(조회 추가 없음).
|
|
277
|
+
*
|
|
278
|
+
* 마지막 구간은 나머지를 흡수한다(부동소수 나눗셈으로 끝이 밀리면 마지막 완료가 빠진다).
|
|
279
|
+
* `count <= 1` 이면 쪼갤 것이 없으므로 빈 배열 — 호출부가 "추세 없음" 으로 다룬다.
|
|
280
|
+
*/
|
|
281
|
+
function foldKpiBuckets(events, window, count) {
|
|
282
|
+
const n = Math.max(0, Math.min(48, Math.floor(count)));
|
|
283
|
+
if (n <= 1)
|
|
284
|
+
return [];
|
|
285
|
+
const step = (window.toMs - window.fromMs) / n;
|
|
286
|
+
const out = [];
|
|
287
|
+
for (let i = 0; i < n; i++) {
|
|
288
|
+
const fromMs = window.fromMs + step * i;
|
|
289
|
+
const toMs = i === n - 1 ? window.toMs : window.fromMs + step * (i + 1);
|
|
290
|
+
const b = foldKpi(events, { fromMs, toMs });
|
|
291
|
+
out.push({ fromMs, toMs, tasks: b.throughput.tasks, orders: b.throughput.orders, workP50Ms: b.workTime.p50Ms });
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=kpi-fold.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kpi-fold.js","sourceRoot":"","sources":["../../server/engine/kpi-fold.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 업무 KPI 폴드 — **이벤트 저널을 접어** 시간창 지표를 만든다(순수).\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 지금 트윈이 답할 수 있는 것은 \"이 순간의 값\"(점유 92%)과 \"인프라 부하\"(초당 이벤트 수)뿐이다.\n * 운영이 묻는 것은 다르다 — **지난 한 시간에 몇 건 처리했나, 한 건에 얼마나 걸리나, 자원은 얼마나\n * 일했나.** 그 값이 없으면 AI 는 \"점유 92%\" 는 말해도 \"목표보다 나쁜가\" 는 답할 수 없다.\n *\n * ── 원료는 이미 있다 ────────────────────────────────────────────────────────\n * 저널(`TwinEvent`)에 `task.status`·`order.status`·`equipment.status` 전이가 `eventTime`(ISO)과 함께\n * append-only 로 쌓인다. 그래서 새 계측을 심을 필요가 없다 — **접기만 하면 된다.**\n * 같은 모양의 선례가 이 폴더에 있다(`oee-accumulator.ts` — equipment 전이를 접어 OEE 카운터 생산).\n *\n * ── 이 파일이 하지 않는 것 ──────────────────────────────────────────────────\n * · 목표와 비교하지 않는다. \"95% 목표 대비\" 는 목표를 저장하는 별개 층(경영 KPI)의 몫이고,\n * 이 층은 **사실만** 생산한다. 두 층을 섞으면 목표를 정의하지 않은 현장은 사실조차 못 본다.\n * · 체류시간(dwell)은 아직 내지 않는다. 물건이 노드에 머문 시간은 item 단위 이벤트(epcis.*)를\n * 짝지어야 하고, 그 짝맞춤 규칙이 도메인마다 다르다. **없는 것을 0 으로 꾸미지 않는다.**\n * · DB·커널을 모른다. 입력은 평범한 배열이고 출력은 평범한 객체다(테스트가 값싸다).\n */\n\n/** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */\nexport interface KpiEvent {\n eventType?: string\n /** ISO 시각. 파싱 불가하면 그 줄은 버린다(조용히 0 으로 세지 않는다). */\n eventTime?: string\n payload?: any\n}\n\nexport interface KpiWindow {\n /** 창의 시작·끝(epoch ms). 완료 시각이 이 안에 든 것만 성과로 센다. */\n fromMs: number\n toMs: number\n}\n\nexport interface DurationStats {\n /** 짝이 맞아 계산된 건수. */\n count: number\n p50Ms: number\n p90Ms: number\n avgMs: number\n}\n\n/**\n * 관점 축 — **같은 사실을 다른 각으로** 본다.\n *\n * 고정된 한 렌즈(자원별)만 낼 수 있으면 사용자가 \"구역별로는?\" 이라고 물어도 답이 없다. 사실 계산이\n * 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다.\n *\n * resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나)\n * node 도착 지점별(어디서) order 오더별(무엇을 위해)\n * area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)\n */\nexport type KpiGroupBy = 'resource' | 'taskKind' | 'node' | 'order' | 'area'\n\nexport interface KpiGroup {\n /** 축의 값(자원 id·작업 종류·노드 id·오더 id·구역 id). 값이 없던 기록은 'unknown'. */\n key: string\n /** 이 축에서 완료된 건수. */\n tasks: number\n leadTime: DurationStats\n workTime: DurationStats\n waitTime: DurationStats\n /** 이 축에 붙어 있던 시간의 합(자원 축에서 특히 의미 있다). */\n busyMs: number\n}\n\nexport interface KpiResult {\n window: { fromMs: number; toMs: number }\n /** 창 안에 **완료된** 건수 — 성과는 완료 시점으로 센다(시작 시점으로 세면 진행 중인 것이 섞인다). */\n throughput: { tasks: number; orders: number }\n /** 리드타임 — 생성(created)에서 완료까지. 사용자가 체감하는 소요 시간. */\n leadTime: DurationStats\n /** 작업시간 — 착수(in-progress)에서 완료까지. 대기를 뺀 실작업. */\n workTime: DurationStats\n /** 대기시간 — 생성에서 착수까지. 자원이 부족하면 여기가 늘어난다. */\n waitTime: DurationStats\n /** 자원별 점유 — 작업에 붙어 있던 시간의 합과 창 대비 비율(작업으로 설명되는 부분만). */\n utilization: { resourceId: string; busyMs: number; ratio: number }[]\n /**\n * **셈에서 빠진 것** — 완료는 창 안이지만 시작 이벤트를 못 찾은 건수.\n * 조회 구간이 짧아 시작이 잘렸거나 저널이 유실된 경우다. 이 값을 숨기면 평균이 조용히 거짓이 된다.\n */\n unpaired: { leadTime: number; workTime: number }\n /**\n * 소비한 이벤트 수 — **넘겨받은 배열 전체**(창 앞 lookback 포함). 진단용이다.\n *\n * \"측정된 기록이 있는가\" 의 판단에는 쓰지 말 것 — 호출부는 창보다 앞까지 읽어 넘기므로(완료된 작업의\n * 시작이 창 밖일 수 있다) 창 안이 비어 있어도 이 값은 0 이 아니다. 그 판단은 `eventsInWindow` 로 한다.\n */\n eventsSeen: number\n /**\n * **창 안에** 들어온 이벤트 수 — \"이 구간에 측정할 기록이 있었는가\" 의 정답.\n *\n * 이것이 0 이면 \"처리량 0\" 이 아니라 **\"기록이 없다\"** 다. 두 상황을 섞으면 사용자가 잘못된 결론을\n * 낸다(앞 구간의 기록만 읽어 놓고 \"0건 처리\" 라고 말하는 일이 실제로 가능했다).\n */\n eventsInWindow: number\n /** 요청한 관점으로 쪼갠 값(요청했을 때만). */\n groups?: { by: KpiGroupBy; items: KpiGroup[]; truncated?: number; truncatedTasks?: number }\n}\n\n/** 폴드 옵션 — 관점 축과, 그 축을 만들기 위해 이벤트 밖에서 와야 하는 지도. */\nexport interface KpiFoldOptions {\n groupBy?: KpiGroupBy\n /**\n * 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라\n * 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(조용히 다른 축으로 바꾸지 않는다).\n */\n nodeArea?: Record<string, string>\n /**\n * 축의 상한(기본 20). 넘치면 잘라내고 **잘린 축의 수와 그 안의 건수를 함께 알린다**.\n *\n * 축의 수만 알리면 부족하다 — \"그 외 89개\" 로는 그것이 3건인지 300건인지 모른다. 보여준 축이 전부인\n * 것처럼 읽히면 사용자가 꼬리를 놓친다.\n */\n groupLimit?: number\n}\n\n/* ── 관점 축의 재료 ─────────────────────────────────────────────────────────\n * 축의 값은 이벤트 payload 에 이미 들어 있다(`TaskStatusDelta`: kind·orderId·fromNode·toNode·\n * resourceRef). 새 계측을 심을 필요가 없다 — **이미 쌓인 저널을 다르게 묶기만** 한다.\n */\ninterface TaskFacets {\n resource?: string\n taskKind?: string\n /** 작업이 **도착한** 지점. 없으면 출발 지점(둘 다 없으면 축은 'unknown'). */\n node?: string\n order?: string\n}\n\n/** 창 안에 완료된 한 건의 기록 — 통계의 원료이자 관점 축의 원료. */\ninterface CompletionRecord {\n taskId: string\n at: number\n facets: TaskFacets\n leadMs?: number\n workMs?: number\n waitMs?: number\n}\n\n/**\n * 이 작업에 대해 본 축의 값을 기억한다 — **처음 본 값을 남긴다**.\n *\n * 왜 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.\n * 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.\n */\nfunction rememberFacets(store: Map<string, TaskFacets>, id: string, d: any): void {\n const prev = store.get(id) ?? {}\n const node = d.toNode ?? d.fromNode\n store.set(id, {\n resource: prev.resource ?? (d.resourceRef || undefined),\n taskKind: prev.taskKind ?? (d.kind || undefined),\n order: prev.order ?? (d.orderId || undefined),\n node: node || prev.node\n })\n}\n\n/** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */\nfunction facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {\n const f = rec.facets\n switch (options.groupBy) {\n case 'resource':\n return f.resource ?? 'unknown'\n case 'taskKind':\n return f.taskKind ?? 'unknown'\n case 'node':\n return f.node ?? 'unknown'\n case 'order':\n return f.order ?? 'unknown'\n case 'area':\n /* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가\n * 그것을 구역으로 오해한다). */\n return (f.node && options.nodeArea?.[f.node]) ?? 'unknown'\n default:\n return 'unknown'\n }\n}\n\n/**\n * 기록을 축으로 묶어 축별 통계를 낸다 — **전체와 같은 규칙**으로(같은 stats 함수, 같은 창).\n *\n * 축의 값이 아주 많을 수 있다(오더 축은 수천 개). 그래서 건수 큰 것부터 상한까지만 돌려주고\n * **잘라낸 수를 함께 알린다** — 조용한 절단은 \"이게 전부\" 라는 거짓을 만든다.\n */\nfunction groupRecords(\n records: CompletionRecord[],\n options: KpiFoldOptions\n): { by: KpiGroupBy; items: KpiGroup[]; truncated?: number; truncatedTasks?: number } {\n const by = options.groupBy as KpiGroupBy\n const bins = new Map<string, CompletionRecord[]>()\n for (const rec of records) {\n const key = facetKey(rec, options)\n const bin = bins.get(key)\n if (bin) bin.push(rec)\n else bins.set(key, [rec])\n }\n\n const all = [...bins.entries()]\n .map(([key, rows]) => ({\n key,\n tasks: rows.length,\n leadTime: stats(rows.map(r => r.leadMs).filter((v): v is number => v !== undefined)),\n workTime: stats(rows.map(r => r.workMs).filter((v): v is number => v !== undefined)),\n waitTime: stats(rows.map(r => r.waitMs).filter((v): v is number => v !== undefined)),\n busyMs: rows.reduce((a, r) => a + (r.workMs ?? 0), 0)\n }))\n .sort((a, b) => b.tasks - a.tasks || (a.key < b.key ? -1 : 1))\n\n const limit = Math.max(1, Math.min(100, options.groupLimit ?? 20))\n const items = all.slice(0, limit)\n const dropped = all.slice(limit)\n /* 축별로 \"창 대비 비율\" 은 내지 않는다 — 자원 아닌 축(오더·작업종류)에서 그 비율은 의미가 없고,\n * 의미 없는 숫자를 내면 사용자가 그것으로 판단한다. 자원 점유는 `utilization` 이 이미 낸다. */\n return {\n by,\n items,\n ...(dropped.length\n ? { truncated: dropped.length, truncatedTasks: dropped.reduce((a, g) => a + g.tasks, 0) }\n : {})\n }\n}\n\nfunction ms(value: string | undefined): number | null {\n if (!value) return null\n const t = Date.parse(value)\n return Number.isFinite(t) ? t : null\n}\n\n/** 분위수 — 정렬된 값에서 선형 보간 없이 가장 가까운 아래 값(작은 표본에서 과장하지 않는다). */\nfunction quantile(sorted: number[], q: number): number {\n if (sorted.length === 0) return 0\n const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(q * sorted.length)))\n return sorted[idx]\n}\n\nfunction stats(values: number[]): DurationStats {\n if (values.length === 0) return { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }\n const sorted = [...values].sort((a, b) => a - b)\n const sum = sorted.reduce((a, b) => a + b, 0)\n return {\n count: sorted.length,\n p50Ms: quantile(sorted, 0.5),\n p90Ms: quantile(sorted, 0.9),\n avgMs: Math.round(sum / sorted.length)\n }\n}\n\n/**\n * 이벤트를 접어 시간창 KPI 를 만든다.\n *\n * **호출부 규약**: `events` 는 창보다 **넉넉히 앞까지** 담아 넘긴다(예: 창 시작 − 리드타임 상한).\n * 완료된 작업의 시작 이벤트가 창 밖에 있을 수 있기 때문이다. 못 찾은 것은 `unpaired` 로 알린다 —\n * 평균에서 조용히 빠지면 지표가 거짓이 된다.\n *\n * 시간축은 `eventTime`(ISO) 차분이다. sim 클록이 실제 시각과 오프셋이 있어도 **차분은 정확**하다\n * (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.\n */\nexport function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldOptions = {}): KpiResult {\n /* 작업별 이정표 시각 — 마지막 값을 남긴다(재시도로 같은 전이가 두 번 오면 나중 것이 사실). */\n const created = new Map<string, number>()\n const started = new Map<string, number>()\n /* 축의 값은 **어느 전이에서든** 올 수 있다(완료 이벤트에 kind 가 빠져 있고 생성에만 있는 구현이 있다).\n * 그래서 작업별로 한 번 본 값을 기억한다 — 축이 이벤트 모양에 따라 통째로 비는 것을 막는다. */\n const facets = new Map<string, TaskFacets>()\n const completedTasks: { id: string; at: number; resourceId?: string }[] = []\n let completedOrders = 0\n let seen = 0\n let inWindow = 0\n\n for (const e of events) {\n const at = ms(e.eventTime)\n if (at === null) continue\n seen++\n if (at >= window.fromMs && at <= window.toMs) inWindow++\n const d = e.payload?.data ?? e.payload ?? {}\n\n if (e.eventType === 'task.status') {\n const id = d.taskId\n if (!id) continue\n rememberFacets(facets, id, d)\n if (d.status === 'created') created.set(id, at)\n else if (d.status === 'in-progress' && !started.has(id)) started.set(id, at)\n else if (d.status === 'completed') completedTasks.push({ id, at, resourceId: d.resourceRef })\n continue\n }\n if (e.eventType === 'order.status') {\n /* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고\n * 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */\n const status = String(d.status ?? '').toLowerCase()\n if (at >= window.fromMs && at <= window.toMs && (status === 'completed' || status === 'fulfilled' || status === 'done')) {\n completedOrders++\n }\n }\n }\n\n const leads: number[] = []\n const works: number[] = []\n const waits: number[] = []\n const busyByResource = new Map<string, number>()\n /* 창 안에 완료된 건들의 **기록** — 통계를 낸 뒤에도 버리지 않는다. 관점 축은 같은 기록을 다시\n * 묶는 것일 뿐이므로, 기록을 남기면 축을 하나 더 붙이는 값이 거의 0 이 된다. */\n const records: CompletionRecord[] = []\n let tasksInWindow = 0\n let unpairedLead = 0\n let unpairedWork = 0\n\n for (const t of completedTasks) {\n if (t.at < window.fromMs || t.at > window.toMs) continue // 성과는 완료 시점으로 센다\n tasksInWindow++\n const c = created.get(t.id)\n const s = started.get(t.id)\n const rec: CompletionRecord = { taskId: t.id, at: t.at, facets: facets.get(t.id) ?? {} }\n if (t.resourceId) rec.facets = { ...rec.facets, resource: t.resourceId }\n if (c !== undefined && t.at >= c) leads.push((rec.leadMs = t.at - c))\n else unpairedLead++\n if (s !== undefined && t.at >= s) {\n const busy = t.at - s\n works.push((rec.workMs = busy))\n if (t.resourceId) busyByResource.set(t.resourceId, (busyByResource.get(t.resourceId) ?? 0) + busy)\n } else {\n unpairedWork++\n }\n if (c !== undefined && s !== undefined && s >= c) waits.push((rec.waitMs = s - c))\n records.push(rec)\n }\n\n const span = Math.max(1, window.toMs - window.fromMs)\n const utilization = [...busyByResource.entries()]\n .map(([resourceId, busyMs]) => ({ resourceId, busyMs, ratio: Math.min(1, busyMs / span) }))\n .sort((a, b) => b.busyMs - a.busyMs)\n\n return {\n window: { fromMs: window.fromMs, toMs: window.toMs },\n throughput: { tasks: tasksInWindow, orders: completedOrders },\n leadTime: stats(leads),\n workTime: stats(works),\n waitTime: stats(waits),\n utilization,\n unpaired: { leadTime: unpairedLead, workTime: unpairedWork },\n eventsSeen: seen,\n eventsInWindow: inWindow,\n ...(options.groupBy ? { groups: groupRecords(records, options) } : {})\n }\n}\n\n/**\n * 관점 × 비교 교차 — **\"구역별로 어제 대비 어떤가\"**.\n *\n * 축(무엇을)과 비교(언제 대비)를 따로 보면 사용자가 머릿속에서 두 표를 대조해야 한다. 운영에서 가장\n * 값 있는 질문은 그 교차다: 전체는 비슷한데 **한 구역만 나빠진** 경우를 전체 숫자로는 절대 못 본다.\n *\n * 정직성 규율:\n * · 두 구간에 **모두 있는** 축만 delta 를 낸다.\n * · 지금만 있는 축은 `isNew`(이전에 없던 일) — delta 를 건수와 같게 적으면 \"늘었다\" 로 오독된다.\n * · **사라진 축**(전에 있었고 지금 없는 것)은 따로 돌려준다. 표에서 그냥 빠지면 **멈춘 구역이\n * 눈에 보이지 않는다** — 운영에서는 이것이 가장 중요한 신호일 수 있다.\n */\nexport interface CrossedGroup extends KpiGroup {\n /** 비교 구간의 건수(두 구간에 모두 있을 때만). */\n prevTasks?: number\n /** 현재 − 비교구간 건수(두 구간에 모두 있을 때만). */\n deltaTasks?: number\n /** 비교 구간 대비 작업시간 중앙값 차이(둘 다 측정된 때만). */\n deltaWorkP50Ms?: number\n /** 비교 구간에는 없던 축 — delta 대신 이 표식을 준다. */\n isNew?: boolean\n}\n\nexport function crossGroups(\n now: KpiGroup[],\n prev: KpiGroup[]\n): { items: CrossedGroup[]; disappeared: { key: string; tasks: number }[] } {\n const before = new Map(prev.map(g => [g.key, g]))\n const items: CrossedGroup[] = now.map(g => {\n const p = before.get(g.key)\n if (!p) return { ...g, isNew: true }\n return {\n ...g,\n prevTasks: p.tasks,\n deltaTasks: g.tasks - p.tasks,\n ...(deltaP50(g.workTime, p.workTime) !== undefined ? { deltaWorkP50Ms: deltaP50(g.workTime, p.workTime) } : {})\n }\n })\n const present = new Set(now.map(g => g.key))\n const disappeared = prev.filter(g => !present.has(g.key)).map(g => ({ key: g.key, tasks: g.tasks }))\n return { items, disappeared }\n}\n\n/* ── 시간 비교 ──────────────────────────────────────────────────────────────\n * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다(\"29초\" 가 개선인지 악화인지). 같은 길이의 창을 옮겨\n * 한 번 더 접으면 되므로(폴드는 순수) 값싸다. 창 옮기기와 차이 계산은 계산 규칙이라 여기 있다 —\n * 조회 층에 두면 테스트가 DB 를 끌고 와야 한다.\n */\n\n/**\n * 비교 구간의 창 — 같은 **길이**로 옮긴다.\n *\n * previous: 바로 앞 구간(경계가 맞물리게 — 겹치거나 벌어지면 구간의 합이 어긋난다).\n * yesterday: 정확히 24시간 전 같은 자리. 시뮬 트윈의 클록이 실제 시각과 달라도 **차분은 정확**하다.\n */\nexport function shiftWindow(fromMs: number, toMs: number, basis: 'previous' | 'yesterday'): KpiWindow {\n const span = toMs - fromMs\n const back = basis === 'yesterday' ? 24 * 60 * 60_000 : span\n return { fromMs: fromMs - back, toMs: toMs - back }\n}\n\n/**\n * 중앙값 차이(현재 − 비교구간) — **둘 다 측정된 것만** 낸다.\n *\n * 한쪽이 결측이면 차이는 **없는 것**이다(0 이 아니다). 0 으로 내면 \"변화 없음\" 이라는 거짓이 된다.\n * 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록, 소요는 작을수록 좋다는 해석은 소비처의 몫이다.\n */\nexport function deltaP50(now: DurationStats, prev: DurationStats): number | undefined {\n return now.count > 0 && prev.count > 0 ? now.p50Ms - prev.p50Ms : undefined\n}\n\n/** 구간 하나의 최소 요약 — 추세를 그릴 정보만(전체 KPI 를 구간마다 실어 보내지 않는다). */\nexport interface KpiBucket {\n fromMs: number\n toMs: number\n tasks: number\n orders: number\n /** 그 구간의 작업시간 중앙값 — 추세에서 \"느려지고 있는가\" 를 보는 값. */\n workP50Ms: number\n}\n\n/**\n * 창을 N 등분해 구간별로 접는다 — **같은 이벤트를 창만 바꿔** 다시 접는다(조회 추가 없음).\n *\n * 마지막 구간은 나머지를 흡수한다(부동소수 나눗셈으로 끝이 밀리면 마지막 완료가 빠진다).\n * `count <= 1` 이면 쪼갤 것이 없으므로 빈 배열 — 호출부가 \"추세 없음\" 으로 다룬다.\n */\nexport function foldKpiBuckets(events: KpiEvent[], window: KpiWindow, count: number): KpiBucket[] {\n const n = Math.max(0, Math.min(48, Math.floor(count)))\n if (n <= 1) return []\n const step = (window.toMs - window.fromMs) / n\n const out: KpiBucket[] = []\n for (let i = 0; i < n; i++) {\n const fromMs = window.fromMs + step * i\n const toMs = i === n - 1 ? window.toMs : window.fromMs + step * (i + 1)\n const b = foldKpi(events, { fromMs, toMs })\n out.push({ fromMs, toMs, tasks: b.throughput.tasks, orders: b.throughput.orders, workP50Ms: b.workTime.p50Ms })\n }\n return out\n}\n"]}
|