/** 预警处置模式三块聚合接口(票 19)。 */ import { districtQuery, industryQuery } from '../domain/filterOptions.js' import { toEventAnalysis, toEventHistory, toHandlingEfficiency } from '../domain/warningResponsePanels.js' import { fetchSection, isEmptyBy } from './domainRequest.js' import { eventTimeWindow, historyWindows } from '../domain/eventRange.js' import { SECTION_STATE, section } from '../domain/sectionState.js' import { sortEventRows } from '../domain/eventList.js' import { PAGE_SIZE, serverPageView } from '../domain/pagination.js' import { REQUEST_REASON } from '../domain/requestReason.js' export const WARNING_RESPONSE_PATHS = { history: '/events/history-analysis', efficiency: '/events/handling-efficiency', analysis: '/events/multidimensional-analysis', } const freshnessOf = (payload) => (payload && payload.freshness && (payload.freshness.status || payload.freshness.state)) || '' const familyParam = (mode) => (mode === 'ALARM' ? 'ALARM' : 'WARNING') /** 多窗合并时每窗的取数上限(后端 `pageSize` 上限 1000):把每个窗已加载的逐事件行 * 全取下再合并,前端对合并集合分页(多窗不存在全局页,见规范三分法)。 */ const HISTORY_WINDOW_MAX_PAGE_SIZE = 1000 /** * 请求范围类型 → 前端范围键(`7d`/`30d`/`90d`)。响应里的 `range.type` 是后端 * `TREND_WINDOW`(显式起止时间时不回显预设范围),所以**范围键由请求侧带出**, * 供渲染层做「范围变化回第 1 页」的页码归属键(条文 5/6)。 */ const RANGE_KEY_BY_TYPE = { LAST_7_DAYS: '7d', LAST_30_DAYS: '30d', LAST_90_DAYS: '90d' } /** * 片区入参的落点(票 67,2026-10-22 逐端点实测)。 * * 事件族四个端点里**只有** `/events/multidimensional-analysis`(预警分析)真收 `district`: * 非法值 400、`district=0/1` 的桶计数不同。另外三个(当前事件 / 历史事件 / 处置效率) * 实测**完全不认**该参数(非法值也 200、响应逐字节相同),所以本文件只给分析端点下发, * 其余一律不下发 —— 给不认的端点塞参数是「伪装的筛选」,比不筛更坏(票 67 的验收纪律)。 * * 同一族里「一块随片区变、三块不变」这件事,界面上由 `domain/filterCaliber.js` 的 * 面板口径说明如实交代(概览模式的行业筛选早就是同一形态的先例)。 */ function rangeParams(options) { const window = options.startTime && options.endTime ? options : eventTimeWindow(options.rangeType) return { range: options.rangeType, startTime: window.startTime, endTime: window.endTime } } /** * 历史事件(票 19 聚合接口;票 61 改成「单窗服务端分页 / 多窗合并前端分页」): * * · **单窗**(近 7 天):`page`/`pageSize=10` 进查询串,后端回当前页的逐事件行 + * `pagination`,`data.paging` 记 `mode:'server'`; * · **多窗**(近 30/90 天,单请求上限 31 天为后端契约):每窗取到上限 1000 条后 * 合并去重并按后端同一条排序口径重排,`data.paging` 记 `mode:'merged'` 与 * `windowCount`/`loaded`——没有「全局页」,页脚必须如实说明,不伪造页码。 * * 顶层失败/窗口回显不一致的收口与既有逻辑一致;竞态(迟到响应丢弃)由 eventStore 的 * token 纪律承担。 */ export async function fetchEventHistory(options = {}) { const range = rangeParams(options) const windows = historyWindows(range.startTime, range.endTime) const singleWindow = windows.length === 1 const requestedPage = Number.isInteger(options.page) && options.page > 0 ? options.page : 1 const results = await Promise.all( windows.map((window) => fetchHistoryWindow({ ...options, ...window, page: singleWindow ? requestedPage : 1, pageSize: singleWindow ? PAGE_SIZE : HISTORY_WINDOW_MAX_PAGE_SIZE, }), ), ) const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state)) if (failed) return failed const hour = (value) => Math.floor(Date.parse(value) / 3600000) const mismatch = results.some((result, index) => result.mode === 'real' && result.data && (hour(result.data.range?.start) !== hour(windows[index].startTime) || hour(result.data.range?.end) !== hour(windows[index].endTime))) if (mismatch) return section(SECTION_STATE.FAILED, { mode: 'real', reason: '历史接口回显窗口与所选范围不一致,请检查后端时间参数。' }) const populated = results.filter((result) => result.data) if (!populated.length) return results[0] || section(SECTION_STATE.EMPTY) const rows = new Map() const events = new Map() for (const result of populated) { for (const row of result.data.trend || []) rows.set(row.bucket, row) for (const event of result.data.events || []) events.set(event.code, event) } const first = populated[0] const retained = results.some((result) => result.state === SECTION_STATE.STALE) const mergedEvents = sortEventRows([...events.values()]) const rangeKey = RANGE_KEY_BY_TYPE[options.rangeType] || '' const paging = singleWindow ? { mode: 'server', windowCount: 1, rangeKey, ...serverPageView(first.data && first.data.pagination, mergedEvents.length) } : { mode: 'merged', windowCount: windows.length, loaded: mergedEvents.length, total: mergedEvents.length, rangeKey } return section(retained ? SECTION_STATE.STALE : SECTION_STATE.SUCCESS, { mode: first.mode, retained, data: { ...first.data, trend: [...rows.values()].sort((a, b) => String(a.bucket).localeCompare(String(b.bucket))), events: mergedEvents, range: { key: '', type: options.rangeType || '', start: range.startTime, end: range.endTime }, missing: [...new Set(populated.flatMap((result) => result.data.missing || []))], // 单窗:服务端页码视图;多窗:只有「已加载集合」口径(无全局页)。 paging, pagination: singleWindow ? (first.data && first.data.pagination) || null : null, }, }) } function fetchHistoryWindow(options = {}) { return fetchSection(WARNING_RESPONSE_PATHS.history, { params: { eventFamily: familyParam(options.mode), industry: industryQuery(options.industry), ...rangeParams(options), page: options.page, pageSize: options.pageSize, }, toData: toEventHistory, isEmpty: isEmptyBy((payload) => { if (Array.isArray(payload && payload.rows)) return payload.rows.length === 0 const section = payload && payload.sections && payload.sections.events return Array.isArray(section && section.data) ? section.data.length === 0 : Array.isArray(payload && payload.data) && payload.data.length === 0 }), freshnessOf, config: options.config, fetchImpl: options.fetchImpl, }) } /** * 聚合端点的**多窗取数 + 合并**(票 75)。 * * 后端契约是「单请求 ≤31 天」(实测 90 天显式窗口 → `400 efficiency window must not exceed 31 days`), * 但按 30 天切窗后逐窗请求全部 200 —— 所以 90 天的支持放在**前端消费侧**:用与票 61 * (历史事件多窗合并)同一段 `historyWindows` 切窗,逐窗取原始载荷,合并后再过一次域转换。 * * 为什么在**原始 JSON 层**合并而不是域对象层:`toHandlingEfficiency`/`toEventAnalysis` 的 * 输入是后端形状,合并产物只要长得像"一次大窗口的响应",两条既有转换就一行不用改 —— * 单窗路径与多窗路径最后走同一段域转换,不会分叉出两套口径。 * * 失败语义(票 61 同一条纪律):任一窗失败 → 整分区 FAILED(不把 3/4 窗的数冒充 90 天); * 有窗 STALE → 整体 STALE(retained:有值但过期);全部空窗 → EMPTY(真的没有,不是失败)。 * * @param {string} path 端点路径 * @param {object} options 取数选项(mode/industry/config/fetchImpl) * @param {{toDomain: Function, merge: Function, isEmptyRaw: Function}} hooks */ function fetchAggregated(path, options, hooks) { const base = { eventFamily: familyParam(options.mode), industry: industryQuery(options.industry) } // 票 67:只有实测收片区的端点才带 district(见上方说明);多窗路径的每个窗都带上。 if (hooks.acceptsDistrict === true) base.district = districtQuery(options.scope) const range = rangeParams(options) const windows = historyWindows(range.startTime, range.endTime) if (windows.length <= 1) { return fetchSection(path, { params: { ...base, ...range }, toData: hooks.toDomain, isEmpty: isEmptyBy(hooks.isEmptyRaw), freshnessOf, config: options.config, fetchImpl: options.fetchImpl, }) } // 多窗:`toData` 走恒等(保留原始载荷给合并器),EMPTY 判定推迟到合并后统一做。 return Promise.all(windows.map((window) => fetchSection(path, { params: { ...base, ...rangeParams({ ...options, ...window }) }, toData: (payload) => payload, isEmpty: () => false, freshnessOf, config: options.config, fetchImpl: options.fetchImpl, }))).then((results) => { const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state)) if (failed) return failed const populated = results.filter((result) => result.data) if (!populated.length) { return section(SECTION_STATE.EMPTY, { mode: 'multi-window', reason: REQUEST_REASON.UNAVAILABLE }) } const merged = hooks.merge(populated.map((result) => result.data), { startTime: range.startTime, endTime: range.endTime }) const retained = results.some((result) => result.state === SECTION_STATE.STALE) if (hooks.isEmptyRaw(merged)) { return section(SECTION_STATE.EMPTY, { mode: 'multi-window', retained }) } return section(retained ? SECTION_STATE.STALE : SECTION_STATE.SUCCESS, { mode: 'multi-window', retained, data: hooks.toDomain(merged), missing: populated.flatMap((result) => (result.data && result.data.missing) || []), }) }) } /** 防御性计数(合并求和用;非数字按 0,不让一个脏字段拖垮整窗合并)。 */ function countOf(value) { const n = Number(value) return Number.isFinite(n) ? n : 0 } /** * 合并多个 handling-efficiency 载荷(票 75)。 * * 逐字段口径(全部**可从各窗独立验证**,合并不发明新数): * · `durationTrend`:按时序拼接(窗口相接不重叠;仍按 `timeBucket` 去重防边界重复); * · `events`:各窗首页拼接(等级分布的"仅统计已返回页"标记由域层继续如实算); * · `summary` 各计数:求和;`handlingEfficiency` 用**合并后状态计数重算** * (分子=已结案(5),分母=可计入(0–6)——与后端 `stateCounts` 同一算法,不抄单窗百分比); * · `averageDuration`:按 `sampleCount` 加权平均(无样本如实 EMPTY,不画 0 冒充); * · `workOrderStateCounts`:按 `stateCode` 求和(字典名取首见窗)。 * * 导出说明:闸门(t75)直接消费它断言合并口径 —— 合并规则只有这一份实现, * 闸门不复刻第二份(复刻会漂移:服务改了、闸门还在验旧规则)。 */ export function mergeEfficiencyPayloads(payloads, fullRange) { const list = payloads.filter(Boolean) const first = list[0] || {} let durationTrend = [] const seenBuckets = new Set() let events = [] let totalEvents = 0 let linked = 0 let missing = 0 let handled = 0 let inProgress = 0 let cancelled = 0 let weightedDuration = 0 let durationSamples = 0 const byState = new Map() for (const payload of list) { const summary = payload.summary || {} totalEvents += countOf(summary.totalEvents) linked += countOf(summary.linkedWorkOrderCount) missing += countOf(summary.missingWorkOrderCount) handled += countOf(summary.handledCount) inProgress += countOf(summary.inProgressCount) cancelled += countOf(summary.cancelledCount) const duration = summary.averageDuration || {} const samples = countOf(duration.sampleCount) if (samples > 0 && Number.isFinite(Number(duration.value))) { weightedDuration += Number(duration.value) * samples durationSamples += samples } for (const row of Array.isArray(payload.workOrderStateCounts) ? payload.workOrderStateCounts : []) { const previous = byState.get(row.stateCode) byState.set(row.stateCode, { stateCode: row.stateCode, stateName: (previous && previous.stateName) || row.stateName, count: ((previous && previous.count) || 0) + countOf(row.count), }) } for (const row of Array.isArray(payload.durationTrend) ? payload.durationTrend : []) { if (seenBuckets.has(row.timeBucket)) continue seenBuckets.add(row.timeBucket) durationTrend.push(row) } if (Array.isArray(payload.events)) events = events.concat(payload.events) } durationTrend.sort((left, right) => String(left.timeBucket).localeCompare(String(right.timeBucket))) const stateCounts = [...byState.values()].sort((left, right) => left.stateCode - right.stateCode) const basis = (first.summary && first.summary.handlingEfficiency && first.summary.handlingEfficiency.basis) || 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' const numerator = stateCounts.filter((row) => row.stateCode === 5).reduce((sum, row) => sum + row.count, 0) const denominator = stateCounts.filter((row) => row.stateCode >= 0 && row.stateCode <= 6).reduce((sum, row) => sum + row.count, 0) const handlingEfficiency = denominator > 0 ? { value: (numerator / denominator).toFixed(4), numerator, denominator, state: 'AVAILABLE', reason: 'READY', basis } : { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS', basis } const averageDuration = durationSamples > 0 ? { value: (weightedDuration / durationSamples).toFixed(2), unit: 'HOURS', sampleCount: durationSamples, state: 'AVAILABLE', reason: 'READY' } : { value: '0.00', unit: 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WORK_ORDERS' } return { ...first, range: { ...(first.range || {}), type: 'TREND_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime }, durationTrend, events, workOrderStateCounts: stateCounts, summary: { ...(first.summary || {}), totalEvents, linkedWorkOrderCount: linked, missingWorkOrderCount: missing, handledCount: handled, inProgressCount: inProgress, cancelledCount: cancelled, handlingEfficiency, averageDuration, }, } } /** * 合并多个 multidimensional-analysis 载荷(票 75)。 * * `dimensions` 按 `dimensionCode` 合并(次序取首见窗——各窗维度集相同,只是数不同); * buckets 按 `bucketCode` 求和,`percentage` **重算**而不是把各窗百分比相加 * (50% + 25% = 75% 是错的,分母变了)。 * * 导出说明:同 `mergeEfficiencyPayloads` —— 合并规则单一实现,闸门直接消费。 */ export function mergeAnalysisPayloads(payloads, fullRange) { const list = payloads.filter(Boolean) const first = list[0] || {} const dimensions = new Map() let totalCount = 0 for (const payload of list) { totalCount += countOf(payload.totalCount) for (const dimension of Array.isArray(payload.dimensions) ? payload.dimensions : []) { const entry = dimensions.get(dimension.dimensionCode) || { dimensionCode: dimension.dimensionCode, dimensionLabel: dimension.dimensionLabel, totalCount: 0, buckets: new Map(), bucketOrder: [], } entry.totalCount += countOf(dimension.totalCount) for (const bucket of Array.isArray(dimension.buckets) ? dimension.buckets : []) { const previous = entry.buckets.get(bucket.bucketCode) if (previous) { previous.count += countOf(bucket.count) } else { const copy = { ...bucket, count: countOf(bucket.count) } entry.buckets.set(bucket.bucketCode, copy) entry.bucketOrder.push(copy) } } dimensions.set(dimension.dimensionCode, entry) } } return { ...first, range: { ...(first.range || {}), type: 'ANALYSIS_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime }, totalCount, dimensions: [...dimensions.values()].map((entry) => ({ dimensionCode: entry.dimensionCode, dimensionLabel: entry.dimensionLabel, totalCount: entry.totalCount, buckets: entry.bucketOrder.map((bucket) => ({ ...bucket, percentage: entry.totalCount > 0 ? ((bucket.count / entry.totalCount) * 100).toFixed(2) : '0.00', })), })), } } const EFFICIENCY_EMPTY = (payload) => { const family = payload && payload.familyMetrics return !payload || (!(payload.summary && payload.summary.handlingEfficiency) && !payload.handlingEfficiency && !(family && family.handlingEfficiency) && payload.averageResponseSeconds === undefined && payload.averageResponseDuration === undefined && !(payload.averageDuration && typeof payload.averageDuration === 'object')) } export function fetchHandlingEfficiency(options = {}) { return fetchAggregated(WARNING_RESPONSE_PATHS.efficiency, options, { toDomain: toHandlingEfficiency, merge: mergeEfficiencyPayloads, isEmptyRaw: EFFICIENCY_EMPTY, }) } export function fetchEventAnalysis(options = {}) { return fetchAggregated(WARNING_RESPONSE_PATHS.analysis, options, { toDomain: toEventAnalysis, merge: mergeAnalysisPayloads, // 票 67:事件族里**唯一**收片区入参的端点(实测见 `rangeParams` 上方)。 acceptsDistrict: true, isEmptyRaw: (payload) => { const dimensions = (payload && payload.dimensions) || payload || {} if (Array.isArray(dimensions)) return !dimensions.some((row) => Array.isArray(row.buckets) && row.buckets.length) return !['types', 'byType', 'levels', 'byLevel', 'industries', 'byIndustry', 'regions', 'byRegion'].some((key) => Array.isArray(dimensions[key]) && dimensions[key].length) }, }) }