warningResponseApi.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. /** 预警处置模式三块聚合接口(票 19)。 */
  2. import { districtQuery, industryQuery } from '../domain/filterOptions.js'
  3. import { toEventAnalysis, toEventHistory, toHandlingEfficiency } from '../domain/warningResponsePanels.js'
  4. import { fetchSection, isEmptyBy } from './domainRequest.js'
  5. import { eventTimeWindow, historyWindows } from '../domain/eventRange.js'
  6. import { SECTION_STATE, section } from '../domain/sectionState.js'
  7. import { sortEventRows } from '../domain/eventList.js'
  8. import { PAGE_SIZE, serverPageView } from '../domain/pagination.js'
  9. import { REQUEST_REASON } from '../domain/requestReason.js'
  10. export const WARNING_RESPONSE_PATHS = {
  11. history: '/events/history-analysis',
  12. efficiency: '/events/handling-efficiency',
  13. analysis: '/events/multidimensional-analysis',
  14. }
  15. const freshnessOf = (payload) => (payload && payload.freshness && (payload.freshness.status || payload.freshness.state)) || ''
  16. const familyParam = (mode) => (mode === 'ALARM' ? 'ALARM' : 'WARNING')
  17. /** 多窗合并时每窗的取数上限(后端 `pageSize` 上限 1000):把每个窗已加载的逐事件行
  18. * 全取下再合并,前端对合并集合分页(多窗不存在全局页,见规范三分法)。 */
  19. const HISTORY_WINDOW_MAX_PAGE_SIZE = 1000
  20. /**
  21. * 请求范围类型 → 前端范围键(`7d`/`30d`/`90d`)。响应里的 `range.type` 是后端
  22. * `TREND_WINDOW`(显式起止时间时不回显预设范围),所以**范围键由请求侧带出**,
  23. * 供渲染层做「范围变化回第 1 页」的页码归属键(条文 5/6)。
  24. */
  25. const RANGE_KEY_BY_TYPE = { LAST_7_DAYS: '7d', LAST_30_DAYS: '30d', LAST_90_DAYS: '90d' }
  26. /**
  27. * 片区入参的落点(票 67,2026-10-22 逐端点实测)。
  28. *
  29. * 事件族四个端点里**只有** `/events/multidimensional-analysis`(预警分析)真收 `district`:
  30. * 非法值 400、`district=0/1` 的桶计数不同。另外三个(当前事件 / 历史事件 / 处置效率)
  31. * 实测**完全不认**该参数(非法值也 200、响应逐字节相同),所以本文件只给分析端点下发,
  32. * 其余一律不下发 —— 给不认的端点塞参数是「伪装的筛选」,比不筛更坏(票 67 的验收纪律)。
  33. *
  34. * 同一族里「一块随片区变、三块不变」这件事,界面上由 `domain/filterCaliber.js` 的
  35. * 面板口径说明如实交代(概览模式的行业筛选早就是同一形态的先例)。
  36. */
  37. function rangeParams(options) {
  38. const window = options.startTime && options.endTime ? options : eventTimeWindow(options.rangeType)
  39. return { range: options.rangeType, startTime: window.startTime, endTime: window.endTime }
  40. }
  41. /**
  42. * 历史事件(票 19 聚合接口;票 61 改成「单窗服务端分页 / 多窗合并前端分页」):
  43. *
  44. * · **单窗**(近 7 天):`page`/`pageSize=10` 进查询串,后端回当前页的逐事件行 +
  45. * `pagination`,`data.paging` 记 `mode:'server'`;
  46. * · **多窗**(近 30/90 天,单请求上限 31 天为后端契约):每窗取到上限 1000 条后
  47. * 合并去重并按后端同一条排序口径重排,`data.paging` 记 `mode:'merged'` 与
  48. * `windowCount`/`loaded`——没有「全局页」,页脚必须如实说明,不伪造页码。
  49. *
  50. * 顶层失败/窗口回显不一致的收口与既有逻辑一致;竞态(迟到响应丢弃)由 eventStore 的
  51. * token 纪律承担。
  52. */
  53. export async function fetchEventHistory(options = {}) {
  54. const range = rangeParams(options)
  55. const windows = historyWindows(range.startTime, range.endTime)
  56. const singleWindow = windows.length === 1
  57. const requestedPage = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
  58. const results = await Promise.all(
  59. windows.map((window) =>
  60. fetchHistoryWindow({
  61. ...options,
  62. ...window,
  63. page: singleWindow ? requestedPage : 1,
  64. pageSize: singleWindow ? PAGE_SIZE : HISTORY_WINDOW_MAX_PAGE_SIZE,
  65. }),
  66. ),
  67. )
  68. const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state))
  69. if (failed) return failed
  70. const hour = (value) => Math.floor(Date.parse(value) / 3600000)
  71. const mismatch = results.some((result, index) => result.mode === 'real' && result.data &&
  72. (hour(result.data.range?.start) !== hour(windows[index].startTime) || hour(result.data.range?.end) !== hour(windows[index].endTime)))
  73. if (mismatch) return section(SECTION_STATE.FAILED, { mode: 'real', reason: '历史接口回显窗口与所选范围不一致,请检查后端时间参数。' })
  74. const populated = results.filter((result) => result.data)
  75. if (!populated.length) return results[0] || section(SECTION_STATE.EMPTY)
  76. const rows = new Map()
  77. const events = new Map()
  78. for (const result of populated) {
  79. for (const row of result.data.trend || []) rows.set(row.bucket, row)
  80. for (const event of result.data.events || []) events.set(event.code, event)
  81. }
  82. const first = populated[0]
  83. const retained = results.some((result) => result.state === SECTION_STATE.STALE)
  84. const mergedEvents = sortEventRows([...events.values()])
  85. const rangeKey = RANGE_KEY_BY_TYPE[options.rangeType] || ''
  86. const paging = singleWindow
  87. ? { mode: 'server', windowCount: 1, rangeKey, ...serverPageView(first.data && first.data.pagination, mergedEvents.length) }
  88. : { mode: 'merged', windowCount: windows.length, loaded: mergedEvents.length, total: mergedEvents.length, rangeKey }
  89. return section(retained ? SECTION_STATE.STALE : SECTION_STATE.SUCCESS, {
  90. mode: first.mode,
  91. retained,
  92. data: {
  93. ...first.data,
  94. trend: [...rows.values()].sort((a, b) => String(a.bucket).localeCompare(String(b.bucket))),
  95. events: mergedEvents,
  96. range: { key: '', type: options.rangeType || '', start: range.startTime, end: range.endTime },
  97. missing: [...new Set(populated.flatMap((result) => result.data.missing || []))],
  98. // 单窗:服务端页码视图;多窗:只有「已加载集合」口径(无全局页)。
  99. paging,
  100. pagination: singleWindow ? (first.data && first.data.pagination) || null : null,
  101. },
  102. })
  103. }
  104. function fetchHistoryWindow(options = {}) {
  105. return fetchSection(WARNING_RESPONSE_PATHS.history, {
  106. params: {
  107. eventFamily: familyParam(options.mode),
  108. industry: industryQuery(options.industry),
  109. ...rangeParams(options),
  110. page: options.page,
  111. pageSize: options.pageSize,
  112. },
  113. toData: toEventHistory,
  114. isEmpty: isEmptyBy((payload) => {
  115. if (Array.isArray(payload && payload.rows)) return payload.rows.length === 0
  116. const section = payload && payload.sections && payload.sections.events
  117. return Array.isArray(section && section.data) ? section.data.length === 0 : Array.isArray(payload && payload.data) && payload.data.length === 0
  118. }),
  119. freshnessOf,
  120. config: options.config,
  121. fetchImpl: options.fetchImpl,
  122. })
  123. }
  124. /**
  125. * 聚合端点的**多窗取数 + 合并**(票 75)。
  126. *
  127. * 后端契约是「单请求 ≤31 天」(实测 90 天显式窗口 → `400 efficiency window must not exceed 31 days`),
  128. * 但按 30 天切窗后逐窗请求全部 200 —— 所以 90 天的支持放在**前端消费侧**:用与票 61
  129. * (历史事件多窗合并)同一段 `historyWindows` 切窗,逐窗取原始载荷,合并后再过一次域转换。
  130. *
  131. * 为什么在**原始 JSON 层**合并而不是域对象层:`toHandlingEfficiency`/`toEventAnalysis` 的
  132. * 输入是后端形状,合并产物只要长得像"一次大窗口的响应",两条既有转换就一行不用改 ——
  133. * 单窗路径与多窗路径最后走同一段域转换,不会分叉出两套口径。
  134. *
  135. * 失败语义(票 61 同一条纪律):任一窗失败 → 整分区 FAILED(不把 3/4 窗的数冒充 90 天);
  136. * 有窗 STALE → 整体 STALE(retained:有值但过期);全部空窗 → EMPTY(真的没有,不是失败)。
  137. *
  138. * @param {string} path 端点路径
  139. * @param {object} options 取数选项(mode/industry/config/fetchImpl)
  140. * @param {{toDomain: Function, merge: Function, isEmptyRaw: Function}} hooks
  141. */
  142. function fetchAggregated(path, options, hooks) {
  143. const base = { eventFamily: familyParam(options.mode), industry: industryQuery(options.industry) }
  144. // 票 67:只有实测收片区的端点才带 district(见上方说明);多窗路径的每个窗都带上。
  145. if (hooks.acceptsDistrict === true) base.district = districtQuery(options.scope)
  146. const range = rangeParams(options)
  147. const windows = historyWindows(range.startTime, range.endTime)
  148. if (windows.length <= 1) {
  149. return fetchSection(path, {
  150. params: { ...base, ...range },
  151. toData: hooks.toDomain,
  152. isEmpty: isEmptyBy(hooks.isEmptyRaw),
  153. freshnessOf,
  154. config: options.config,
  155. fetchImpl: options.fetchImpl,
  156. })
  157. }
  158. // 多窗:`toData` 走恒等(保留原始载荷给合并器),EMPTY 判定推迟到合并后统一做。
  159. return Promise.all(windows.map((window) => fetchSection(path, {
  160. params: { ...base, ...rangeParams({ ...options, ...window }) },
  161. toData: (payload) => payload,
  162. isEmpty: () => false,
  163. freshnessOf,
  164. config: options.config,
  165. fetchImpl: options.fetchImpl,
  166. }))).then((results) => {
  167. const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state))
  168. if (failed) return failed
  169. const populated = results.filter((result) => result.data)
  170. if (!populated.length) {
  171. return section(SECTION_STATE.EMPTY, { mode: 'multi-window', reason: REQUEST_REASON.UNAVAILABLE })
  172. }
  173. const merged = hooks.merge(populated.map((result) => result.data), { startTime: range.startTime, endTime: range.endTime })
  174. const retained = results.some((result) => result.state === SECTION_STATE.STALE)
  175. if (hooks.isEmptyRaw(merged)) {
  176. return section(SECTION_STATE.EMPTY, { mode: 'multi-window', retained })
  177. }
  178. return section(retained ? SECTION_STATE.STALE : SECTION_STATE.SUCCESS, {
  179. mode: 'multi-window',
  180. retained,
  181. data: hooks.toDomain(merged),
  182. missing: populated.flatMap((result) => (result.data && result.data.missing) || []),
  183. })
  184. })
  185. }
  186. /** 防御性计数(合并求和用;非数字按 0,不让一个脏字段拖垮整窗合并)。 */
  187. function countOf(value) {
  188. const n = Number(value)
  189. return Number.isFinite(n) ? n : 0
  190. }
  191. /**
  192. * 合并多个 handling-efficiency 载荷(票 75)。
  193. *
  194. * 逐字段口径(全部**可从各窗独立验证**,合并不发明新数):
  195. * · `durationTrend`:按时序拼接(窗口相接不重叠;仍按 `timeBucket` 去重防边界重复);
  196. * · `events`:各窗首页拼接(等级分布的"仅统计已返回页"标记由域层继续如实算);
  197. * · `summary` 各计数:求和;`handlingEfficiency` 用**合并后状态计数重算**
  198. * (分子=已结案(5),分母=可计入(0–6)——与后端 `stateCounts` 同一算法,不抄单窗百分比);
  199. * · `averageDuration`:按 `sampleCount` 加权平均(无样本如实 EMPTY,不画 0 冒充);
  200. * · `workOrderStateCounts`:按 `stateCode` 求和(字典名取首见窗)。
  201. *
  202. * 导出说明:闸门(t75)直接消费它断言合并口径 —— 合并规则只有这一份实现,
  203. * 闸门不复刻第二份(复刻会漂移:服务改了、闸门还在验旧规则)。
  204. */
  205. export function mergeEfficiencyPayloads(payloads, fullRange) {
  206. const list = payloads.filter(Boolean)
  207. const first = list[0] || {}
  208. let durationTrend = []
  209. const seenBuckets = new Set()
  210. let events = []
  211. let totalEvents = 0
  212. let linked = 0
  213. let missing = 0
  214. let handled = 0
  215. let inProgress = 0
  216. let cancelled = 0
  217. let weightedDuration = 0
  218. let durationSamples = 0
  219. const byState = new Map()
  220. for (const payload of list) {
  221. const summary = payload.summary || {}
  222. totalEvents += countOf(summary.totalEvents)
  223. linked += countOf(summary.linkedWorkOrderCount)
  224. missing += countOf(summary.missingWorkOrderCount)
  225. handled += countOf(summary.handledCount)
  226. inProgress += countOf(summary.inProgressCount)
  227. cancelled += countOf(summary.cancelledCount)
  228. const duration = summary.averageDuration || {}
  229. const samples = countOf(duration.sampleCount)
  230. if (samples > 0 && Number.isFinite(Number(duration.value))) {
  231. weightedDuration += Number(duration.value) * samples
  232. durationSamples += samples
  233. }
  234. for (const row of Array.isArray(payload.workOrderStateCounts) ? payload.workOrderStateCounts : []) {
  235. const previous = byState.get(row.stateCode)
  236. byState.set(row.stateCode, {
  237. stateCode: row.stateCode,
  238. stateName: (previous && previous.stateName) || row.stateName,
  239. count: ((previous && previous.count) || 0) + countOf(row.count),
  240. })
  241. }
  242. for (const row of Array.isArray(payload.durationTrend) ? payload.durationTrend : []) {
  243. if (seenBuckets.has(row.timeBucket)) continue
  244. seenBuckets.add(row.timeBucket)
  245. durationTrend.push(row)
  246. }
  247. if (Array.isArray(payload.events)) events = events.concat(payload.events)
  248. }
  249. durationTrend.sort((left, right) => String(left.timeBucket).localeCompare(String(right.timeBucket)))
  250. const stateCounts = [...byState.values()].sort((left, right) => left.stateCode - right.stateCode)
  251. const basis = (first.summary && first.summary.handlingEfficiency && first.summary.handlingEfficiency.basis)
  252. || 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS'
  253. const numerator = stateCounts.filter((row) => row.stateCode === 5).reduce((sum, row) => sum + row.count, 0)
  254. const denominator = stateCounts.filter((row) => row.stateCode >= 0 && row.stateCode <= 6).reduce((sum, row) => sum + row.count, 0)
  255. const handlingEfficiency = denominator > 0
  256. ? { value: (numerator / denominator).toFixed(4), numerator, denominator, state: 'AVAILABLE', reason: 'READY', basis }
  257. : { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS', basis }
  258. const averageDuration = durationSamples > 0
  259. ? { value: (weightedDuration / durationSamples).toFixed(2), unit: 'HOURS', sampleCount: durationSamples, state: 'AVAILABLE', reason: 'READY' }
  260. : { value: '0.00', unit: 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WORK_ORDERS' }
  261. return {
  262. ...first,
  263. range: { ...(first.range || {}), type: 'TREND_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime },
  264. durationTrend,
  265. events,
  266. workOrderStateCounts: stateCounts,
  267. summary: {
  268. ...(first.summary || {}),
  269. totalEvents,
  270. linkedWorkOrderCount: linked,
  271. missingWorkOrderCount: missing,
  272. handledCount: handled,
  273. inProgressCount: inProgress,
  274. cancelledCount: cancelled,
  275. handlingEfficiency,
  276. averageDuration,
  277. },
  278. }
  279. }
  280. /**
  281. * 合并多个 multidimensional-analysis 载荷(票 75)。
  282. *
  283. * `dimensions` 按 `dimensionCode` 合并(次序取首见窗——各窗维度集相同,只是数不同);
  284. * buckets 按 `bucketCode` 求和,`percentage` **重算**而不是把各窗百分比相加
  285. * (50% + 25% = 75% 是错的,分母变了)。
  286. *
  287. * 导出说明:同 `mergeEfficiencyPayloads` —— 合并规则单一实现,闸门直接消费。
  288. */
  289. export function mergeAnalysisPayloads(payloads, fullRange) {
  290. const list = payloads.filter(Boolean)
  291. const first = list[0] || {}
  292. const dimensions = new Map()
  293. let totalCount = 0
  294. for (const payload of list) {
  295. totalCount += countOf(payload.totalCount)
  296. for (const dimension of Array.isArray(payload.dimensions) ? payload.dimensions : []) {
  297. const entry = dimensions.get(dimension.dimensionCode) || {
  298. dimensionCode: dimension.dimensionCode,
  299. dimensionLabel: dimension.dimensionLabel,
  300. totalCount: 0,
  301. buckets: new Map(),
  302. bucketOrder: [],
  303. }
  304. entry.totalCount += countOf(dimension.totalCount)
  305. for (const bucket of Array.isArray(dimension.buckets) ? dimension.buckets : []) {
  306. const previous = entry.buckets.get(bucket.bucketCode)
  307. if (previous) {
  308. previous.count += countOf(bucket.count)
  309. } else {
  310. const copy = { ...bucket, count: countOf(bucket.count) }
  311. entry.buckets.set(bucket.bucketCode, copy)
  312. entry.bucketOrder.push(copy)
  313. }
  314. }
  315. dimensions.set(dimension.dimensionCode, entry)
  316. }
  317. }
  318. return {
  319. ...first,
  320. range: { ...(first.range || {}), type: 'ANALYSIS_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime },
  321. totalCount,
  322. dimensions: [...dimensions.values()].map((entry) => ({
  323. dimensionCode: entry.dimensionCode,
  324. dimensionLabel: entry.dimensionLabel,
  325. totalCount: entry.totalCount,
  326. buckets: entry.bucketOrder.map((bucket) => ({
  327. ...bucket,
  328. percentage: entry.totalCount > 0 ? ((bucket.count / entry.totalCount) * 100).toFixed(2) : '0.00',
  329. })),
  330. })),
  331. }
  332. }
  333. const EFFICIENCY_EMPTY = (payload) => {
  334. const family = payload && payload.familyMetrics
  335. return !payload || (!(payload.summary && payload.summary.handlingEfficiency) && !payload.handlingEfficiency && !(family && family.handlingEfficiency) && payload.averageResponseSeconds === undefined && payload.averageResponseDuration === undefined && !(payload.averageDuration && typeof payload.averageDuration === 'object'))
  336. }
  337. export function fetchHandlingEfficiency(options = {}) {
  338. return fetchAggregated(WARNING_RESPONSE_PATHS.efficiency, options, {
  339. toDomain: toHandlingEfficiency,
  340. merge: mergeEfficiencyPayloads,
  341. isEmptyRaw: EFFICIENCY_EMPTY,
  342. })
  343. }
  344. export function fetchEventAnalysis(options = {}) {
  345. return fetchAggregated(WARNING_RESPONSE_PATHS.analysis, options, {
  346. toDomain: toEventAnalysis,
  347. merge: mergeAnalysisPayloads,
  348. // 票 67:事件族里**唯一**收片区入参的端点(实测见 `rangeParams` 上方)。
  349. acceptsDistrict: true,
  350. isEmptyRaw: (payload) => {
  351. const dimensions = (payload && payload.dimensions) || payload || {}
  352. if (Array.isArray(dimensions)) return !dimensions.some((row) => Array.isArray(row.buckets) && row.buckets.length)
  353. return !['types', 'byType', 'levels', 'byLevel', 'industries', 'byIndustry', 'regions', 'byRegion'].some((key) => Array.isArray(dimensions[key]) && dimensions[key].length)
  354. },
  355. })
  356. }