import type { PageResult, ApiResponse } from '@/types/common' import { unwrapApiResponse } from '@/utils/api-response' import { request } from '@/utils/request' import type { SigningMethod, SigningTaskStatus, WorkbenchCampus } from './types' export interface ReportTaskRow { id: string patientId: string patientName: string campus: WorkbenchCampus department: string documentName: string category: string method: SigningMethod status: SigningTaskStatus createdAt: string signedAt?: string 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 { timezone: string generatedAt: string fromDate: string toDate: string statusCounts: Omit 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[] } } export interface ReportOptions { campuses: { id: string; name: string }[] departments: { id: string; campusId: string; name: string }[] templateCategories: string[] } export interface ReportAnalyticsQuery { createdFrom: string createdTo: string timezone: string campusId?: string departmentId?: string templateCategory?: string signMethod?: 'PAD' | 'SMS' } export interface ReportFilterInput { dateRange: '7d' | '14d' | 'month' | 'custom' customFrom: string customTo: string campus: string department: string category: string method: SigningMethod | 'all' } interface ReportGroupDto { key: string total: number signed: number confirming: number pending: number signing: number expired: number voided: number failed: number completionRate: number } 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 } 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 }[] } } interface ReportOptionsDto { campuses: { id: string; name: string }[] departments: { id: string; campusId: string; name: string }[] templateCategories: 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 { 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())}` } function mapGroup(group: ReportGroupDto): ReportGroupRow { const { voided, ...counts } = group return { ...counts, void: voided } } 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' } } function mapMethod(value: string): SigningMethod { return value === 'SMS' ? 'sms' : 'pad' } 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 { 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 { const response = await request.get>('/v1/reports/signing/options') return unwrapApiResponse(response) } export async function getReportAnalytics( query: ReportAnalyticsQuery, ): Promise { const response = await request.get>( '/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> { const response = await request.get>>( '/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 { return request .post>('/v1/reports/signing/export-jobs', { ...input, page: 1, size: 200, timeDimension: 'CREATED', sort: 'createdAt', direction: 'desc', }) .then(unwrapApiResponse) } export function getReportExportJob(jobId: string): Promise { return request .get>( `/v1/reports/signing/export-jobs/${encodeURIComponent(jobId)}`, ) .then(unwrapApiResponse) } export async function downloadReportExport(path: string, filename: string): Promise { 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(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) }