@things-factory/headless-twin 10.0.5 → 10.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +37 -24
  2. package/dist-server/engine/entity-delta.d.ts +13 -1
  3. package/dist-server/engine/entity-delta.js +130 -16
  4. package/dist-server/engine/entity-delta.js.map +1 -1
  5. package/dist-server/engine/index.d.ts +4 -0
  6. package/dist-server/engine/index.js +4 -0
  7. package/dist-server/engine/index.js.map +1 -1
  8. package/dist-server/engine/kpi-fold.d.ts +20 -3
  9. package/dist-server/engine/kpi-fold.js +45 -53
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +16 -0
  12. package/dist-server/engine/kpi-query.js +129 -19
  13. package/dist-server/engine/kpi-query.js.map +1 -1
  14. package/dist-server/engine/kpi-target.d.ts +141 -0
  15. package/dist-server/engine/kpi-target.js +137 -0
  16. package/dist-server/engine/kpi-target.js.map +1 -0
  17. package/dist-server/engine/live-attentions.d.ts +4 -2
  18. package/dist-server/engine/live-attentions.js +5 -1
  19. package/dist-server/engine/live-attentions.js.map +1 -1
  20. package/dist-server/engine/measured-estimator.d.ts +50 -0
  21. package/dist-server/engine/measured-estimator.js +78 -0
  22. package/dist-server/engine/measured-estimator.js.map +1 -0
  23. package/dist-server/engine/model-basis.d.ts +23 -0
  24. package/dist-server/engine/model-basis.js +100 -0
  25. package/dist-server/engine/model-basis.js.map +1 -0
  26. package/dist-server/engine/oee-accumulator.d.ts +2 -2
  27. package/dist-server/engine/oee-accumulator.js +4 -4
  28. package/dist-server/engine/oee-accumulator.js.map +1 -1
  29. package/dist-server/engine/spec-coverage.d.ts +49 -0
  30. package/dist-server/engine/spec-coverage.js +60 -0
  31. package/dist-server/engine/spec-coverage.js.map +1 -0
  32. package/dist-server/engine/structure-diff.d.ts +25 -0
  33. package/dist-server/engine/structure-diff.js +63 -0
  34. package/dist-server/engine/structure-diff.js.map +1 -0
  35. package/dist-server/engine/travel-estimator.d.ts +57 -0
  36. package/dist-server/engine/travel-estimator.js +120 -0
  37. package/dist-server/engine/travel-estimator.js.map +1 -0
  38. package/dist-server/engine/twin-engine.d.ts +181 -18
  39. package/dist-server/engine/twin-engine.js +545 -96
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +39 -0
  42. package/dist-server/engine/warm-start.js +37 -0
  43. package/dist-server/engine/warm-start.js.map +1 -0
  44. package/dist-server/index.js +8 -0
  45. package/dist-server/index.js.map +1 -1
  46. package/dist-server/service/index.d.ts +4 -2
  47. package/dist-server/service/index.js +21 -14
  48. package/dist-server/service/index.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +2 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +307 -1
  52. package/dist-server/service/reference/reference-master.js +96 -8
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +3 -3
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  59. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  60. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  61. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  62. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  63. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  64. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  65. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  66. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  67. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  68. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  69. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  70. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  71. package/dist-server/service/twin-event/twin-event.d.ts +16 -0
  72. package/dist-server/service/twin-event/twin-event.js +50 -0
  73. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  74. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  75. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  76. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  77. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  78. package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
  79. package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
  80. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  81. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  82. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  83. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  84. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  85. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  86. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  87. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  89. package/dist-server/service/twin-space/twin-space.js +5 -0
  90. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  91. package/dist-server/service/twin-structure/index.d.ts +2 -0
  92. package/dist-server/service/twin-structure/index.js +6 -0
  93. package/dist-server/service/twin-structure/index.js.map +1 -0
  94. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  95. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  96. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  97. package/dist-server/service/twin-target/index.d.ts +4 -0
  98. package/dist-server/service/twin-target/index.js +8 -0
  99. package/dist-server/service/twin-target/index.js.map +1 -0
  100. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  101. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  102. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  103. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  104. package/dist-server/service/twin-target/twin-target.js +112 -0
  105. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  106. package/dist-server/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +6 -6
  108. package/server/engine/entity-delta.ts +135 -15
  109. package/server/engine/index.ts +4 -0
  110. package/server/engine/kpi-fold.ts +62 -59
  111. package/server/engine/kpi-query.ts +140 -19
  112. package/server/engine/kpi-target.ts +226 -0
  113. package/server/engine/live-attentions.ts +12 -2
  114. package/server/engine/measured-estimator.ts +91 -0
  115. package/server/engine/model-basis.ts +94 -0
  116. package/server/engine/oee-accumulator.ts +5 -5
  117. package/server/engine/spec-coverage.ts +85 -0
  118. package/server/engine/structure-diff.ts +88 -0
  119. package/server/engine/travel-estimator.ts +133 -0
  120. package/server/engine/twin-engine.ts +587 -106
  121. package/server/engine/warm-start.ts +53 -0
  122. package/server/index.ts +9 -0
  123. package/server/service/index.ts +7 -0
  124. package/server/service/reference/reference-live.ts +2 -1
  125. package/server/service/reference/reference-master.ts +383 -10
  126. package/server/service/reference/reference-resolver.ts +3 -3
  127. package/server/service/reference/template-registry.ts +1 -1
  128. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  129. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  130. package/server/service/twin-event/backfill-keys.ts +72 -0
  131. package/server/service/twin-event/twin-event-keys.ts +102 -0
  132. package/server/service/twin-event/twin-event-type.ts +27 -0
  133. package/server/service/twin-event/twin-event.ts +62 -0
  134. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  135. package/server/service/twin-instance/twin-instance.ts +1 -1
  136. package/server/service/twin-journal/twin-journal-query.ts +129 -5
  137. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  138. package/server/service/twin-space/twin-space-area.ts +1 -1
  139. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  140. package/server/service/twin-space/twin-space.ts +14 -0
  141. package/server/service/twin-structure/index.ts +3 -0
  142. package/server/service/twin-structure/twin-structure.ts +93 -0
  143. package/server/service/twin-target/index.ts +5 -0
  144. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  145. package/server/service/twin-target/twin-target.ts +97 -0
  146. package/test/capability-mapping.test.ts +5 -5
  147. package/test/duration-estimators.test.ts +144 -0
  148. package/test/entity-delta.test.ts +150 -19
  149. package/test/ingest-bench.test.ts +9 -9
  150. package/test/kpi-fold.test.ts +254 -3
  151. package/test/live-mirror-parity.test.ts +53 -19
  152. package/test/master-to-twin.test.ts +87 -13
  153. package/test/model-basis.test.ts +86 -0
  154. package/test/oee-accumulator.test.ts +9 -9
  155. package/test/scale-twin-bench.test.ts +22 -22
  156. package/test/spec-coverage.test.ts +113 -0
  157. package/test/streamline-e2e.test.ts +10 -10
  158. package/test/structure-revision-db.test.ts +310 -0
  159. package/test/twin-event-keys.test.ts +108 -0
  160. package/test/vocabulary-guard.test.ts +43 -0
  161. package/test/warm-start.test.ts +78 -0
