feat(reports): 接入真实报表分析及分组明细与异步导出
This commit is contained in:
@@ -1,328 +1,427 @@
|
||||
/**
|
||||
* 报表分析(签署总览 / 签署量分析 / 签署时效分析 / 患者结构分析)的数据源。
|
||||
*
|
||||
* 现状:后端没有任何分析聚合接口,所以这四个菜单目前只有演示数据。
|
||||
* 这里刻意**返回任务级明细**而不是预先算好的聚合结果,原因是演示阶段聚合逻辑
|
||||
* 要能被页面与验证脚本反复调用(换筛选条件、下钻、导出都基于同一份明细)。
|
||||
*
|
||||
* ⚠️ 真实接入时不要照搬这个形状:一次分析查询不该把全量任务明细拉到浏览器里算。
|
||||
* 后端应按页面给出四个聚合接口(总览指标 + 趋势、签署量分组、时效指标 + 分组、
|
||||
* 患者结构分组),届时这个模块退化成「按查询参数取聚合结果」,
|
||||
* 页面侧的聚合函数随之删掉即可 —— 页面只消费聚合结果,不关心它从哪来。
|
||||
*
|
||||
* 演示数据的两条硬约束(都踩过):
|
||||
* 1. **确定性**:用固定种子的 PRNG,不用 Math.random。否则每次刷新数字都在跳,
|
||||
* 截图对不上、验证脚本没法断言「筛选后行数应该变少」。
|
||||
* 2. **锚定「距今天数」**:任务的日期按「N 天前」生成,不写死具体日期。
|
||||
* 写死日期后过几天再打开,近 7 天 / 近 14 天 / 本月 全是空的,
|
||||
* 演示时看起来像筛选坏了。
|
||||
*/
|
||||
import type { PageResult, ApiResponse } from '@/types/common'
|
||||
import { unwrapApiResponse } from '@/utils/api-response'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
import { mockFlags } from '@/utils/mock-flags'
|
||||
import type { SigningMethod, SigningTaskStatus, WorkbenchCampus } from './types'
|
||||
|
||||
import { mockDocumentCatalog, campusOfDepartment } from './mock-catalog'
|
||||
import type {
|
||||
PatientSex,
|
||||
SigningMethod,
|
||||
SigningTaskStatus,
|
||||
VisitType,
|
||||
WorkbenchCampus,
|
||||
} from './types'
|
||||
|
||||
const useMockData = mockFlags.reportAnalytics
|
||||
|
||||
export const isReportAnalyticsMockEnabled = useMockData
|
||||
|
||||
/**
|
||||
* 演示数据覆盖的天数。
|
||||
* 比最长的时间筛选(本月按 1 号起算,最多 31 天)略短,够用且不至于让
|
||||
* 「本月」看起来总是差几天;真要展示整月,把这里调到 31 即可。
|
||||
*/
|
||||
const DEMO_SPAN_DAYS = 30
|
||||
|
||||
/** 演示任务量级:约 5 条/天,够让趋势图与排行榜有形状,又不会让表格卡顿 */
|
||||
const DEMO_TASK_COUNT = 152
|
||||
|
||||
/** 一条报表任务行:聚合所需的全部维度都在这里,页面不再回查其它数据源 */
|
||||
export interface ReportTaskRow {
|
||||
id: string
|
||||
patientId: string
|
||||
patientName: string
|
||||
sex: PatientSex
|
||||
age: number
|
||||
visitType: VisitType
|
||||
visitNo: string
|
||||
campus: WorkbenchCampus
|
||||
department: string
|
||||
documentName: string
|
||||
category: string
|
||||
method: SigningMethod
|
||||
status: SigningTaskStatus
|
||||
/** 发起时间,`YYYY-MM-DD HH:mm`(与签署记录同一格式,便于直接比较与展示) */
|
||||
createdAt: string
|
||||
/** 签署完成时间;未完成的任务没有这一项 */
|
||||
signedAt?: string
|
||||
/** 签署用时(分钟);只有已签署的任务有。超 1440 为超 24 小时,超 2880 为超期完成 */
|
||||
signMinutes?: number
|
||||
}
|
||||
|
||||
export interface ReportGroupRow {
|
||||
key: string
|
||||
total: number
|
||||
signed: number
|
||||
confirming: number
|
||||
pending: number
|
||||
signing: number
|
||||
expired: number
|
||||
void: number
|
||||
failed: number
|
||||
completionRate: number
|
||||
}
|
||||
|
||||
export interface ReportBarItem {
|
||||
label: string
|
||||
value: number
|
||||
display?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface ReportAnalyticsDataset {
|
||||
tasks: ReportTaskRow[]
|
||||
/**
|
||||
* 数据是否可用。真实模式下没有后端接口,这里返回 false,
|
||||
* 页面据此显示「后端尚未提供分析接口」的说明,而不是渲染一张全 0 的假报表。
|
||||
*/
|
||||
available: boolean
|
||||
/** 数据生成时刻(`YYYY-MM-DD HH:mm`),页面用于「数据截至」提示 */
|
||||
timezone: string
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
确定性随机
|
||||
========================================================================= */
|
||||
|
||||
/** mulberry32:小、快、固定种子可复现 */
|
||||
function createRandom(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
fromDate: string
|
||||
toDate: string
|
||||
statusCounts: Omit<ReportGroupRow, 'key' | 'completionRate'>
|
||||
trend: { key: string; label: string; created: number; signed: number }[]
|
||||
departments: ReportGroupRow[]
|
||||
categories: ReportGroupRow[]
|
||||
methods: ReportGroupRow[]
|
||||
campuses: ReportGroupRow[]
|
||||
documents: ReportGroupRow[]
|
||||
timeliness: {
|
||||
averageMinutes: number | null
|
||||
within24hRate: number | null
|
||||
overdueRate: number | null
|
||||
expiredTaskCount: number
|
||||
signedSampleCount: number
|
||||
departmentAverages: ReportBarItem[]
|
||||
dailyAverages: { date: string; averageMinutes: number; sampleCount: number }[]
|
||||
overdueTaskCount: number
|
||||
overdueTasks: {
|
||||
id: string
|
||||
patientId: string
|
||||
patientName: string
|
||||
department: string
|
||||
documentName: string
|
||||
createdAt: string
|
||||
signedAt: string
|
||||
signMinutes: number
|
||||
}[]
|
||||
}
|
||||
patientStructure: {
|
||||
age: ReportBarItem[]
|
||||
sex: ReportBarItem[]
|
||||
visitType: ReportBarItem[]
|
||||
campus: ReportBarItem[]
|
||||
}
|
||||
}
|
||||
|
||||
function pick<T>(random: () => number, items: readonly T[]): T {
|
||||
return items[Math.floor(random() * items.length)] as T
|
||||
export interface ReportOptions {
|
||||
campuses: { id: string; name: string }[]
|
||||
departments: { id: string; campusId: string; name: string }[]
|
||||
templateCategories: string[]
|
||||
}
|
||||
|
||||
function intBetween(random: () => number, min: number, max: number): number {
|
||||
return min + Math.floor(random() * (max - min + 1))
|
||||
export interface ReportAnalyticsQuery {
|
||||
createdFrom: string
|
||||
createdTo: string
|
||||
timezone: string
|
||||
campusId?: string
|
||||
departmentId?: string
|
||||
templateCategory?: string
|
||||
signMethod?: 'PAD' | 'SMS'
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
演示患者池
|
||||
========================================================================= */
|
||||
|
||||
interface DemoPatient {
|
||||
name: string
|
||||
sex: PatientSex
|
||||
age: number
|
||||
visitType: VisitType
|
||||
export interface ReportFilterInput {
|
||||
dateRange: '7d' | '14d' | 'month' | 'custom'
|
||||
customFrom: string
|
||||
customTo: string
|
||||
campus: string
|
||||
department: string
|
||||
category: string
|
||||
method: SigningMethod | 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者池。
|
||||
*
|
||||
* 含 3 名未成年患者:儿科知情同意同样由监护人签署,报表统计的是患者本人年龄。
|
||||
* 未成年占比刻意保持在三成以下(3/24),与「综合医院里儿科签署只是小部分」的实际相符。
|
||||
*
|
||||
* ⚠️ **不要把「每个年龄段在任意时间窗内都非空」当作验收标准**:未成年任务只有十几条,
|
||||
* 近 7 天窗口里恰好一条都没有是正常的(实测 2026-09-24 当天就是这样)。
|
||||
* 年龄段分档的正确性由分档边界(17/18、40/41、60/61)来保证,不是由「每档都非空」。
|
||||
* 为了让窄窗口好看而硬塞样本,会让年龄段分布失去真实形状。
|
||||
*/
|
||||
const DEMO_PATIENTS: DemoPatient[] = [
|
||||
{ name: '周雅琴', sex: '女', age: 29, visitType: '门诊' },
|
||||
{ name: '李淑芬', sex: '女', age: 67, visitType: '住院' },
|
||||
{ name: '赵美英', sex: '女', age: 58, visitType: '住院' },
|
||||
{ name: '陈志强', sex: '男', age: 61, visitType: '住院' },
|
||||
{ name: '王建国', sex: '男', age: 71, visitType: '住院' },
|
||||
{ name: '孙浩然', sex: '男', age: 54, visitType: '门诊' },
|
||||
{ name: '刘伟', sex: '男', age: 45, visitType: '门诊' },
|
||||
{ name: '吴桂芳', sex: '女', age: 73, visitType: '住院' },
|
||||
{ name: '黄志明', sex: '男', age: 66, visitType: '门诊' },
|
||||
{ name: '郑海燕', sex: '女', age: 38, visitType: '门诊' },
|
||||
{ name: '徐丽华', sex: '女', age: 50, visitType: '体检' },
|
||||
{ name: '马晓东', sex: '男', age: 42, visitType: '其他' },
|
||||
{ name: '林俊杰', sex: '男', age: 34, visitType: '体检' },
|
||||
{ name: '何秀英', sex: '女', age: 62, visitType: '住院' },
|
||||
{ name: '邓小燕', sex: '女', age: 27, visitType: '门诊' },
|
||||
{ name: '曹明辉', sex: '男', age: 57, visitType: '住院' },
|
||||
{ name: '袁丽', sex: '女', age: 19, visitType: '门诊' },
|
||||
{ name: '沈国强', sex: '男', age: 78, visitType: '住院' },
|
||||
{ name: '崔文静', sex: '女', age: 46, visitType: '体检' },
|
||||
{ name: '侯志远', sex: '男', age: 36, visitType: '门诊' },
|
||||
{ name: '龚雨萱', sex: '女', age: 6, visitType: '门诊' },
|
||||
{ name: '范子墨', sex: '男', age: 14, visitType: '住院' },
|
||||
{ name: '柳思彤', sex: '女', age: 16, visitType: '门诊' },
|
||||
{ name: '严志刚', sex: '男', age: 52, visitType: '其他' },
|
||||
]
|
||||
interface ReportGroupDto {
|
||||
key: string
|
||||
total: number
|
||||
signed: number
|
||||
confirming: number
|
||||
pending: number
|
||||
signing: number
|
||||
expired: number
|
||||
voided: number
|
||||
failed: number
|
||||
completionRate: number
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
演示任务生成
|
||||
========================================================================= */
|
||||
|
||||
/** 状态分布:已签署占多数,其余各留一点,七种状态在总览统计块里都看得到 */
|
||||
const STATUS_WEIGHTS: { status: SigningTaskStatus; weight: number }[] = [
|
||||
{ status: 'signed', weight: 62 },
|
||||
{ status: 'confirming', weight: 6 },
|
||||
{ status: 'pending', weight: 9 },
|
||||
{ status: 'signing', weight: 5 },
|
||||
{ status: 'expired', weight: 8 },
|
||||
{ status: 'void', weight: 5 },
|
||||
{ status: 'failed', weight: 5 },
|
||||
]
|
||||
|
||||
const STATUS_TOTAL_WEIGHT = STATUS_WEIGHTS.reduce((sum, item) => sum + item.weight, 0)
|
||||
|
||||
function pickStatus(random: () => number): SigningTaskStatus {
|
||||
let roll = random() * STATUS_TOTAL_WEIGHT
|
||||
|
||||
for (const item of STATUS_WEIGHTS) {
|
||||
roll -= item.weight
|
||||
|
||||
if (roll <= 0) {
|
||||
return item.status
|
||||
}
|
||||
interface ReportAnalyticsDto {
|
||||
timezone: string
|
||||
generatedAt: string
|
||||
fromDate: string
|
||||
toDate: string
|
||||
statusCounts: {
|
||||
total: number
|
||||
signed: number
|
||||
confirming: number
|
||||
pending: number
|
||||
signing: number
|
||||
expired: number
|
||||
voided: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
return 'signed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 四种签署方式的抽样权重。
|
||||
*
|
||||
* 现场两种(手写板 / Pad)占比高,线上两种(短信 / 公众号)略低 —— 四种都要出现,
|
||||
* 否则「签署方式分布」这个报表维度会缺行,演示时看不到新方式。
|
||||
* 权重和不必是 100,`pickMethod` 会自己算总和。
|
||||
*/
|
||||
const METHOD_WEIGHTS: readonly { method: SigningMethod; weight: number }[] = [
|
||||
{ method: 'pad', weight: 38 },
|
||||
{ method: 'ipad', weight: 24 },
|
||||
{ method: 'sms', weight: 26 },
|
||||
{ method: 'wx', weight: 12 },
|
||||
]
|
||||
|
||||
const METHOD_TOTAL_WEIGHT = METHOD_WEIGHTS.reduce((sum, item) => sum + item.weight, 0)
|
||||
|
||||
function pickMethod(random: () => number): SigningMethod {
|
||||
let roll = random() * METHOD_TOTAL_WEIGHT
|
||||
|
||||
for (const item of METHOD_WEIGHTS) {
|
||||
roll -= item.weight
|
||||
|
||||
if (roll <= 0) {
|
||||
return item.method
|
||||
}
|
||||
trend: { date: string; created: number; signed: number }[]
|
||||
departments: ReportGroupDto[]
|
||||
categories: ReportGroupDto[]
|
||||
methods: ReportGroupDto[]
|
||||
campuses: ReportGroupDto[]
|
||||
documents: ReportGroupDto[]
|
||||
timeliness: {
|
||||
averageMinutes: number | null
|
||||
within24hRate: number | null
|
||||
overdueRate: number | null
|
||||
expiredTaskCount: number
|
||||
signedSampleCount: number
|
||||
departmentAverages: { label: string; averageMinutes: number; sampleCount: number }[]
|
||||
dailyAverages: { label: string; averageMinutes: number; sampleCount: number }[]
|
||||
overdueTaskCount: number
|
||||
overdueTasks: {
|
||||
taskId: string
|
||||
patientDisplayId: string
|
||||
patientNameMasked: string
|
||||
department: string
|
||||
documentName: string
|
||||
createdAt: string
|
||||
signedAt: string
|
||||
elapsedMinutes: number
|
||||
}[]
|
||||
}
|
||||
patientStructure: {
|
||||
age: { label: string; value: number }[]
|
||||
sex: { label: string; value: number }[]
|
||||
visitType: { label: string; value: number }[]
|
||||
campus: { label: string; value: number }[]
|
||||
}
|
||||
|
||||
return 'pad'
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
interface ReportOptionsDto {
|
||||
campuses: { id: string; name: string }[]
|
||||
departments: { id: string; campusId: string; name: string }[]
|
||||
templateCategories: string[]
|
||||
}
|
||||
|
||||
function formatMoment(date: Date): string {
|
||||
interface ReportTaskDto {
|
||||
taskId: string
|
||||
patientDisplayId: string
|
||||
patientNameMasked: string
|
||||
status: string
|
||||
signMethod: string
|
||||
createdAt: string
|
||||
signedAt: string | null
|
||||
templateName: string
|
||||
templateCategory: string
|
||||
campusName: string
|
||||
departmentName: string
|
||||
}
|
||||
|
||||
interface BackendPage<T> {
|
||||
records?: T[]
|
||||
items?: T[]
|
||||
total?: number
|
||||
page?: number
|
||||
size?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface ReportTasksQuery extends ReportAnalyticsQuery {
|
||||
page: number
|
||||
size: number
|
||||
dimension?: 'department' | 'category' | 'method' | 'campus'
|
||||
groupValue?: string
|
||||
}
|
||||
|
||||
export interface ReportExportRequest extends ReportTasksQuery {
|
||||
timeDimension: 'CREATED' | 'SIGNED' | 'EXPIRED'
|
||||
sort: string
|
||||
direction: 'asc' | 'desc'
|
||||
format: 'CSV' | 'XLSX'
|
||||
columns: string[]
|
||||
}
|
||||
|
||||
export interface ReportExportJob {
|
||||
jobId: string
|
||||
status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
|
||||
resultCount: number | null
|
||||
errorMessage: string | null
|
||||
downloadPath: string | null
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return value
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z$/, '')
|
||||
.replace(/Z$/, '')
|
||||
const pad = (part: number) => String(part).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 签署用时(分钟)。
|
||||
*
|
||||
* 分三段构造,保证「24 小时内完成率」与「超期完成」两张表都拿得到样本:
|
||||
* 大部分在 3~120 分钟,一部分落在 24 小时附近,少数超过 48 小时有效期。
|
||||
*/
|
||||
function buildSignMinutes(random: () => number): number {
|
||||
const roll = random()
|
||||
|
||||
if (roll < 0.82) {
|
||||
return intBetween(random, 3, 720)
|
||||
}
|
||||
|
||||
if (roll < 0.94) {
|
||||
return intBetween(random, 720, 2880)
|
||||
}
|
||||
|
||||
return intBetween(random, 2881, 6600)
|
||||
function mapGroup(group: ReportGroupDto): ReportGroupRow {
|
||||
const { voided, ...counts } = group
|
||||
return { ...counts, void: voided }
|
||||
}
|
||||
|
||||
function buildTasks(): ReportTaskRow[] {
|
||||
const random = createRandom(20260924)
|
||||
const rows: ReportTaskRow[] = []
|
||||
const today = new Date()
|
||||
|
||||
for (let index = 0; index < DEMO_TASK_COUNT; index += 1) {
|
||||
const patient = pick(random, DEMO_PATIENTS)
|
||||
const document = pick(random, mockDocumentCatalog)
|
||||
const status = pickStatus(random)
|
||||
// 四种方式按现场/线上偏重的权重抽,保证「签署方式分布」四个维度都有量
|
||||
const method = pickMethod(random)
|
||||
|
||||
// 越靠近今天的任务越少:真实签署量不会是均匀分布,均匀分布的趋势图一眼假
|
||||
const daysAgo = Math.floor(DEMO_SPAN_DAYS * Math.pow(random(), 1.35))
|
||||
|
||||
const createdAt = new Date(today)
|
||||
createdAt.setDate(createdAt.getDate() - daysAgo)
|
||||
createdAt.setHours(intBetween(random, 8, 19), intBetween(random, 0, 59), 0, 0)
|
||||
|
||||
// 待科室确认的任务患者也已经签完了,签署时间与用时都要有 ——
|
||||
// 否则「签署时效分析」会把它们当成"还没签",24 小时内完成率被算低
|
||||
const isSigned = status === 'signed' || status === 'confirming'
|
||||
const signMinutes = isSigned ? buildSignMinutes(random) : undefined
|
||||
|
||||
const row: ReportTaskRow = {
|
||||
id: `RT${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`,
|
||||
patientId: `P${202610000 + (DEMO_PATIENTS.indexOf(patient) + 1)}`,
|
||||
patientName: patient.name,
|
||||
sex: patient.sex,
|
||||
age: patient.age,
|
||||
visitType: patient.visitType,
|
||||
visitNo: `${patient.visitType === '住院' ? 'ZY' : patient.visitType === '体检' ? 'TJ' : 'MZ'}${createdAt.getFullYear()}${pad(createdAt.getMonth() + 1)}${pad(createdAt.getDate())}${pad(index + 1)}`,
|
||||
campus: campusOfDepartment(document.department),
|
||||
department: document.department,
|
||||
documentName: document.name,
|
||||
category: document.category,
|
||||
method,
|
||||
status,
|
||||
createdAt: formatMoment(createdAt),
|
||||
}
|
||||
|
||||
if (signMinutes !== undefined) {
|
||||
const signedAt = new Date(createdAt.getTime() + signMinutes * 60_000)
|
||||
row.signMinutes = signMinutes
|
||||
row.signedAt = formatMoment(signedAt)
|
||||
}
|
||||
|
||||
rows.push(row)
|
||||
function mapStatus(value: string): SigningTaskStatus {
|
||||
switch (value) {
|
||||
case 'WAITING_SIGN':
|
||||
return 'pending'
|
||||
case 'WAITING_CONFIRM':
|
||||
return 'confirming'
|
||||
case 'CREATED':
|
||||
case 'GENERATING':
|
||||
return 'signing'
|
||||
case 'SIGNED':
|
||||
return 'signed'
|
||||
case 'EXPIRED':
|
||||
return 'expired'
|
||||
case 'VOIDED':
|
||||
return 'void'
|
||||
default:
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
// 按发起时间倒序:页面与导出的默认顺序都跟这里一致,不必各自再排一次
|
||||
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
对外接口
|
||||
========================================================================= */
|
||||
|
||||
let cachedTasks: ReportTaskRow[] | null = null
|
||||
|
||||
/** 演示数据只生成一次:确定性生成也要避免每次进页面都重跑一遍 */
|
||||
function readTasks(): ReportTaskRow[] {
|
||||
cachedTasks ??= buildTasks()
|
||||
|
||||
return cachedTasks
|
||||
function mapMethod(value: string): SigningMethod {
|
||||
return value === 'SMS' ? 'sms' : 'pad'
|
||||
}
|
||||
|
||||
/**
|
||||
* 取报表分析数据集。
|
||||
*
|
||||
* 真实模式下后端没有对应接口,返回 `available: false` 而不抛错:
|
||||
* 报表页拿到空数据时的正确表现是「说明原因」,不是弹一个红色报错 ——
|
||||
* 后端没上线不是用户的错,也不该看起来像页面坏了。
|
||||
*/
|
||||
export async function getReportAnalyticsDataset(): Promise<ReportAnalyticsDataset> {
|
||||
if (!useMockData) {
|
||||
return { tasks: [], available: false, generatedAt: formatMoment(new Date()) }
|
||||
}
|
||||
|
||||
function mapTask(task: ReportTaskDto): ReportTaskRow {
|
||||
const signedAt = task.signedAt ? formatDateTime(task.signedAt) : undefined
|
||||
const createdAt = formatDateTime(task.createdAt)
|
||||
const elapsed = task.signedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(task.signedAt).getTime() - new Date(task.createdAt).getTime()) / 60_000,
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
return {
|
||||
tasks: readTasks(),
|
||||
available: true,
|
||||
generatedAt: formatMoment(new Date()),
|
||||
id: task.taskId,
|
||||
patientId: task.patientDisplayId,
|
||||
patientName: task.patientNameMasked,
|
||||
campus: task.campusName,
|
||||
department: task.departmentName,
|
||||
documentName: task.templateName,
|
||||
category: task.templateCategory,
|
||||
method: mapMethod(task.signMethod),
|
||||
status: mapStatus(task.status),
|
||||
createdAt,
|
||||
signedAt,
|
||||
signMinutes: elapsed,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReportAnalyticsQuery(
|
||||
filter: ReportFilterInput,
|
||||
window: { from: string; to: string },
|
||||
): ReportAnalyticsQuery {
|
||||
const start = new Date(`${window.from}T00:00:00`)
|
||||
const end = new Date(`${window.to}T00:00:00`)
|
||||
end.setDate(end.getDate() + 1)
|
||||
const query: ReportAnalyticsQuery = {
|
||||
createdFrom: start.toISOString(),
|
||||
createdTo: end.toISOString(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
|
||||
}
|
||||
if (filter.campus && filter.campus !== 'all') query.campusId = filter.campus
|
||||
if (filter.department) query.departmentId = filter.department
|
||||
if (filter.category) query.templateCategory = filter.category
|
||||
if (filter.method !== 'all') query.signMethod = filter.method.toUpperCase() as 'PAD' | 'SMS'
|
||||
return query
|
||||
}
|
||||
|
||||
export async function getReportOptions(): Promise<ReportOptions> {
|
||||
const response = await request.get<ApiResponse<ReportOptionsDto>>('/v1/reports/signing/options')
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getReportAnalytics(
|
||||
query: ReportAnalyticsQuery,
|
||||
): Promise<ReportAnalyticsDataset> {
|
||||
const response = await request.get<ApiResponse<ReportAnalyticsDto>>(
|
||||
'/v1/reports/signing/analytics',
|
||||
{ params: query },
|
||||
)
|
||||
const dto = unwrapApiResponse(response)
|
||||
const { voided, ...statusCounts } = dto.statusCounts
|
||||
return {
|
||||
timezone: dto.timezone,
|
||||
generatedAt: formatDateTime(dto.generatedAt),
|
||||
fromDate: dto.fromDate,
|
||||
toDate: dto.toDate,
|
||||
statusCounts: { ...statusCounts, void: voided },
|
||||
trend: dto.trend.map((point) => ({
|
||||
key: point.date,
|
||||
label: `${Number(point.date.slice(5, 7))}/${Number(point.date.slice(8, 10))}`,
|
||||
created: point.created,
|
||||
signed: point.signed,
|
||||
})),
|
||||
departments: dto.departments.map(mapGroup),
|
||||
categories: dto.categories.map(mapGroup),
|
||||
methods: dto.methods.map(mapGroup),
|
||||
campuses: dto.campuses.map(mapGroup),
|
||||
documents: dto.documents.map(mapGroup),
|
||||
timeliness: {
|
||||
averageMinutes: dto.timeliness.averageMinutes,
|
||||
within24hRate: dto.timeliness.within24hRate,
|
||||
overdueRate: dto.timeliness.overdueRate,
|
||||
expiredTaskCount: dto.timeliness.expiredTaskCount,
|
||||
signedSampleCount: dto.timeliness.signedSampleCount,
|
||||
departmentAverages: dto.timeliness.departmentAverages.map((row) => ({
|
||||
label: row.label,
|
||||
value: row.averageMinutes,
|
||||
})),
|
||||
dailyAverages: dto.timeliness.dailyAverages.map((row) => ({
|
||||
date: row.label,
|
||||
averageMinutes: row.averageMinutes,
|
||||
sampleCount: row.sampleCount,
|
||||
})),
|
||||
overdueTaskCount: dto.timeliness.overdueTaskCount,
|
||||
overdueTasks: dto.timeliness.overdueTasks.map((row) => ({
|
||||
id: row.taskId,
|
||||
patientId: row.patientDisplayId,
|
||||
patientName: row.patientNameMasked,
|
||||
department: row.department,
|
||||
documentName: row.documentName,
|
||||
createdAt: formatDateTime(row.createdAt),
|
||||
signedAt: formatDateTime(row.signedAt),
|
||||
signMinutes: row.elapsedMinutes,
|
||||
})),
|
||||
},
|
||||
patientStructure: {
|
||||
age: dto.patientStructure.age,
|
||||
sex: dto.patientStructure.sex,
|
||||
visitType: dto.patientStructure.visitType,
|
||||
campus: dto.patientStructure.campus,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReportAnalyticsTasks(
|
||||
query: ReportTasksQuery,
|
||||
): Promise<PageResult<ReportTaskRow>> {
|
||||
const response = await request.get<ApiResponse<BackendPage<ReportTaskDto>>>(
|
||||
'/v1/reports/signing/analytics/tasks',
|
||||
{
|
||||
params: query,
|
||||
},
|
||||
)
|
||||
const page = unwrapApiResponse(response)
|
||||
return {
|
||||
records: (page.records ?? page.items ?? []).map(mapTask),
|
||||
total: page.total ?? 0,
|
||||
page: page.page ?? query.page,
|
||||
pageSize: page.pageSize ?? page.size ?? query.size,
|
||||
}
|
||||
}
|
||||
|
||||
export function createReportExportJob(input: ReportExportRequest): Promise<ReportExportJob> {
|
||||
return request
|
||||
.post<ApiResponse<ReportExportJob>>('/v1/reports/signing/export-jobs', {
|
||||
...input,
|
||||
page: 1,
|
||||
size: 200,
|
||||
timeDimension: 'CREATED',
|
||||
sort: 'createdAt',
|
||||
direction: 'desc',
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function getReportExportJob(jobId: string): Promise<ReportExportJob> {
|
||||
return request
|
||||
.get<ApiResponse<ReportExportJob>>(
|
||||
`/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`,
|
||||
)
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export async function downloadReportExport(path: string, filename: string): Promise<void> {
|
||||
if (!/^\/api\/v1\/reports\/signing\/export-jobs\/[0-9a-f-]{36}\/download$/i.test(path)) {
|
||||
throw new Error('后端返回的导出下载地址无效')
|
||||
}
|
||||
const apiPath = path.replace(/^\/api(?=\/)/, '')
|
||||
const blob = await request.get<Blob>(apiPath, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user