/** * 报表分析的聚合逻辑:全部是纯函数,输入「任务明细 + 筛选条件」,输出页面直接渲染的结构。 * * 为什么单独一份而不是写进各页面:同一个「按科室的完成率」在总览的汇总矩阵、 * 时效分析的分组条形榜、患者结构分析的院区分布里都要用;各页各写一遍, * 迟早出现「同一个筛选条件,两个页面算出不同的数」——这种不一致在界面上 * 没有任何提示,只能靠人盯着两个页面比对才能发现。 * * 纯函数还有个直接好处:不依赖 DOM 与 Vue,可以用 Node 直接跑断言(见验证脚本)。 */ import type { ReportTaskRow } from '@/api/workbench/report-analytics' import type { SigningMethod, SigningTaskStatus } from '@/api/workbench/types' import type { ReportBarItem, ReportFilterForm, ReportGroupRow, ReportPatientStructure, ReportStatusCounts, ReportSummaryDimension, ReportTimelinessMetrics, ReportTrendPoint, } from './types' /** 超 24 小时(分钟):时效分析的「24 小时内完成率」分界 */ export const WITHIN_24H_MINUTES = 1440 /** 超 48 小时(分钟):超过签署链接有效期,算「超期完成」 */ export const OVERDUE_MINUTES = 2880 /** 趋势图最多画多少根柱子:自定义区间可能跨好几个月,画不下也没人看 */ const MAX_TREND_DAYS = 60 export const METHOD_LABELS: Record = { pad: '手写板', sms: '短信线上', } /** * 状态文案。六种状态与后端枚举一一对应,不映射成原型里的「待科室确认」—— * 那个状态在后端枚举里不存在,硬映射会让报表与签署记录页对同一个任务给出不同说法。 */ export const STATUS_LABELS: Record = { pending: '待签署', signing: '签署中', signed: '已签署', expired: '已超时', void: '已作废', failed: '处理失败', } /** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */ export const STATUS_COLUMNS: SigningTaskStatus[] = [ 'signed', 'pending', 'signing', 'expired', 'void', 'failed', ] export const SUMMARY_DIMENSION_LABELS: Record = { department: '科室', category: '文档类别', method: '签署方式', campus: '院区', } /** 年龄段分档:顺序固定,不按数量排 —— 年龄天然有序,重排反而看不出分布形状 */ const AGE_BUCKETS: { label: string; match: (age: number) => boolean }[] = [ { label: '0-17 岁', match: (age) => age < 18 }, { label: '18-40 岁', match: (age) => age >= 18 && age <= 40 }, { label: '41-60 岁', match: (age) => age >= 41 && age <= 60 }, { label: '61 岁以上', match: (age) => age > 60 }, ] function pad(value: number): string { return String(value).padStart(2, '0') } /** 把 `YYYY-MM-DD HH:mm` 截成日期键 */ export function dateKeyOf(moment: string): string { return moment.slice(0, 10) } function dateKeyOfDate(date: Date): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } /** 日期键 → 坐标轴标签 `M/D` */ export function axisLabelOf(dateKey: string): string { const [, month, day] = dateKey.split('-') return `${Number(month)}/${Number(day)}` } /** 任务状态 → 六类计数里的字段名:状态枚举与计数字段一一对应,直接索引即可 */ export function emptyCounts(): ReportStatusCounts { return { total: 0, signed: 0, pending: 0, signing: 0, expired: 0, void: 0, failed: 0 } } export function addToCounts(counts: ReportStatusCounts, status: ReportTaskRow['status']): void { counts.total += 1 counts[status] += 1 } /** 完成率:已签署 ÷ 发起。发起为 0 时按 0 计,不返回 NaN */ export function completionRateOf(counts: ReportStatusCounts): number { return counts.total ? Math.round((counts.signed / counts.total) * 100) : 0 } /** * 解析筛选的时间窗口,返回闭区间 `{ from, to }`(都是 `YYYY-MM-DD`)。 * * 返回 null 表示「不做时间过滤」:自定义区间只填了一头时属于「条件还没填完」, * 这时既不该按半截条件筛,也不该悄悄套一个默认窗口 —— 由页面禁用查询按钮, * 这里只是给出一个确定的行为。 */ export function resolveDateWindow( filter: ReportFilterForm, today = new Date(), ): { from: string; to: string } | null { const to = dateKeyOfDate(today) if (filter.dateRange === 'custom') { if (!filter.customFrom || !filter.customTo) { return null } // 起点晚于终点时按用户填的两个端点反着理解,比返回空结果友好 return filter.customFrom <= filter.customTo ? { from: filter.customFrom, to: filter.customTo } : { from: filter.customTo, to: filter.customFrom } } const from = new Date(today) if (filter.dateRange === 'month') { from.setDate(1) } else { const days = filter.dateRange === '7d' ? 7 : 14 from.setDate(from.getDate() - (days - 1)) } from.setHours(0, 0, 0, 0) return { from: dateKeyOfDate(from), to } } /** 按筛选条件过滤任务明细 */ export function filterReportTasks( tasks: readonly ReportTaskRow[], filter: ReportFilterForm, today = new Date(), ): ReportTaskRow[] { const window = resolveDateWindow(filter, today) return tasks.filter((task) => { const day = dateKeyOf(task.createdAt) if (window && (day < window.from || day > window.to)) { return false } if (filter.campus !== 'all' && task.campus !== filter.campus) { return false } if (filter.department && task.department !== filter.department) { return false } if (filter.category && task.category !== filter.category) { return false } if (filter.method !== 'all' && task.method !== filter.method) { return false } return true }) } export function countByStatus(tasks: readonly ReportTaskRow[]): ReportStatusCounts { const counts = emptyCounts() tasks.forEach((task) => addToCounts(counts, task.status)) return counts } /** * 每日趋势:同一天分别统计发起量与完成量。 * * 窗口天数由 `window` 决定,而不是「有数据的那几天」—— 中间没有任务的日期 * 也要占一根空柱,否则横轴会被压缩,趋势的疏密就看不出真实节奏了。 */ export function buildTrend( tasks: readonly ReportTaskRow[], window: { from: string; to: string } | null, ): ReportTrendPoint[] { const createdMap = new Map() const signedMap = new Map() tasks.forEach((task) => { const created = dateKeyOf(task.createdAt) createdMap.set(created, (createdMap.get(created) ?? 0) + 1) if (task.signedAt) { const signed = dateKeyOf(task.signedAt) signedMap.set(signed, (signedMap.get(signed) ?? 0) + 1) } }) const keys = window ? enumerateDays(window.from, window.to) : [...createdMap.keys()].sort() return keys.map((key) => ({ key, label: axisLabelOf(key), created: createdMap.get(key) ?? 0, signed: signedMap.get(key) ?? 0, })) } /** 枚举闭区间内的每一天,超过 MAX_TREND_DAYS 时只保留最后 N 天 */ function enumerateDays(from: string, to: string): string[] { const keys: string[] = [] const cursor = new Date(`${from}T00:00:00`) const end = new Date(`${to}T00:00:00`) // 上限兜底:自定义区间传个跨年的范围时,别把浏览器画死 while (cursor <= end && keys.length < 400) { keys.push(dateKeyOfDate(cursor)) cursor.setDate(cursor.getDate() + 1) } return keys.length > MAX_TREND_DAYS ? keys.slice(-MAX_TREND_DAYS) : keys } /** 分组键:四种维度共用一套取值口径 */ export function groupKeyOf(task: ReportTaskRow, dimension: ReportSummaryDimension): string { if (dimension === 'category') { return task.category || '其他' } if (dimension === 'method') { return METHOD_LABELS[task.method] } if (dimension === 'campus') { return task.campus || '其他' } return task.department || '其他' } /** 按维度分组统计,按发起量倒序(原型口径:最大的分组排最前) */ export function groupTasks( tasks: readonly ReportTaskRow[], dimension: ReportSummaryDimension, ): ReportGroupRow[] { const buckets = new Map() tasks.forEach((task) => { const key = groupKeyOf(task, dimension) const counts = buckets.get(key) ?? emptyCounts() addToCounts(counts, task.status) buckets.set(key, counts) }) return [...buckets.entries()] .map(([key, counts]) => ({ key, ...counts, completionRate: completionRateOf(counts), })) .sort((a, b) => b.total - a.total || a.key.localeCompare(b.key, 'zh-Hans-CN')) } /** 排行条形榜:按数量倒序,可截断(文档 TOP10) */ export function buildRanking( tasks: readonly ReportTaskRow[], keyOf: (task: ReportTaskRow) => string, limit?: number, ): ReportBarItem[] { const buckets = new Map() tasks.forEach((task) => { const key = keyOf(task) buckets.set(key, (buckets.get(key) ?? 0) + 1) }) const items = [...buckets.entries()] .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label, 'zh-Hans-CN')) return limit === undefined ? items : items.slice(0, limit) } /** * 时效指标。 * * 样本口径:只有**已签署**的任务参与平均时长与两个比率的分母。 * 把未完成任务也算进去会把平均值稀释成一个没有意义的数 —— 它们不是「签得快」, * 而是「还没签完」。所以样本为 0 时三个指标返回 null,页面显示「—」而不是 0。 */ export function buildTimeliness(tasks: readonly ReportTaskRow[]): ReportTimelinessMetrics { const signed = tasks.filter((task) => task.signMinutes !== undefined) if (!signed.length) { return { averageMinutes: null, within24hRate: null, overdueRate: null, expiredTaskCount: tasks.filter((task) => task.status === 'expired').length, signedSampleCount: 0, } } const total = signed.reduce((sum, task) => sum + (task.signMinutes ?? 0), 0) const within24h = signed.filter((task) => (task.signMinutes ?? 0) <= WITHIN_24H_MINUTES).length const overdue = signed.filter((task) => (task.signMinutes ?? 0) > OVERDUE_MINUTES).length return { averageMinutes: Math.round(total / signed.length), within24hRate: Math.round((within24h / signed.length) * 100), overdueRate: Math.round((overdue / signed.length) * 100), expiredTaskCount: tasks.filter((task) => task.status === 'expired').length, signedSampleCount: signed.length, } } /** 各科室平均签署时长,按均值倒序;颜色按 24h / 48h 阈值分级 */ export function buildDurationByDepartment(tasks: readonly ReportTaskRow[]): ReportBarItem[] { const sum = new Map() tasks.forEach((task) => { if (task.signMinutes === undefined) { return } const key = task.department || '其他' const bucket = sum.get(key) ?? { total: 0, count: 0 } bucket.total += task.signMinutes bucket.count += 1 sum.set(key, bucket) }) return [...sum.entries()] .map(([label, bucket]) => { const average = Math.round(bucket.total / bucket.count) return { label, value: average, display: formatDuration(average), color: durationColor(average), } }) .sort((a, b) => b.value - a.value || a.label.localeCompare(b.label, 'zh-Hans-CN')) } /** 每日平均签署时长(分钟),按完成日期归集 */ export function buildDailyAverageDuration( tasks: readonly ReportTaskRow[], window: { from: string; to: string } | null, ): ReportBarItem[] { const sum = new Map() tasks.forEach((task) => { if (task.signMinutes === undefined || !task.signedAt) { return } const key = dateKeyOf(task.signedAt) const bucket = sum.get(key) ?? { total: 0, count: 0 } bucket.total += task.signMinutes bucket.count += 1 sum.set(key, bucket) }) const keys = window ? enumerateDays(window.from, window.to) : [...sum.keys()].sort() return keys.map((key) => { const bucket = sum.get(key) return { label: axisLabelOf(key), value: bucket ? Math.round(bucket.total / bucket.count) : 0, } }) } /** 超期完成任务:完成用时超过 48 小时的已签署任务,用时长的排前面 */ export function buildOverdueTasks(tasks: readonly ReportTaskRow[]): ReportTaskRow[] { return tasks .filter((task) => (task.signMinutes ?? 0) > OVERDUE_MINUTES) .sort((a, b) => (b.signMinutes ?? 0) - (a.signMinutes ?? 0)) } /** 患者结构:年龄段固定顺序,其余按数量倒序(与原型一致) */ export function buildPatientStructure(tasks: readonly ReportTaskRow[]): ReportPatientStructure { // 年龄段只扫一遍:四档各 filter 一次是 4 倍工作量,而且四档之间还容易写歪边界 const ageCounts = AGE_BUCKETS.map(() => 0) tasks.forEach((task) => { const index = AGE_BUCKETS.findIndex((bucket) => bucket.match(task.age)) if (index >= 0) { ageCounts[index] += 1 } }) const age = AGE_BUCKETS.map((bucket, index) => ({ label: bucket.label, value: ageCounts[index] ?? 0, display: `${ageCounts[index] ?? 0} 人次`, })) const total = tasks.length || 1 const sex = buildRanking(tasks, (task) => task.sex).map((item) => ({ ...item, display: `${item.value} 人次 · ${Math.round((item.value / total) * 100)}%`, })) const visitType = buildRanking(tasks, (task) => task.visitType).map((item) => ({ ...item, display: `${item.value} 人次`, })) const campus = buildRanking(tasks, (task) => task.campus || '其他').map((item) => ({ ...item, display: `${item.value} 人次`, })) return { age, sex, visitType, campus } } /** 时长展示:对齐原型口径(不足 1 小时用分钟,超过用小时且保留一位小数) */ export function formatDuration(minutes: number | null | undefined): string { if (minutes === null || minutes === undefined || Number.isNaN(minutes)) { return '—' } if (minutes < 60) { return `${minutes} 分钟` } const hours = minutes / 60 return `${hours < 10 ? Math.round(hours * 10) / 10 : Math.round(hours)} 小时` } /** 时长分级配色:24 小时内绿、48 小时内橙、超过红 */ export function durationColor(minutes: number): string { if (minutes <= WITHIN_24H_MINUTES) { return '#0f9d6c' } return minutes <= OVERDUE_MINUTES ? '#d9821f' : '#d0534f' } /** 完成率配色:≥90% 绿、≥80% 橙、其余红(原型口径) */ export function completionRateColor(rate: number): string { if (rate >= 90) { return '#0f9d6c' } return rate >= 80 ? '#d9821f' : '#d0534f' } /** 条形榜调色板(与原型一致) */ export const BAR_PALETTE = ['#0e6e8c', '#18a0c0', '#0f9d6c', '#d9821f', '#7a4dbb', '#d0534f'] /** 取条形颜色:显式给了就用,否则按调色板轮换 */ export function barColorOf(item: ReportBarItem, index: number): string { return item.color ?? BAR_PALETTE[index % BAR_PALETTE.length] ?? '#0e6e8c' }