| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371 |
- /** 预警处置模式三块聚合接口(票 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)
- },
- })
- }
|