@@ -1 +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, activeShiftAt, type TaskFacets, type WorkCalendarEntry } 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 /**\n * 하위 꼬리 — **분포를 양쪽에서 접지하기 위해** 낸다.\n * p50·p90 만 있으면 아래쪽 퍼짐을 위쪽에서 거울처럼 베껴 쓸 수밖에 없다(발명). 관측이 말하게 한다.\n */\n p10Ms: number\n p50Ms: number\n p90Ms: number\n avgMs: number\n}\n\n/**\n * 관점 축 — **같은 사실을 다른 각으로** 본다.\n *\n * 고정된 한 렌즈(자원별)만 낼 수 있으면 사용자가 \"구역별로는?\" 이라고 물어도 답이 없다. 사실 계산이\n * 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다.\n *\n * resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나)\n * location 도착 지점별(어디서) order 오더별(무엇을 위해)\n * area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)\n * shift 교대별(언제) — 완료 시각 + 현장의 시각 기준에서 **계산한다**(이벤트에 없다)\n */\nexport type KpiGroupBy = 'resource' | 'taskKind' | 'location' | 'order' | 'area' | 'shift'\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 locationArea?: Record<string, string>\n /**\n * 교대 선언 — **이벤트에는 교대가 없다**(시각만 있다). 현장 마스터가 아는 값이라 호출부가 넘긴다.\n * 없으면 교대 축은 'unknown' 으로 모인다(다른 축으로 조용히 바꾸지 않는다).\n */\n shifts?: WorkCalendarEntry[]\n /**\n * 현장의 시각 기준(분) — 교대 축이 `HH:MM` 을 어느 기준으로 읽을지. **공간이 갖는 값**이다\n * (테넌트가 아니다: 한 테넌트가 다른 시간대의 공장을 함께 가질 수 있다). 없으면 UTC.\n */\n utcOffsetMinutes?: number\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 'location':\n return f.location ?? 'unknown'\n case 'order':\n return f.order ?? 'unknown'\n case 'area':\n /* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가\n * 그것을 구역으로 오해한다). */\n return (f.location && options.locationArea?.[f.location]) ?? 'unknown'\n case 'shift':\n /*\n * 교대는 **저널에 찍혀 있지 않다 — 계산한다.**\n *\n * 이벤트에 교대 이름을 스탬프하면 두 가지가 무너진다: ① 같은 사실이 두 곳에 생기고,\n * ② 나중에 교대 시간을 고치면 **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 교대는\n * `완료 시각 + 현장의 시각 기준 + 교대 선언`에서 나오는 순수 파생이므로 여기서 낸다 —\n * 라이브·히스토리·예측이 **커널과 같은 함수**(`activeShiftOf`)를 쓴다.\n *\n * 선언이 없으면 'unknown' 이다. 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.\n */\n if (!options.shifts?.length) return 'unknown'\n /* 시각까지 넘긴다 — **휴일에 일어난 일은 어느 교대에도 속하지 않는다.** 되풀이만 보면 그날 서지도\n 않은 교대에 집계돼, 교대별 성과가 조용히 거짓이 된다. */\n return activeShiftAt(options.shifts, rec.at, options.utcOffsetMinutes) ?? '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, p10Ms: 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 p10Ms: quantile(sorted, 0.1),\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"]}
@@ -1,3 +1,4 @@
1
+ import { type TargetVerdict } from './kpi-target.js';
1
2
  import { type CrossedGroup, type KpiGroupBy, type KpiResult } from './kpi-fold.js';
2
3
  export interface TwinKpiInput {
3
4
  domainId: string;
@@ -85,6 +86,14 @@ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
85
86
  orders: number;
86
87
  workP50Ms: number;
87
88
  }[];
89
+ /**
90
+ * **목표 대비 판정** — 숫자를 판단으로 바꾸는 값(§kpi-target).
91
+ *
92
+ * 목표가 걸린 지표만 들어온다. 목표가 없거나 그 구간에 값이 없는 지표는 **여기 나타나지 않는다** —
93
+ * 전부 "목표 없음" 으로 채우면 화면이 회색 노이즈가 되고, 없는 값을 0 으로 놓으면 "목표 크게 미달"
94
+ * 이라는 없는 사실이 만들어진다.
95
+ */
96
+ targets?: TargetVerdict[];
88
97
  /**
89
98
  * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.
90
99
  *
@@ -140,6 +149,13 @@ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
140
149
  }
141
150
  /** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */
142
151
  type KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] & Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>;
152
+ /**
153
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
154
+ *
155
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
156
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
157
+ */
158
+ export declare function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]>;
143
159
  /**
144
160
  * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
145
161
  *
@@ -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 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.
@@ -18,11 +19,14 @@ exports.computeTwinKpi = computeTwinKpi;
18
19
  * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.
19
20
  */
20
21
  const typeorm_1 = require("typeorm");
22
+ const twin_kernel_1 = require("@operato/twin-kernel");
21
23
  const shell_1 = require("@things-factory/shell");
22
24
  const twin_event_js_1 = require("../service/twin-event/twin-event.js");
23
25
  const twin_instance_js_1 = require("../service/twin-instance/twin-instance.js");
24
26
  const twin_area_js_1 = require("../service/twin-space/twin-area.js");
25
27
  const twin_space_js_1 = require("../service/twin-space/twin-space.js");
28
+ const twin_target_js_1 = require("../service/twin-target/twin-target.js");
29
+ const kpi_target_js_1 = require("./kpi-target.js");
26
30
  const twin_engine_js_1 = require("./twin-engine.js");
27
31
  const kpi_fold_js_1 = require("./kpi-fold.js");
28
32
  /** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */
@@ -30,21 +34,90 @@ const BUSINESS_EVENTS = ['task.status', 'order.status'];
30
34
  /** 한 번에 접을 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */
31
35
  const MAX_EVENTS = 20000;
32
36
  /**
33
- * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가
34
- * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
37
+ * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `locations[].parentId` 가
38
+ * 마스터 계층에서 온 소속이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
35
39
  *
36
- * 구역 이름은 `TwinArea` 따로 있다 id 보여주면 사용자가 무엇인지 모른다.
40
+ * **한 홉만 보면 안 된다.** `parentId` 구역일 수도 있고 중간 단(라인·셀)일 수도 있다. 라인이 끼면
41
+ * 한 홉짜리 코드는 "구역이 LINE-1" 이라고 답하고, 그 id 는 구역 목록에 없으므로 그 아래 스테이션이
42
+ * **집계에서 조용히 빠진다**(에러 없이 틀린 숫자). 그래서 커널 `hierarchyOf` 로 사슬을 끝까지 걷는다 —
43
+ * 걷는 규칙은 커널에 한 벌뿐이고 소비처는 그것만 부른다.
37
44
  */
38
- async function nodeAreaMap(domainId, instanceIds, spaceId) {
39
- const nodeArea = {};
45
+ /**
46
+ * 교대 축의 재료 — **교대 선언과 현장의 시각 기준.**
47
+ *
48
+ * 저널에는 시각만 있고 교대가 없다(일부러 그렇게 뒀다 — 교대는 파생이라 찍어 두면 나중에 교대 시간을
49
+ * 고쳤을 때 과거가 옛 구분으로 굳는다). 그래서 폴드가 계산할 재료를 여기서 모은다.
50
+ *
51
+ * 선언의 출처는 **보드의 설비**다. 여러 설비가 서로 다른 교대를 가질 수 있지만, 축은 하나여야 하므로
52
+ * **가장 많이 쓰이는 선언 한 벌**을 쓴다 — 3교대 공장은 전 설비가 같은 교대이므로 이것이 곧 정답이고,
53
+ * 자원마다 교대가 다른 현장에서는 이 축이 거칠다는 뜻이다(그 사실을 여기 적어 둔다).
54
+ *
55
+ * 시각 기준은 보드에 실려 있다(`utcOffsetMinutes` — 공간이 갖는 값을 기동 시 호스트가 풀어 넣는다).
56
+ * 없으면 UTC 로 읽고, 그 기본값은 커널 계약에 밝혀져 있다.
57
+ */
58
+ /**
59
+ * 이 범위에 걸린 운영 목표 — **좁은 선언이 넓은 선언을 덮는다.**
60
+ *
61
+ * 공간 전체 목표 위에 인스턴스 목표를 얹을 수 있어야 예외를 표현할 수 있다("이 라인만 다르다").
62
+ * 같은 지표에 둘 다 있으면 인스턴스가 이긴다.
63
+ */
64
+ async function loadTargets(domainId, spaceId, instanceIds, axis) {
65
+ /* 조회는 **가져오기만** 한다 — 어느 목표가 이기는지는 순수 함수(`scopedTargets`)가 정한다.
66
+ 그 규칙이 DB 접근 뒤에 있으면 화면을 눌러 봐야만 확인된다(그리고 실제로 확인되지 않았다). */
67
+ const rows = await (0, shell_1.getRepository)(twin_target_js_1.TwinTarget).find({ where: { domain: { id: domainId } } }).catch(() => []);
68
+ return (0, kpi_target_js_1.scopedTargets)(rows, { spaceId, instanceIds, axis });
69
+ }
70
+ async function shiftContext(domainId, instanceIds) {
71
+ if (!instanceIds.length)
72
+ return {};
73
+ const rows = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({ where: { domain: { id: domainId }, instanceId: (0, typeorm_1.In)(instanceIds) } });
74
+ const tally = new Map();
75
+ let utcOffsetMinutes;
76
+ for (const row of rows) {
77
+ const board = row.board;
78
+ if (utcOffsetMinutes === undefined && typeof board?.utcOffsetMinutes === 'number')
79
+ utcOffsetMinutes = board.utcOffsetMinutes;
80
+ for (const e of (board?.equipment ?? [])) {
81
+ const decl = e?.workCalendar;
82
+ if (!Array.isArray(decl) || !decl.length)
83
+ continue;
84
+ const key = JSON.stringify(decl);
85
+ const hit = tally.get(key);
86
+ if (hit)
87
+ hit.n++;
88
+ else
89
+ tally.set(key, { decl, n: 1 });
90
+ }
91
+ }
92
+ let best;
93
+ for (const v of tally.values())
94
+ if (!best || v.n > best.n)
95
+ best = v;
96
+ return { ...(best ? { shifts: best.decl } : {}), ...(utcOffsetMinutes !== undefined ? { utcOffsetMinutes } : {}) };
97
+ }
98
+ async function locationAreaMap(domainId, instanceIds, spaceId) {
99
+ const locationArea = {};
40
100
  if (instanceIds.length) {
41
101
  const rows = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({
42
102
  where: { domain: { id: domainId }, instanceId: (0, typeorm_1.In)(instanceIds) }
43
103
  });
44
104
  for (const row of rows) {
45
- for (const n of (row.board?.nodes ?? [])) {
46
- if (n?.id && n?.parentId)
47
- nodeArea[String(n.id)] = String(n.parentId);
105
+ const locations = (row.board?.locations ?? []).filter(n => n?.id);
106
+ if (!locations.length)
107
+ continue;
108
+ /* 순환은 hierarchyOf 가 던진다 — 한 트윈의 잘못된 계층이 다른 트윈의 집계까지 죽이지 않게 여기서 가둔다. */
109
+ let h;
110
+ try {
111
+ h = (0, twin_kernel_1.hierarchyOf)({ locations });
112
+ }
113
+ catch (e) {
114
+ console.warn(`[twin-kpi] "${row.instanceId}": location hierarchy is broken — area rollup skipped for this twin: ${e?.message}`);
115
+ continue;
116
+ }
117
+ for (const n of locations) {
118
+ const area = h.rollupOf(String(n.id));
119
+ if (area)
120
+ locationArea[String(n.id)] = area; // 구역 미상은 넣지 않는다 — 없는 구역을 만들지 않는다
48
121
  }
49
122
  }
50
123
  }
@@ -61,7 +134,7 @@ async function nodeAreaMap(domainId, instanceIds, spaceId) {
61
134
  labels[a.areaId] = a.name;
62
135
  }
63
136
  }
64
- return { nodeArea, labels };
137
+ return { locationArea, labels };
65
138
  }
66
139
  /** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */
67
140
  async function latestEventTime(domainId, instanceIds) {
@@ -117,6 +190,16 @@ async function resolveTargets(input) {
117
190
  const realityModes = [...new Set(rows.map((x) => String(x.realityMode ?? 'unknown')))];
118
191
  return { instanceIds, realityModes };
119
192
  }
193
+ /**
194
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
195
+ *
196
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
197
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
198
+ */
199
+ async function resolveTwinTargets(domainId, instanceId, spaceId) {
200
+ const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId });
201
+ return instanceIds;
202
+ }
120
203
  /**
121
204
  * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
122
205
  *
@@ -156,13 +239,22 @@ async function computeTwinKpi(input) {
156
239
  throw new Error('invalid window: fromTime must be a valid time before toTime');
157
240
  }
158
241
  const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs);
159
- /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
160
- const areaMap = input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} };
242
+ /* 구역·교대 축은 **이벤트 밖의 지식**을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
243
+ const areaMap = input.groupBy === 'area' ? await locationAreaMap(input.domainId, instanceIds, input.spaceId) : { locationArea: {}, labels: {} };
244
+ const shiftCtx = input.groupBy === 'shift' ? await shiftContext(input.domainId, instanceIds) : {};
161
245
  const kpi = (0, kpi_fold_js_1.foldKpi)(events, { fromMs, toMs }, {
162
246
  groupBy: input.groupBy,
163
- nodeArea: areaMap.nodeArea,
247
+ locationArea: areaMap.locationArea,
248
+ ...shiftCtx,
164
249
  groupLimit: input.groupLimit
165
250
  });
251
+ /*
252
+ * 목표를 **폴드 뒤, 그룹 조립 앞**에 읽는다 — 전체 판정과 축별 판정이 같은 목록에서 나와야
253
+ * 두 곳의 임계가 갈라지지 않는다. 폴드 자체는 목표를 모른다(사실 층과 판정 층의 분리).
254
+ */
255
+ const scoped = await loadTargets(input.domainId, input.spaceId, instanceIds, input.groupBy);
256
+ const verdicts = (0, kpi_target_js_1.judgeTargets)(kpi, scoped.overall);
257
+ const groupTargets = scoped.byAxisKey;
166
258
  const window = {
167
259
  fromTime: new Date(fromMs).toISOString(),
168
260
  toTime: new Date(toMs).toISOString(),
@@ -203,7 +295,8 @@ async function computeTwinKpi(input) {
203
295
  * 있는데 없다고 말하게 된다). */
204
296
  const prev = (0, kpi_fold_js_1.foldKpi)(prevEvents, w, {
205
297
  groupBy: input.groupBy,
206
- nodeArea: areaMap.nodeArea,
298
+ locationArea: areaMap.locationArea,
299
+ ...shiftCtx, // 비교 구간도 **같은 교대 정의**로 접는다(정의가 다르면 축이 어긋난다)
207
300
  groupLimit: input.groupBy ? 100 : undefined
208
301
  });
209
302
  prevCappedFlag = prevCapped;
@@ -238,10 +331,16 @@ async function computeTwinKpi(input) {
238
331
  const groups = kpi.groups
239
332
  ? {
240
333
  by: kpi.groups.by,
241
- items: (crossed?.items ?? kpi.groups.items).map(g => ({
242
- ...g,
243
- ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})
244
- })),
334
+ items: (crossed?.items ?? kpi.groups.items).map(g => {
335
+ /* 축의 이 값에 목표가 걸려 있으면 판정을 함께 낸다 — "이 구역만 목표가 다르다" 를
336
+ 표에서 바로 있게. 목표가 없는 축값에는 필드를 만들지 않는다. */
337
+ const gt = (0, kpi_target_js_1.judgeGroupTargets)(g, groupTargets[g.key]);
338
+ return {
339
+ ...g,
340
+ ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {}),
341
+ ...(gt.length ? { targets: gt } : {})
342
+ };
343
+ }),
245
344
  ...(crossed?.disappeared.length
246
345
  ? {
247
346
  disappeared: crossed.disappeared.map(d => ({
@@ -254,12 +353,22 @@ async function computeTwinKpi(input) {
254
353
  ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }
255
354
  : {}),
256
355
  ...(input.groupBy === 'area' &&
257
- Object.keys(areaMap.nodeArea).length === 0 &&
356
+ Object.keys(areaMap.locationArea).length === 0 &&
258
357
  kpi.groups.items.some(g => g.key === 'unknown')
259
- ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
358
+ ? { note: 'locations carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
260
359
  : {})
261
360
  }
262
361
  : undefined;
362
+ /*
363
+ * 목표 대비 판정 — **사실 계산 뒤에 얹는다.**
364
+ *
365
+ * 폴드는 목표를 모른다(사실만 낸다). 목표를 아는 순간 "세는 규칙" 과 "잘했나 판정하는 규칙" 이
366
+ * 한 함수에 섞이고, 목표가 바뀔 때마다 사실 계산을 건드리게 된다. 그래서 조회 층에서 목표를
367
+ * 실어 오고, 판정은 순수 함수(`judgeTargets`)가 한다.
368
+ *
369
+ * **목표가 없으면 판정하지 않는다** — 필드 자체를 만들지 않는다. 전부 "목표 없음" 으로 채우면
370
+ * 화면이 회색 노이즈가 되고, 목표가 있는 지표가 묻힌다.
371
+ */
263
372
  /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */
264
373
  const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi;
265
374
  return {
@@ -270,6 +379,7 @@ async function computeTwinKpi(input) {
270
379
  ...rest,
271
380
  ...(buckets ? { buckets } : {}),
272
381
  ...(groups ? { groups } : {}),
382
+ ...(verdicts.length ? { targets: verdicts } : {}),
273
383
  ...(comparison ? { comparison } : {})
274
384
  };
275
385
  }
@@ -1 +1 @@
1
- {"version":3,"file":"kpi-query.js","sourceRoot":"","sources":["../../server/engine/kpi-query.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 업무 KPI 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.\n *\n * 왜 한 곳인가: 처음에 GraphQL 질의와 AI 도구가 각각 같은 조회를 적었다. 그러면 창 규칙이 두 곳에서\n * 갈라지고(한쪽만 고치면 화면과 AI 가 다른 숫자를 말한다) 조회 상한·테넌트 조건도 복제된다.\n * 계산은 `kpi-fold`(순수)가 소유하고, 이 파일은 **무엇을 읽을지**만 정한다.\n *\n * ── 창의 기준 시각은 \"트윈의 시계\" 다 ──────────────────────────────────────\n * 시뮬 트윈은 자기 클록으로 산다(예: 저널의 eventTime 이 2026-01-01T00:25Z 인데 실제 지금은 7월).\n * 그런 트윈에 \"최근 1시간\" 을 **실제 시각**으로 물으면 그 구간에는 아무 기록이 없어 항상\n * \"측정된 기록 없음\" 이 된다 — 폴드는 정상인데 답이 쓸모없다.\n *\n * 그래서 호출부가 끝 시각을 주지 않으면 **그 트윈의 마지막 기록 시각**을 끝으로 잡는다. 커널의 시각\n * 상수를 앱에서 재현하지 않고(무방언), 라이브 트윈에서도 자연스럽다(마지막 기록 ≈ 지금).\n * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.\n */\nimport { Between, In } from 'typeorm'\n\nimport { getRepository } from '@things-factory/shell'\n\nimport { TwinEvent } from '../service/twin-event/twin-event.js'\nimport { TwinInstance } from '../service/twin-instance/twin-instance.js'\nimport { TwinArea } from '../service/twin-space/twin-area.js'\nimport { TwinSpace } from '../service/twin-space/twin-space.js'\nimport { TwinEngine } from './twin-engine.js'\nimport {\n crossGroups,\n deltaP50,\n foldKpi,\n foldKpiBuckets,\n shiftWindow,\n type CrossedGroup,\n type KpiEvent,\n type KpiGroupBy,\n type KpiResult\n} from './kpi-fold.js'\n\n/** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */\nconst BUSINESS_EVENTS = ['task.status', 'order.status']\n\n/** 한 번에 접을 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */\nconst MAX_EVENTS = 20000\n\nexport interface TwinKpiInput {\n domainId: string\n /** 한 트윈. `spaceId` 와 둘 중 하나는 있어야 한다. */\n instanceId?: string\n /**\n * 한 **공간** — 그 공간에서 가동 중인 트윈 전부를 **한 번에 접는다**.\n *\n * 트윈별로 접어 평균을 다시 평균하면 분위수가 거짓이 된다(p90 의 평균은 p90 이 아니다).\n * 그래서 이벤트를 모아 **한 번** 접는다. 사용자는 \"부산 창고\" 를 보고, 그 안에 트윈이 몇 개든\n * 합쳐서 본다(헤더 트윈 셀렉터를 없앤 결정과 같은 규율).\n */\n spaceId?: string\n /** 창의 끝(ISO). 미지정이면 **그 트윈의 마지막 기록 시각**. */\n toTime?: string\n /** 창의 시작(ISO). 미지정이면 끝에서 windowMinutes 만큼 뒤로. */\n fromTime?: string\n /** 창 길이(분). fromTime 이 있으면 무시. 기본 60, 1~1440. */\n windowMinutes?: number\n /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60, 0~1440. */\n lookbackMinutes?: number\n /**\n * 창을 N 등분해 **구간별 값도** 함께 낸다(추세용). 1~48, 기본 0(쪼개지 않음).\n * 조회는 한 번이고 접기만 N 번 반복한다 — 폴드가 순수하므로 값싸다.\n */\n buckets?: number\n /**\n * 같은 사실을 **다른 관점으로** 쪼갠다: 자원별·작업종류별·도착지점별·오더별·구역별.\n *\n * 고정된 한 렌즈만 낼 수 있으면 \"구역별로는 어떤가\" 라는 질문에 화면도 AI 도 답할 수 없다.\n * 축은 조회 인자 하나이고, 접기는 순수 함수라 축을 하나 더 보는 값이 거의 없다.\n */\n groupBy?: KpiGroupBy\n /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 함께 알린다. */\n groupLimit?: number\n /**\n * **무엇과 비교할 것인가** — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday).\n *\n * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다(\"29초\" 가 개선인지 악화인지). 창만 옮겨 한 번 더\n * 접으면 되므로(폴드는 순수) 값싸다. 비교 구간에 기록이 없으면 **delta 를 만들지 않는다** —\n * 없는 것을 0% 로 그리면 \"변화 없음\" 이라는 거짓이 된다.\n */\n compareTo?: 'previous' | 'yesterday'\n}\n\nexport interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {\n /** 사람이 읽는 창(ISO) + 기준. 폴드의 raw 창(ms)은 여기에 흡수한다. */\n window: {\n fromTime: string\n toTime: string\n minutes: number\n /** 끝 시각을 어디서 얻었나 — 'given'(호출부 지정) | 'latest-event'(트윈의 마지막 기록). */\n basis: 'given' | 'latest-event'\n }\n /**\n * 조회가 **상한에 걸렸는가**(구간이 너무 커서 일부만 읽었을 수 있다).\n *\n * true 면 이 구간의 숫자는 **하한**이다 — 실제로는 더 있었을 수 있다. 조용히 잘라내면 숫자가 조용히\n * 작아지고 사용자는 그것을 성과 하락으로 읽는다. 구간을 좁혀 다시 물어야 한다.\n */\n eventsCapped?: boolean\n /**\n * 이 구간에 **측정할 기록이 있었는가**. false 는 \"처리량 0\" 이 아니라 **\"기록이 없다\"** 는 뜻이다 —\n * 두 상황은 완전히 다르고, 섞으면 사용자가 잘못된 결론을 낸다.\n */\n measured: boolean\n note?: string\n /** 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록을 밝힌다(숫자의 출처를 숨기지 않는다). */\n /**\n * 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록.\n *\n * `realityModes` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) ·\n * sim-world(가상 세계) · sim-experiment(실험 사본). 같은 \"345건\" 이 현실의 실적일 수도, 시뮬레이션의\n * 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다.\n */\n scope: { spaceId?: string; instanceIds: string[]; realityModes: string[] }\n /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */\n buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number }[]\n /**\n * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.\n *\n * `measured:false` 면 그 구간에 기록이 없다는 뜻이고 `delta` 는 없다(0 이 아니다).\n * delta 는 **현재 − 비교구간** 이다. 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록 좋고 소요는\n * 작을수록 좋다는 해석은 소비처(화면·AI)의 몫이고, 계산 층이 판단을 섞으면 목표 없이 판정하게 된다.\n */\n comparison?: {\n basis: 'previous' | 'yesterday'\n window: { fromTime: string; toTime: string }\n measured: boolean\n throughput?: { tasks: number; orders: number }\n workTime?: KpiResult['workTime']\n leadTime?: KpiResult['leadTime']\n waitTime?: KpiResult['waitTime']\n delta?: { tasks: number; orders: number; workP50Ms?: number; leadP50Ms?: number; waitP50Ms?: number }\n }\n /**\n * 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이\n * 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다.\n */\n groups?: {\n by: KpiGroupBy\n items: (KpiGroupOut & { label?: string })[]\n /**\n * 비교 구간에는 일이 있었는데 **지금은 없는** 축. 표에서 그냥 빠지면 멈춘 구역·놀고 있는 자원이\n * 눈에 보이지 않는다 — 운영에서는 이것이 가장 중요한 신호일 수 있다.\n */\n disappeared?: { key: string; label?: string; tasks: number }[]\n /** 상한 때문에 빠진 축의 수와, 그 축들이 안고 있던 건수(둘 다 알려야 꼬리를 놓치지 않는다). */\n truncated?: number\n truncatedTasks?: number\n note?: string\n }\n}\n\n/** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */\ntype KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] &\n Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>\n\n/**\n * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가\n * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.\n *\n * 구역 이름은 `TwinArea` 에 따로 있다 — id 만 보여주면 사용자가 무엇인지 모른다.\n */\nasync function nodeAreaMap(\n domainId: string,\n instanceIds: string[],\n spaceId?: string\n): Promise<{ nodeArea: Record<string, string>; labels: Record<string, string> }> {\n const nodeArea: Record<string, string> = {}\n if (instanceIds.length) {\n const rows = await getRepository(TwinInstance).find({\n where: { domain: { id: domainId }, instanceId: In(instanceIds) }\n })\n for (const row of rows) {\n for (const n of ((row.board as any)?.nodes ?? []) as any[]) {\n if (n?.id && n?.parentId) nodeArea[String(n.id)] = String(n.parentId)\n }\n }\n }\n\n const labels: Record<string, string> = {}\n const sid = spaceId\n if (sid) {\n const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } })\n if (space) {\n const areas = await getRepository(TwinArea).find({\n where: { domain: { id: domainId } as any, space: { id: space.id } as any }\n })\n for (const a of areas) if (a.areaId && a.name) labels[a.areaId] = a.name\n }\n }\n return { nodeArea, labels }\n}\n\n/** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */\nasync function latestEventTime(domainId: string, instanceIds: string[]): Promise<string | null> {\n if (instanceIds.length === 0) return null\n const row = await getRepository(TwinEvent).findOne({\n where: { domain: { id: domainId }, instanceId: In(instanceIds), eventType: In(BUSINESS_EVENTS) },\n order: { revision: 'DESC' }\n })\n return row?.eventTime ?? null\n}\n\n/**\n * 창 구간의 업무 이벤트 — 창보다 `lookbackMs` 만큼 앞까지 읽는다(완료된 작업의 시작이 창 밖일 수 있다).\n *\n * eventTime 은 ISO 문자열 컬럼이라 사전식 비교가 성립한다(같은 파일군의 시각 커서와 동일 전제).\n * 비교 구간도 같은 규칙으로 읽어야 하므로 함수로 둔다 — 두 곳에 적으면 규칙이 갈라진다.\n */\nasync function fetchEvents(domainId: string, instanceIds: string[], fromMs: number, toMs: number, lookbackMs: number) {\n if (instanceIds.length === 0) return { events: [] as KpiEvent[], capped: false }\n const rows = await getRepository(TwinEvent).find({\n where: {\n domain: { id: domainId },\n instanceId: In(instanceIds),\n eventType: In(BUSINESS_EVENTS),\n eventTime: Between(new Date(fromMs - lookbackMs).toISOString(), new Date(toMs).toISOString())\n },\n order: { revision: 'ASC' },\n take: MAX_EVENTS\n })\n /* 상한에 정확히 닿으면 **더 있었을 수 있다** — 그 사실을 돌려준다. 조용히 잘라내면 숫자가 조용히\n * 작아지고, 사용자는 그것을 성과 하락으로 읽는다(가장 나쁜 종류의 거짓이다). */\n return {\n events: rows.map(r => ({ eventType: r.eventType, eventTime: r.eventTime, payload: r.payload })),\n capped: rows.length >= MAX_EVENTS\n }\n}\n\n/**\n * 집계 대상 트윈 목록.\n *\n * 공간이 주어지면 **가동 중이고 운영 목적인** 것만 모은다 — 벤치 사본은 사용자가 보는 현장이 아니다.\n * 트윈이 지정되면 그것 하나. 둘 다 없으면 무엇을 재야 할지 알 수 없으므로 명시 실패한다.\n */\nasync function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: string[]; realityModes: string[] }> {\n if (!input.instanceId && !input.spaceId) throw new Error('either instanceId or spaceId is required')\n const list = await TwinEngine.list(input.domainId)\n const rows = input.instanceId\n ? list.filter((x: any) => x?.instanceId === input.instanceId)\n : list.filter((x: any) => x?.running && x.spaceId === input.spaceId && x.purpose !== 'bench' && x.instanceId)\n /* 목록에서 못 찾아도(정지·비등록) 지정된 트윈은 그대로 조회한다 — 저널은 남아 있을 수 있다. */\n const instanceIds = rows.length ? rows.map((x: any) => x.instanceId as string) : input.instanceId ? [input.instanceId] : []\n const realityModes = [...new Set(rows.map((x: any) => String(x.realityMode ?? 'unknown')))]\n return { instanceIds, realityModes }\n}\n\n/**\n * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.\n *\n * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).\n */\nexport async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput> {\n const minutes = Math.max(1, Math.min(24 * 60, Number(input.windowMinutes) || 60))\n const lookbackMs = Math.max(0, Math.min(24 * 60, input.lookbackMinutes ?? 60)) * 60_000\n\n const { instanceIds, realityModes } = await resolveTargets(input)\n const scope = { spaceId: input.spaceId, instanceIds, realityModes }\n\n /* 끝 시각 — 지정이 없으면 대상들의 마지막 기록. 그것도 없으면 측정할 것이 없다. */\n let basis: 'given' | 'latest-event' = 'given'\n let toMs = input.toTime ? Date.parse(input.toTime) : NaN\n if (!Number.isFinite(toMs)) {\n const latest = await latestEventTime(input.domainId, instanceIds)\n if (!latest) {\n const now = Date.now()\n return {\n window: {\n fromTime: new Date(now - minutes * 60_000).toISOString(),\n toTime: new Date(now).toISOString(),\n minutes,\n basis: 'latest-event'\n },\n measured: false,\n scope,\n note:\n instanceIds.length === 0\n ? 'no running twin in this space — nothing to measure.'\n : 'no operational events in the journal yet — there is nothing to measure (this is NOT \"zero throughput\").'\n }\n }\n toMs = Date.parse(latest)\n basis = 'latest-event'\n }\n\n const fromMs = input.fromTime ? Date.parse(input.fromTime) : toMs - minutes * 60_000\n if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) {\n throw new Error('invalid window: fromTime must be a valid time before toTime')\n }\n\n const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs)\n\n /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */\n const areaMap =\n input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} }\n const kpi = foldKpi(events, { fromMs, toMs }, {\n groupBy: input.groupBy,\n nodeArea: areaMap.nodeArea,\n groupLimit: input.groupLimit\n })\n const window = {\n fromTime: new Date(fromMs).toISOString(),\n toTime: new Date(toMs).toISOString(),\n minutes: Math.round((toMs - fromMs) / 60_000),\n basis\n }\n if (kpi.eventsInWindow === 0) {\n return {\n window,\n measured: false,\n scope,\n note: 'no operational events in this window — nothing to measure (this is NOT \"zero throughput\").'\n }\n }\n\n /* 구간별 값 — 쪼개기 규칙은 순수 모듈이 소유한다(테스트 가능). 여기서는 시각을 ISO 로 옮기기만. */\n const rawBuckets = foldKpiBuckets(events, { fromMs, toMs }, Number(input.buckets) || 0)\n const buckets = rawBuckets.length\n ? rawBuckets.map(b => ({\n fromTime: new Date(b.fromMs).toISOString(),\n toTime: new Date(b.toMs).toISOString(),\n tasks: b.tasks,\n orders: b.orders,\n workP50Ms: b.workP50Ms\n }))\n : undefined\n\n /* 비교 구간 — **같은 길이·같은 규칙**으로 한 번 더 접는다. 기록이 없으면 delta 를 만들지 않는다\n * (없는 것을 0 으로 그리면 \"변화 없음\" 이라는 거짓이 된다). */\n let comparison: TwinKpiOutput['comparison']\n /* 관점 × 비교 교차 — 축별 delta 와 **사라진 축**(전에 있었고 지금 없는 것). */\n let crossed: ReturnType<typeof crossGroups> | undefined\n /* 비교 구간 조회도 상한에 걸릴 수 있다 — 그 경우 delta 가 실제보다 작아 보인다. */\n let prevCappedFlag = false\n if (input.compareTo === 'previous' || input.compareTo === 'yesterday') {\n const w = shiftWindow(fromMs, toMs, input.compareTo)\n const { events: prevEvents, capped: prevCapped } = await fetchEvents(\n input.domainId,\n instanceIds,\n w.fromMs,\n w.toMs,\n lookbackMs\n )\n /* 축이 요청됐으면 비교 구간도 **같은 축**으로 접는다 — 교차(\"구역별로 어제 대비\")를 내기 위해.\n * 상한은 축이 서로 어긋나지 않게 넉넉히 둔다(현재 축이 상한 안에 있는데 비교 축에서 밀려나면\n * 있는데 없다고 말하게 된다). */\n const prev = foldKpi(prevEvents, w, {\n groupBy: input.groupBy,\n nodeArea: areaMap.nodeArea,\n groupLimit: input.groupBy ? 100 : undefined\n })\n prevCappedFlag = prevCapped\n if (input.groupBy && kpi.groups && prev.groups && prev.eventsInWindow > 0) {\n crossed = crossGroups(kpi.groups.items, prev.groups.items)\n }\n const base = {\n basis: input.compareTo,\n window: { fromTime: new Date(w.fromMs).toISOString(), toTime: new Date(w.toMs).toISOString() }\n }\n comparison =\n prev.eventsInWindow === 0\n ? { ...base, measured: false }\n : {\n ...base,\n measured: true,\n throughput: prev.throughput,\n workTime: prev.workTime,\n leadTime: prev.leadTime,\n waitTime: prev.waitTime,\n delta: {\n tasks: kpi.throughput.tasks - prev.throughput.tasks,\n orders: kpi.throughput.orders - prev.throughput.orders,\n workP50Ms: deltaP50(kpi.workTime, prev.workTime),\n leadP50Ms: deltaP50(kpi.leadTime, prev.leadTime),\n waitP50Ms: deltaP50(kpi.waitTime, prev.waitTime)\n }\n }\n }\n\n /* 관점 값은 시각 변환이 없어 그대로 통과한다. 붙이는 것은 ① 사람이 읽는 이름과 ② 축이 통째로\n * 비었을 때의 이유다 — \"구역별 0건\" 과 \"노드에 구역이 안 붙어 있다\" 는 완전히 다른 상황이다. */\n const groups = kpi.groups\n ? {\n by: kpi.groups.by,\n items: (crossed?.items ?? kpi.groups.items).map(g => ({\n ...g,\n ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})\n })),\n ...(crossed?.disappeared.length\n ? {\n disappeared: crossed.disappeared.map(d => ({\n ...d,\n ...(areaMap.labels[d.key] ? { label: areaMap.labels[d.key] } : {})\n }))\n }\n : {}),\n ...(kpi.groups.truncated\n ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }\n : {}),\n ...(input.groupBy === 'area' &&\n Object.keys(areaMap.nodeArea).length === 0 &&\n kpi.groups.items.some(g => g.key === 'unknown')\n ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT \"no work in areas\".' }\n : {})\n }\n : undefined\n\n /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */\n const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi\n return {\n window,\n measured: true,\n scope,\n ...(capped || prevCappedFlag ? { eventsCapped: true } : {}),\n ...rest,\n ...(buckets ? { buckets } : {}),\n ...(groups ? { groups } : {}),\n ...(comparison ? { comparison } : {})\n }\n}\n"]}
1
+ {"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 { hierarchyOf } from '@operato/twin-kernel'\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 { TwinTarget } from '../service/twin-target/twin-target.js'\nimport { judgeTargets, judgeGroupTargets, scopedTargets, type ScopedTargets, type TargetVerdict } from './kpi-target.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 * **목표 대비 판정** — 숫자를 판단으로 바꾸는 값(§kpi-target).\n *\n * 목표가 걸린 지표만 들어온다. 목표가 없거나 그 구간에 값이 없는 지표는 **여기 나타나지 않는다** —\n * 전부 \"목표 없음\" 으로 채우면 화면이 회색 노이즈가 되고, 없는 값을 0 으로 놓으면 \"목표 크게 미달\"\n * 이라는 없는 사실이 만들어진다.\n */\n targets?: TargetVerdict[]\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 * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `locations[].parentId` 가\n * 마스터 계층에서 온 소속이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.\n *\n * **한 홉만 보면 안 된다.** `parentId` 는 구역일 수도 있고 중간 단(라인·셀)일 수도 있다. 라인이 끼면\n * 한 홉짜리 코드는 \"구역이 LINE-1\" 이라고 답하고, 그 id 는 구역 목록에 없으므로 그 아래 스테이션이\n * **집계에서 조용히 빠진다**(에러 없이 틀린 숫자). 그래서 커널 `hierarchyOf` 로 사슬을 끝까지 걷는다 —\n * 걷는 규칙은 커널에 한 벌뿐이고 소비처는 그것만 부른다.\n */\n/**\n * 교대 축의 재료 — **교대 선언과 현장의 시각 기준.**\n *\n * 저널에는 시각만 있고 교대가 없다(일부러 그렇게 뒀다 — 교대는 파생이라 찍어 두면 나중에 교대 시간을\n * 고쳤을 때 과거가 옛 구분으로 굳는다). 그래서 폴드가 계산할 재료를 여기서 모은다.\n *\n * 선언의 출처는 **보드의 설비**다. 여러 설비가 서로 다른 교대를 가질 수 있지만, 축은 하나여야 하므로\n * **가장 많이 쓰이는 선언 한 벌**을 쓴다 — 3교대 공장은 전 설비가 같은 교대이므로 이것이 곧 정답이고,\n * 자원마다 교대가 다른 현장에서는 이 축이 거칠다는 뜻이다(그 사실을 여기 적어 둔다).\n *\n * 시각 기준은 보드에 실려 있다(`utcOffsetMinutes` — 공간이 갖는 값을 기동 시 호스트가 풀어 넣는다).\n * 없으면 UTC 로 읽고, 그 기본값은 커널 계약에 밝혀져 있다.\n */\n/**\n * 이 범위에 걸린 운영 목표 — **좁은 선언이 넓은 선언을 덮는다.**\n *\n * 공간 전체 목표 위에 인스턴스 목표를 얹을 수 있어야 예외를 표현할 수 있다(\"이 라인만 다르다\").\n * 같은 지표에 둘 다 있으면 인스턴스가 이긴다.\n */\nasync function loadTargets(\n domainId: string,\n spaceId: string | undefined,\n instanceIds: string[],\n axis?: string\n): Promise<ScopedTargets> {\n /* 조회는 **가져오기만** 한다 — 어느 목표가 이기는지는 순수 함수(`scopedTargets`)가 정한다.\n 그 규칙이 DB 접근 뒤에 있으면 화면을 눌러 봐야만 확인된다(그리고 실제로 확인되지 않았다). */\n const rows = await getRepository(TwinTarget).find({ where: { domain: { id: domainId } } }).catch(() => [])\n return scopedTargets(rows, { spaceId, instanceIds, axis })\n}\n\nasync function shiftContext(\n domainId: string,\n instanceIds: string[]\n): Promise<{ shifts?: any[]; utcOffsetMinutes?: number }> {\n if (!instanceIds.length) return {}\n const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, instanceId: In(instanceIds) } })\n const tally = new Map<string, { decl: any[]; n: number }>()\n let utcOffsetMinutes: number | undefined\n for (const row of rows) {\n const board = row.board as any\n if (utcOffsetMinutes === undefined && typeof board?.utcOffsetMinutes === 'number') utcOffsetMinutes = board.utcOffsetMinutes\n for (const e of (board?.equipment ?? []) as any[]) {\n const decl = e?.workCalendar\n if (!Array.isArray(decl) || !decl.length) continue\n const key = JSON.stringify(decl)\n const hit = tally.get(key)\n if (hit) hit.n++\n else tally.set(key, { decl, n: 1 })\n }\n }\n let best: { decl: any[]; n: number } | undefined\n for (const v of tally.values()) if (!best || v.n > best.n) best = v\n return { ...(best ? { shifts: best.decl } : {}), ...(utcOffsetMinutes !== undefined ? { utcOffsetMinutes } : {}) }\n}\n\nasync function locationAreaMap(\n domainId: string,\n instanceIds: string[],\n spaceId?: string\n): Promise<{ locationArea: Record<string, string>; labels: Record<string, string> }> {\n const locationArea: 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 const locations = (((row.board as any)?.locations ?? []) as any[]).filter(n => n?.id)\n if (!locations.length) continue\n /* 순환은 hierarchyOf 가 던진다 — 한 트윈의 잘못된 계층이 다른 트윈의 집계까지 죽이지 않게 여기서 가둔다. */\n let h\n try {\n h = hierarchyOf({ locations })\n } catch (e: any) {\n console.warn(`[twin-kpi] \"${row.instanceId}\": location hierarchy is broken — area rollup skipped for this twin: ${e?.message}`)\n continue\n }\n for (const n of locations) {\n const area = h.rollupOf(String(n.id))\n if (area) locationArea[String(n.id)] = area // 구역 미상은 넣지 않는다 — 없는 구역을 만들지 않는다\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 { locationArea, 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 locationAreaMap(input.domainId, instanceIds, input.spaceId) : { locationArea: {}, labels: {} }\n const shiftCtx = input.groupBy === 'shift' ? await shiftContext(input.domainId, instanceIds) : {}\n const kpi = foldKpi(events, { fromMs, toMs }, {\n groupBy: input.groupBy,\n locationArea: areaMap.locationArea,\n ...shiftCtx,\n groupLimit: input.groupLimit\n })\n /*\n * 목표를 **폴드 뒤, 그룹 조립 앞**에 읽는다 — 전체 판정과 축별 판정이 같은 목록에서 나와야\n * 두 곳의 임계가 갈라지지 않는다. 폴드 자체는 목표를 모른다(사실 층과 판정 층의 분리).\n */\n const scoped = await loadTargets(input.domainId, input.spaceId, instanceIds, input.groupBy)\n const verdicts = judgeTargets(kpi, scoped.overall)\n const groupTargets = scoped.byAxisKey\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 locationArea: areaMap.locationArea,\n ...shiftCtx, // 비교 구간도 **같은 교대 정의**로 접는다(정의가 다르면 축이 어긋난다)\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 /* 축의 이 값에 목표가 걸려 있으면 판정을 함께 낸다 — \"이 구역만 목표가 다르다\" 를\n 표에서 바로 볼 수 있게. 목표가 없는 축값에는 필드를 만들지 않는다. */\n const gt = judgeGroupTargets(g as any, groupTargets[g.key])\n return {\n ...g,\n ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {}),\n ...(gt.length ? { targets: gt } : {})\n }\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.locationArea).length === 0 &&\n kpi.groups.items.some(g => g.key === 'unknown')\n ? { note: 'locations 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 /*\n * 목표 대비 판정 — **사실 계산 뒤에 얹는다.**\n *\n * 폴드는 목표를 모른다(사실만 낸다). 목표를 아는 순간 \"세는 규칙\" 과 \"잘했나 판정하는 규칙\" 이\n * 한 함수에 섞이고, 목표가 바뀔 때마다 사실 계산을 건드리게 된다. 그래서 조회 층에서 목표를\n * 실어 오고, 판정은 순수 함수(`judgeTargets`)가 한다.\n *\n * **목표가 없으면 판정하지 않는다** — 필드 자체를 만들지 않는다. 전부 \"목표 없음\" 으로 채우면\n * 화면이 회색 노이즈가 되고, 목표가 있는 지표가 묻힌다.\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 ...(verdicts.length ? { targets: verdicts } : {}),\n ...(comparison ? { comparison } : {})\n }\n}\n"]}