484 lines
15 KiB
TypeScript
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'
|
|
}
|