@things-factory/headless-twin 10.0.5 → 10.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist-server/engine/kpi-fold.js +24 -49
  2. package/dist-server/engine/kpi-fold.js.map +1 -1
  3. package/dist-server/engine/kpi-query.d.ts +7 -0
  4. package/dist-server/engine/kpi-query.js +11 -0
  5. package/dist-server/engine/kpi-query.js.map +1 -1
  6. package/dist-server/engine/twin-engine.d.ts +29 -2
  7. package/dist-server/engine/twin-engine.js +78 -21
  8. package/dist-server/engine/twin-engine.js.map +1 -1
  9. package/dist-server/engine/warm-start.d.ts +39 -0
  10. package/dist-server/engine/warm-start.js +37 -0
  11. package/dist-server/engine/warm-start.js.map +1 -0
  12. package/dist-server/index.js +8 -0
  13. package/dist-server/index.js.map +1 -1
  14. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  15. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  16. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  17. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  18. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  19. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  20. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  21. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  22. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  23. package/dist-server/service/twin-event/twin-event.d.ts +5 -0
  24. package/dist-server/service/twin-event/twin-event.js +45 -0
  25. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  26. package/dist-server/service/twin-journal/twin-journal-query.d.ts +19 -0
  27. package/dist-server/service/twin-journal/twin-journal-query.js +74 -0
  28. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  29. package/dist-server/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +6 -6
  31. package/server/engine/kpi-fold.ts +23 -52
  32. package/server/engine/kpi-query.ts +11 -0
  33. package/server/engine/twin-engine.ts +95 -28
  34. package/server/engine/warm-start.ts +53 -0
  35. package/server/index.ts +9 -0
  36. package/server/service/twin-event/backfill-keys.ts +72 -0
  37. package/server/service/twin-event/twin-event-keys.ts +102 -0
  38. package/server/service/twin-event/twin-event-type.ts +27 -0
  39. package/server/service/twin-event/twin-event.ts +48 -0
  40. package/server/service/twin-journal/twin-journal-query.ts +79 -3
  41. package/test/kpi-fold.test.ts +22 -0
  42. package/test/twin-event-keys.test.ts +108 -0
  43. package/test/warm-start.test.ts +78 -0
@@ -25,22 +25,13 @@ exports.crossGroups = crossGroups;
25
25
  exports.shiftWindow = shiftWindow;
26
26
  exports.deltaP50 = deltaP50;
27
27
  exports.foldKpiBuckets = foldKpiBuckets;
28
- /**
29
- * 작업에 대해 축의 값을 기억한다 **처음 값을 남긴다**.
30
- *
31
- * 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.
32
- * 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.
28
+ /*
29
+ * 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것,
30
+ * 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니
31
+ * 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 건으로 세어 처리량·점유가 부풀려졌다).
32
+ * 파일은 이제 "접힌 기록 지표" 한다.
33
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
- }
34
+ const twin_kernel_1 = require("@operato/twin-kernel");
44
35
  /** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */
45
36
  function facetKey(rec, options) {
46
37
  const f = rec.facets;
@@ -48,7 +39,7 @@ function facetKey(rec, options) {
48
39
  case 'resource':
49
40
  return f.resource ?? 'unknown';
50
41
  case 'taskKind':
51
- return f.taskKind ?? 'unknown';
42
+ return f.kind ?? 'unknown';
52
43
  case 'node':
53
44
  return f.node ?? 'unknown';
54
45
  case 'order':
@@ -137,13 +128,8 @@ function stats(values) {
137
128
  * (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
138
129
  */
139
130
  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 = [];
131
+ /* 작업 짝맞춤은 **커널 규칙**으로(중복 구현 금지 곳에 적으면 같은 저널로 다른 답이 나온다). */
132
+ const { records: tasks } = (0, twin_kernel_1.foldTaskRecords)(events.filter(e => e.eventType === 'task.status'));
147
133
  let completedOrders = 0;
148
134
  let seen = 0;
149
135
  let inWindow = 0;
@@ -155,19 +141,6 @@ function foldKpi(events, window, options = {}) {
155
141
  if (at >= window.fromMs && at <= window.toMs)
156
142
  inWindow++;
157
143
  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
144
  if (e.eventType === 'order.status') {
172
145
  /* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고
173
146
  * 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */
@@ -187,24 +160,26 @@ function foldKpi(events, window, options = {}) {
187
160
  let tasksInWindow = 0;
188
161
  let unpairedLead = 0;
189
162
  let unpairedWork = 0;
190
- for (const t of completedTasks) {
191
- if (t.at < window.fromMs || t.at > window.toMs)
163
+ for (const t of tasks) {
164
+ const completed = t.completedMs;
165
+ if (completed === undefined)
166
+ continue; // 아직 완료되지 않은 작업 — 성과로 세지 않는다
167
+ if (completed < window.fromMs || completed > window.toMs)
192
168
  continue; // 성과는 완료 시점으로 센다
193
169
  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));
170
+ const c = t.createdMs;
171
+ const s = t.startedMs;
172
+ const rec = { taskId: t.taskId, at: completed, facets: t.facets };
173
+ const resource = rec.facets.resource;
174
+ if (c !== undefined && completed >= c)
175
+ leads.push((rec.leadMs = completed - c));
201
176
  else
202
177
  unpairedLead++;
203
- if (s !== undefined && t.at >= s) {
204
- const busy = t.at - s;
178
+ if (s !== undefined && completed >= s) {
179
+ const busy = completed - s;
205
180
  works.push((rec.workMs = busy));
206
- if (t.resourceId)
207
- busyByResource.set(t.resourceId, (busyByResource.get(t.resourceId) ?? 0) + busy);
181
+ if (resource)
182
+ busyByResource.set(resource, (busyByResource.get(resource) ?? 0) + busy);
208
183
  }
209
184
  else {
210
185
  unpairedWork++;
@@ -1 +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"]}
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/*\n * 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것,\n * 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니\n * 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다).\n * 이 파일은 이제 \"접힌 기록 → 창 지표\" 만 한다.\n */\nimport { foldTaskRecords, type TaskFacets } from '@operato/twin-kernel'\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 */\n/** 창 안에 완료된 한 건의 기록 — 통계의 원료이자 관점 축의 원료. */\ninterface CompletionRecord {\n taskId: string\n at: number\n facets: TaskFacets\n leadMs?: number\n workMs?: number\n waitMs?: number\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.kind ?? '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 { records: tasks } = foldTaskRecords(events.filter(e => e.eventType === 'task.status'))\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 === '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 tasks) {\n const completed = t.completedMs\n if (completed === undefined) continue // 아직 완료되지 않은 작업 — 성과로 세지 않는다\n if (completed < window.fromMs || completed > window.toMs) continue // 성과는 완료 시점으로 센다\n tasksInWindow++\n const c = t.createdMs\n const s = t.startedMs\n const rec: CompletionRecord = { taskId: t.taskId, at: completed, facets: t.facets }\n const resource = rec.facets.resource\n if (c !== undefined && completed >= c) leads.push((rec.leadMs = completed - c))\n else unpairedLead++\n if (s !== undefined && completed >= s) {\n const busy = completed - s\n works.push((rec.workMs = busy))\n if (resource) busyByResource.set(resource, (busyByResource.get(resource) ?? 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"]}
@@ -140,6 +140,13 @@ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
140
140
  }
141
141
  /** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */
142
142
  type KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] & Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>;
143
+ /**
144
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
145
+ *
146
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
147
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
148
+ */
149
+ export declare function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]>;
143
150
  /**
144
151
  * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
145
152
  *
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveTwinTargets = resolveTwinTargets;
3
4
  exports.computeTwinKpi = computeTwinKpi;
4
5
  /*
5
6
  * 업무 KPI 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.
@@ -117,6 +118,16 @@ async function resolveTargets(input) {
117
118
  const realityModes = [...new Set(rows.map((x) => String(x.realityMode ?? 'unknown')))];
118
119
  return { instanceIds, realityModes };
119
120
  }
121
+ /**
122
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
123
+ *
124
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
125
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
126
+ */
127
+ async function resolveTwinTargets(domainId, instanceId, spaceId) {
128
+ const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId });
129
+ return instanceIds;
130
+ }
120
131
  /**
121
132
  * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
122
133
  *
@@ -1 +1 @@
1
- {"version":3,"file":"kpi-query.js","sourceRoot":"","sources":["../../server/engine/kpi-query.ts"],"names":[],"mappings":";;AAgQA,wCAuKC;AAvaD;;;;;;;;;;;;;;;GAeG;AACH,qCAAqC;AAErC,iDAAqD;AAErD,uEAA+D;AAC/D,gFAAwE;AACxE,qEAA6D;AAC7D,uEAA+D;AAC/D,qDAA6C;AAC7C,+CAUsB;AAEtB,qEAAqE;AACrE,MAAM,eAAe,GAAG,CAAC,aAAa,EAAE,cAAc,CAAC,CAAA;AAEvD,yCAAyC;AACzC,MAAM,UAAU,GAAG,KAAK,CAAA;AAuHxB;;;;;GAKG;AACH,KAAK,UAAU,WAAW,CACxB,QAAgB,EAChB,WAAqB,EACrB,OAAgB;IAEhB,MAAM,QAAQ,GAA2B,EAAE,CAAA;IAC3C,IAAI,WAAW,CAAC,MAAM,EAAE,CAAC;QACvB,MAAM,IAAI,GAAG,MAAM,IAAA,qBAAa,EAAC,+BAAY,CAAC,CAAC,IAAI,CAAC;YAClD,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,UAAU,EAAE,IAAA,YAAE,EAAC,WAAW,CAAC,EAAE;SACjE,CAAC,CAAA;QACF,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,KAAK,MAAM,CAAC,IAAI,CAAE,GAAG,CAAC,KAAa,EAAE,KAAK,IAAI,EAAE,CAAU,EAAE,CAAC;gBAC3D,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,EAAE,QAAQ;oBAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAA;YACvE,CAAC;QACH,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAA2B,EAAE,CAAA;IACzC,MAAM,GAAG,GAAG,OAAO,CAAA;IACnB,IAAI,GAAG,EAAE,CAAC;QACR,MAAM,KAAK,GAAG,MAAM,IAAA,qBAAa,EAAC,yBAAS,CAAC,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC,CAAA;QAC3G,IAAI,KAAK,EAAE,CAAC;YACV,MAAM,KAAK,GAAG,MAAM,IAAA,qBAAa,EAAC,uBAAQ,CAAC,CAAC,IAAI,CAAC;gBAC/C,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAS,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAS,EAAE;aAC3E,CAAC,CAAA;YACF,KAAK,MAAM,CAAC,IAAI,KAAK;gBAAE,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI;oBAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAA;QAC1E,CAAC;IACH,CAAC;IACD,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAA;AAC7B,CAAC;AAED,8CAA8C;AAC9C,KAAK,UAAU,eAAe,CAAC,QAAgB,EAAE,WAAqB;IACpE,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACzC,MAAM,GAAG,GAAG,MAAM,IAAA,qBAAa,EAAC,yBAAS,CAAC,CAAC,OAAO,CAAC;QACjD,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,UAAU,EAAE,IAAA,YAAE,EAAC,WAAW,CAAC,EAAE,SAAS,EAAE,IAAA,YAAE,EAAC,eAAe,CAAC,EAAE;QAChG,KAAK,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE;KAC5B,CAAC,CAAA;IACF,OAAO,GAAG,EAAE,SAAS,IAAI,IAAI,CAAA;AAC/B,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,WAAW,CAAC,QAAgB,EAAE,WAAqB,EAAE,MAAc,EAAE,IAAY,EAAE,UAAkB;IAClH,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,EAAgB,EAAE,MAAM,EAAE,KAAK,EAAE,CAAA;IAChF,MAAM,IAAI,GAAG,MAAM,IAAA,qBAAa,EAAC,yBAAS,CAAC,CAAC,IAAI,CAAC;QAC/C,KAAK,EAAE;YACL,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;YACxB,UAAU,EAAE,IAAA,YAAE,EAAC,WAAW,CAAC;YAC3B,SAAS,EAAE,IAAA,YAAE,EAAC,eAAe,CAAC;YAC9B,SAAS,EAAE,IAAA,iBAAO,EAAC,IAAI,IAAI,CAAC,MAAM,GAAG,UAAU,CAAC,CAAC,WAAW,EAAE,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;SAC9F;QACD,KAAK,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE;QAC1B,IAAI,EAAE,UAAU;KACjB,CAAC,CAAA;IACF;qDACiD;IACjD,OAAO;QACL,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;QAC/F,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,UAAU;KAClC,CAAA;AACH,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,cAAc,CAAC,KAAmB;IAC/C,IAAI,CAAC,KAAK,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAA;IACpG,MAAM,IAAI,GAAG,MAAM,2BAAU,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;IAClD,MAAM,IAAI,GAAG,KAAK,CAAC,UAAU;QAC3B,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,KAAK,CAAC,UAAU,CAAC;QAC7D,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,IAAI,CAAC,CAAC,OAAO,KAAK,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO,KAAK,OAAO,IAAI,CAAC,CAAC,UAAU,CAAC,CAAA;IAC/G,2DAA2D;IAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,CAAC,UAAoB,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;IAC3H,MAAM,YAAY,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3F,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,CAAA;AACtC,CAAC;AAED;;;;GAIG;AACI,KAAK,UAAU,cAAc,CAAC,KAAmB;IACtD,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;IACjF,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,EAAE,KAAK,CAAC,eAAe,IAAI,EAAE,CAAC,CAAC,GAAG,MAAM,CAAA;IAEvF,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,GAAG,MAAM,cAAc,CAAC,KAAK,CAAC,CAAA;IACjE,MAAM,KAAK,GAAG,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,CAAA;IAEnE,oDAAoD;IACpD,IAAI,KAAK,GAA6B,OAAO,CAAA;IAC7C,IAAI,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,CAAA;IACxD,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3B,MAAM,MAAM,GAAG,MAAM,eAAe,CAAC,KAAK,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAA;QACjE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;YACtB,OAAO;gBACL,MAAM,EAAE;oBACN,QAAQ,EAAE,IAAI,IAAI,CAAC,GAAG,GAAG,OAAO,GAAG,MAAM,CAAC,CAAC,WAAW,EAAE;oBACxD,MAAM,EAAE,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,WAAW,EAAE;oBACnC,OAAO;oBACP,KAAK,EAAE,cAAc;iBACtB;gBACD,QAAQ,EAAE,KAAK;gBACf,KAAK;gBACL,IAAI,EACF,WAAW,CAAC,MAAM,KAAK,CAAC;oBACtB,CAAC,CAAC,qDAAqD;oBACvD,CAAC,CAAC,yGAAyG;aAChH,CAAA;QACH,CAAC;QACD,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;QACzB,KAAK,GAAG,cAAc,CAAA;IACxB,CAAC;IAED,MAAM,MAAM,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,OAAO,GAAG,MAAM,CAAA;IACpF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,MAAM,IAAI,IAAI,EAAE,CAAC;QACzE,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAA;IAChF,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,WAAW,CAAC,KAAK,CAAC,QAAQ,EAAE,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,CAAC,CAAA;IAEnG,wDAAwD;IACxD,MAAM,OAAO,GACX,KAAK,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,WAAW,CAAC,KAAK,CAAC,QAAQ,EAAE,WAAW,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;IACzH,MAAM,GAAG,GAAG,IAAA,qBAAO,EAAC,MAAM,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;QAC5C,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,UAAU,EAAE,KAAK,CAAC,UAAU;KAC7B,CAAC,CAAA;IACF,MAAM,MAAM,GAAG;QACb,QAAQ,EAAE,IAAI,IAAI,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE;QACxC,MAAM,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE;QACpC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,GAAG,MAAM,CAAC,GAAG,MAAM,CAAC;QAC7C,KAAK;KACN,CAAA;IACD,IAAI,GAAG,CAAC,cAAc,KAAK,CAAC,EAAE,CAAC;QAC7B,OAAO;YACL,MAAM;YACN,QAAQ,EAAE,KAAK;YACf,KAAK;YACL,IAAI,EAAE,4FAA4F;SACnG,CAAA;IACH,CAAC;IAED,+DAA+D;IAC/D,MAAM,UAAU,GAAG,IAAA,4BAAc,EAAC,MAAM,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;IACvF,MAAM,OAAO,GAAG,UAAU,CAAC,MAAM;QAC/B,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACnB,QAAQ,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE;YAC1C,MAAM,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE;YACtC,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,SAAS,EAAE,CAAC,CAAC,SAAS;SACvB,CAAC,CAAC;QACL,CAAC,CAAC,SAAS,CAAA;IAEb;8CAC0C;IAC1C,IAAI,UAAuC,CAAA;IAC3C,wDAAwD;IACxD,IAAI,OAAmD,CAAA;IACvD,uDAAuD;IACvD,IAAI,cAAc,GAAG,KAAK,CAAA;IAC1B,IAAI,KAAK,CAAC,SAAS,KAAK,UAAU,IAAI,KAAK,CAAC,SAAS,KAAK,WAAW,EAAE,CAAC;QACtE,MAAM,CAAC,GAAG,IAAA,yBAAW,EAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;QACpD,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,WAAW,CAClE,KAAK,CAAC,QAAQ,EACd,WAAW,EACX,CAAC,CAAC,MAAM,EACR,CAAC,CAAC,IAAI,EACN,UAAU,CACX,CAAA;QACD;;8BAEsB;QACtB,MAAM,IAAI,GAAG,IAAA,qBAAO,EAAC,UAAU,EAAE,CAAC,EAAE;YAClC,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,QAAQ,EAAE,OAAO,CAAC,QAAQ;YAC1B,UAAU,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS;SAC5C,CAAC,CAAA;QACF,cAAc,GAAG,UAAU,CAAA;QAC3B,IAAI,KAAK,CAAC,OAAO,IAAI,GAAG,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,cAAc,GAAG,CAAC,EAAE,CAAC;YAC1E,OAAO,GAAG,IAAA,yBAAW,EAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QAC5D,CAAC;QACD,MAAM,IAAI,GAAG;YACX,KAAK,EAAE,KAAK,CAAC,SAAS;YACtB,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,EAAE;SAC/F,CAAA;QACD,UAAU;YACR,IAAI,CAAC,cAAc,KAAK,CAAC;gBACvB,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE;gBAC9B,CAAC,CAAC;oBACE,GAAG,IAAI;oBACP,QAAQ,EAAE,IAAI;oBACd,UAAU,EAAE,IAAI,CAAC,UAAU;oBAC3B,QAAQ,EAAE,IAAI,CAAC,QAAQ;oBACvB,QAAQ,EAAE,IAAI,CAAC,QAAQ;oBACvB,QAAQ,EAAE,IAAI,CAAC,QAAQ;oBACvB,KAAK,EAAE;wBACL,KAAK,EAAE,GAAG,CAAC,UAAU,CAAC,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,KAAK;wBACnD,MAAM,EAAE,GAAG,CAAC,UAAU,CAAC,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,MAAM;wBACtD,SAAS,EAAE,IAAA,sBAAQ,EAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC;wBAChD,SAAS,EAAE,IAAA,sBAAQ,EAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC;wBAChD,SAAS,EAAE,IAAA,sBAAQ,EAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC;qBACjD;iBACF,CAAA;IACT,CAAC;IAED;kEAC8D;IAC9D,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM;QACvB,CAAC,CAAC;YACE,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE;YACjB,KAAK,EAAE,CAAC,OAAO,EAAE,KAAK,IAAI,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;gBACpD,GAAG,CAAC;gBACJ,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACnE,CAAC,CAAC;YACH,GAAG,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM;gBAC7B,CAAC,CAAC;oBACE,WAAW,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;wBACzC,GAAG,CAAC;wBACJ,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;qBACnE,CAAC,CAAC;iBACJ;gBACH,CAAC,CAAC,EAAE,CAAC;YACP,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,SAAS;gBACtB,CAAC,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,SAAS,EAAE,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,cAAc,IAAI,CAAC,EAAE;gBACrF,CAAC,CAAC,EAAE,CAAC;YACP,GAAG,CAAC,KAAK,CAAC,OAAO,KAAK,MAAM;gBAC5B,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,MAAM,KAAK,CAAC;gBAC1C,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,SAAS,CAAC;gBAC7C,CAAC,CAAC,EAAE,IAAI,EAAE,0GAA0G,EAAE;gBACtH,CAAC,CAAC,EAAE,CAAC;SACR;QACH,CAAC,CAAC,SAAS,CAAA;IAEb,qDAAqD;IACrD,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,IAAI,EAAE,GAAG,GAAG,CAAA;IAC/D,OAAO;QACL,MAAM;QACN,QAAQ,EAAE,IAAI;QACd,KAAK;QACL,GAAG,CAAC,MAAM,IAAI,cAAc,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,IAAI;QACP,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/B,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7B,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACtC,CAAA;AACH,CAAC","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
+ {"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 * 화면에서 \"이 공간\" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을\n * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.\n */\nexport async function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]> {\n const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId } as TwinKpiInput)\n return instanceIds\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"]}
@@ -63,8 +63,29 @@ export declare class TwinEngine {
63
63
  * 라이브 tick 재개(sim 결정적 re-run / live 인제스트 지속)는 후속. 여기선 상태 복원 + 노출.
64
64
  */
65
65
  static bootstrap(): Promise<void>;
66
- /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
67
- static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode): InstanceRuntime;
66
+ /**
67
+ * 웜스타트 기동하는 커널에 **직전 관측 상태**를 심는다.
68
+ *
69
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
70
+ * `loadBoard` 는 **구조만** 싣는다(노드·무버). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
71
+ * 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
72
+ * 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
73
+ * `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
74
+ * 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다.
75
+ *
76
+ * ── 정직한 한계 ─────────────────────────────────────────────────────────────
77
+ * · **오더는 복원하지 않는다.** `hydrateObserved` 의 오더 인자는 requested/fulfilled/lines 를 요구하는데
78
+ * 스냅샷의 `OrderState` 에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이라
79
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작한다.
80
+ * · 진행 중 개별 task 의 내부 상태도 관측만으로는 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
81
+ * · 근본 해법(상태 영속 계약·revision 이어붙임)은 별도 과제.
82
+ *
83
+ * ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
84
+ * 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 심으면 측정이 오염된다.
85
+ */
86
+ private static warmStart;
87
+ /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
88
+ static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode, purpose?: string): InstanceRuntime;
68
89
  /**
69
90
  * live-모드 기동(face2-inbound-live §1.1) — 커널 tick 대신 StateProjector 로 실 이벤트를 미러.
70
91
  * 관측 상태(점유·위치·아이템)는 projector 가 재구성 → sim 과 동일 data(tag) payload → **보드 컴포넌트 동일 렌더**
@@ -85,6 +106,12 @@ export declare class TwinEngine {
85
106
  private static ensureBroadcastCoalescer;
86
107
  /** dirty live 인스턴스 방송 flush(주기 tick 또는 명시 호출). 테스트/즉시 방송용으로 public. */
87
108
  static flushLiveBroadcasts(): void;
109
+ /**
110
+ * 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.**
111
+ * 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
112
+ * 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
113
+ */
114
+ private static journalRow;
88
115
  /** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
89
116
  static persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise<void>;
90
117
  /** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */