Files
xh-medical-sign-web/clinical-web/src/views/reports/aggregate.ts
T

484 lines
15 KiB
TypeScript

/**
* 报表分析的聚合逻辑:全部是纯函数,输入「任务明细 + 筛选条件」,输出页面直接渲染的结构。
*
* 为什么单独一份而不是写进各页面:同一个「按科室的完成率」在总览的汇总矩阵、
* 时效分析的分组条形榜、患者结构分析的院区分布里都要用;各页各写一遍,
* 迟早出现「同一个筛选条件,两个页面算出不同的数」——这种不一致在界面上
* 没有任何提示,只能靠人盯着两个页面比对才能发现。
*
* 纯函数还有个直接好处:不依赖 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<SigningMethod, string> = {
pad: '手写板',
sms: '短信线上',
}
/**
* 状态文案。六种状态与后端枚举一一对应,不映射成原型里的「待科室确认」——
* 那个状态在后端枚举里不存在,硬映射会让报表与签署记录页对同一个任务给出不同说法。
*/
export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
/** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */
export const STATUS_COLUMNS: SigningTaskStatus[] = [
'signed',
'pending',
'signing',
'expired',
'void',
'failed',
]
export const SUMMARY_DIMENSION_LABELS: Record<ReportSummaryDimension, string> = {
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<string, number>()
const signedMap = new Map<string, number>()
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<string, ReportStatusCounts>()
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<string, number>()
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<string, { total: number; count: number }>()
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<string, { total: number; count: number }>()
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'
}