diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index eda1a06..3dd8b69 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -148,6 +148,62 @@ export interface OverviewStatistics { points: PointsStatistics; } +// --- Health Data Statistics Types --- + +export interface NameValue { + name: string; + value: number; +} + +export interface DialysisStatistics { + total_records: number; + this_month: number; + type_distribution: NameValue[]; + complication_rate: number; + avg_ultrafiltration: number | null; + avg_duration: number | null; + pending_review: number; +} + +export interface LabReportStatistics { + total_reports: number; + this_month: number; + type_distribution: NameValue[]; + abnormal_items: number; + pending_review: number; + reviewed: number; +} + +export interface AppointmentStatistics { + total_appointments: number; + this_month: number; + status_distribution: NameValue[]; + type_distribution: NameValue[]; + cancel_rate: number; +} + +export interface DailyReportRate { + date: string; + reported: number; + total: number; + rate: number; +} + +export interface VitalSignsReportRate { + total_patients: number; + reported_patients: number; + report_rate: number; + total_records: number; + daily_trend: DailyReportRate[]; +} + +export interface HealthDataStats { + dialysis: DialysisStatistics; + lab_reports: LabReportStatistics; + appointments: AppointmentStatistics; + vital_signs_report_rate: VitalSignsReportRate; +} + // --- API --- export const pointsApi = { @@ -295,4 +351,12 @@ export const pointsApi = { }>('/health/admin/statistics/follow-ups'); return data.data; }, + + getHealthDataStats: async (): Promise => { + const { data } = await client.get<{ + success: boolean; + data: HealthDataStats; + }>('/health/admin/statistics/health-data'); + return data.data; + }, }; diff --git a/apps/web/src/pages/health/StatisticsDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard.tsx index 76685a1..94856af 100644 --- a/apps/web/src/pages/health/StatisticsDashboard.tsx +++ b/apps/web/src/pages/health/StatisticsDashboard.tsx @@ -10,6 +10,7 @@ import { Button, Typography, Tooltip, + Tag, } from 'antd'; import { UserOutlined, @@ -33,6 +34,7 @@ import { type ConsultationStatistics, type FollowUpStatistics, type PointsStatistics, + type HealthDataStats, } from '../../api/health/points'; const { Title: AntTitle, Text } = Typography; @@ -85,21 +87,24 @@ export default function StatisticsDashboard() { const [consultationStats, setConsultationStats] = useState(null); const [followUpStats, setFollowUpStats] = useState(null); const [pointsStats, setPointsStats] = useState(null); + const [healthDataStats, setHealthDataStats] = useState(null); const fetchAllStats = useCallback(async () => { setLoading(true); setError(null); try { - const [patients, consultations, followUps, points] = await Promise.all([ + const [patients, consultations, followUps, points, healthData] = await Promise.all([ pointsApi.getPatientStats(), pointsApi.getConsultationStats(), pointsApi.getFollowUpStats(), pointsApi.getStatistics(), + pointsApi.getHealthDataStats(), ]); setPatientStats(patients); setConsultationStats(consultations); setFollowUpStats(followUps); setPointsStats(points); + setHealthDataStats(healthData); } catch (err: unknown) { const message = err instanceof Error ? err.message : '加载统计数据失败'; setError(message); @@ -336,6 +341,159 @@ export default function StatisticsDashboard() { /> + {/* Section 2.5: Health Data Statistics */} + + + 健康数据中心 + + } + bordered={false} + style={{ borderRadius: 12 }} + > + + {/* 透析统计 */} + + 透析记录} + style={{ borderRadius: 8 }} + > + + + + + + + + + + + + + + + + + + + + + + + {(healthDataStats?.dialysis.type_distribution ?? []).length > 0 && ( +
+ 类型分布: + {healthDataStats!.dialysis.type_distribution.map((item) => ( + {item.name}: {item.value} + ))} +
+ )} +
+ + + {/* 化验报告 */} + + 化验报告} + style={{ borderRadius: 8 }} + > + + + + + + + + + + + + + + + + + + + + {(healthDataStats?.lab_reports.type_distribution ?? []).length > 0 && ( +
+ 类型分布: + {healthDataStats!.lab_reports.type_distribution.map((item) => ( + {item.name}: {item.value} + ))} +
+ )} +
+ + + {/* 预约统计 */} + + 预约统计} + style={{ borderRadius: 8 }} + > + + + + + + + + + + + + {(healthDataStats?.appointments.status_distribution ?? []).length > 0 && ( +
+ 状态: + {healthDataStats!.appointments.status_distribution.map((item) => ( + {item.name}: {item.value} + ))} +
+ )} +
+ + + {/* 体征上报率 */} + + 体征上报率} + style={{ borderRadius: 8 }} + > + + + + + + + + + + + + {(healthDataStats?.vital_signs_report_rate.daily_trend ?? []).length > 0 && ( +
+ 近 7 天: +
+ {healthDataStats!.vital_signs_report_rate.daily_trend.map((d) => ( + = 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}> + {d.date.slice(5)} {d.reported}/{d.total} + + ))} +
+
+ )} +
+ +
+
+ {/* Section 3: Quick Links */} , + /// 本月并发症发生率 (%) + pub complication_rate: f64, + /// 平均超滤量 (ml) + pub avg_ultrafiltration: Option, + /// 平均透析时长 (分钟) + pub avg_duration: Option, + /// 待审核数量 + pub pending_review: i64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct LabReportStatisticsResp { + pub total_reports: i64, + pub this_month: i64, + /// 各报告类型分布 + pub type_distribution: Vec, + /// 异常项数(items 中 is_abnormal=true 的总数) + pub abnormal_items: i64, + /// 待审核数量 + pub pending_review: i64, + /// 已审核数量 + pub reviewed: i64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AppointmentStatisticsResp { + pub total_appointments: i64, + pub this_month: i64, + /// 各状态分布 + pub status_distribution: Vec, + /// 各预约类型分布 + pub type_distribution: Vec, + /// 取消率 (%) + pub cancel_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct VitalSignsReportRateResp { + /// 总患者数 + pub total_patients: i64, + /// 本月上报体征的患者数 + pub reported_patients: i64, + /// 上报率 (%) + pub report_rate: f64, + /// 本月体征记录总数 + pub total_records: i64, + /// 上报率趋势(最近 7 天) + pub daily_trend: Vec, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DailyReportRate { + pub date: String, + pub reported: i64, + pub total: i64, + pub rate: f64, +} + +/// 健康数据中心综合统计。 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct HealthDataStatsResp { + pub dialysis: DialysisStatisticsResp, + pub lab_reports: LabReportStatisticsResp, + pub appointments: AppointmentStatisticsResp, + pub vital_signs_report_rate: VitalSignsReportRateResp, +} + +// --------------------------------------------------------------------------- +// 通用结构 +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct NameValue { + pub name: String, + pub value: i64, +} diff --git a/crates/erp-health/src/handler/stats_handler.rs b/crates/erp-health/src/handler/stats_handler.rs index 70d5397..b6cbbdb 100644 --- a/crates/erp-health/src/handler/stats_handler.rs +++ b/crates/erp-health/src/handler/stats_handler.rs @@ -65,3 +65,72 @@ where follow_ups, }))) } + +// --------------------------------------------------------------------------- +// 健康数据统计 +// --------------------------------------------------------------------------- + +pub async fn get_dialysis_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let result = stats_service::get_dialysis_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_lab_report_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let result = stats_service::get_lab_report_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_appointment_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let result = stats_service::get_appointment_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_vital_signs_report_rate( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let result = stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_health_data_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let result = stats_service::get_health_data_stats(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 646d1c2..e182240 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -482,6 +482,26 @@ impl HealthModule { "/health/admin/statistics/dashboard", axum::routing::get(stats_handler::get_dashboard_stats), ) + .route( + "/health/admin/statistics/dialysis", + axum::routing::get(stats_handler::get_dialysis_stats), + ) + .route( + "/health/admin/statistics/lab-reports", + axum::routing::get(stats_handler::get_lab_report_stats), + ) + .route( + "/health/admin/statistics/appointments", + axum::routing::get(stats_handler::get_appointment_stats), + ) + .route( + "/health/admin/statistics/vital-signs-report-rate", + axum::routing::get(stats_handler::get_vital_signs_report_rate), + ) + .route( + "/health/admin/statistics/health-data", + axum::routing::get(stats_handler::get_health_data_stats), + ) // 危急值阈值配置 .route( "/health/critical-value-thresholds", diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs index 1e0d373..2623978 100644 --- a/crates/erp-health/src/service/stats_service.rs +++ b/crates/erp-health/src/service/stats_service.rs @@ -1,14 +1,19 @@ -use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult}; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, FromQueryResult as _}; use erp_core::error::AppResult; use crate::dto::stats_dto::*; use crate::entity::{ patient, consultation_session, follow_up_task, - points_transaction, + points_transaction, dialysis_record, lab_report, + appointment, vital_signs, }; use crate::state::HealthState; +// --------------------------------------------------------------------------- +// 基础运营统计 +// --------------------------------------------------------------------------- + pub async fn get_patient_statistics( state: &HealthState, tenant_id: uuid::Uuid, @@ -162,3 +167,425 @@ async fn compute_avg_response_time( Ok(result.and_then(|r| r.avg_minutes)) } + +// --------------------------------------------------------------------------- +// 健康数据统计 +// --------------------------------------------------------------------------- + +pub async fn get_dialysis_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_records = dialysis_record::Entity::find() + .filter(dialysis_record::Column::TenantId.eq(tenant_id)) + .filter(dialysis_record::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let this_month = dialysis_record::Entity::find() + .filter(dialysis_record::Column::TenantId.eq(tenant_id)) + .filter(dialysis_record::Column::DeletedAt.is_null()) + .filter(Expr::col(dialysis_record::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let pending_review = dialysis_record::Entity::find() + .filter(dialysis_record::Column::TenantId.eq(tenant_id)) + .filter(dialysis_record::Column::DeletedAt.is_null()) + .filter(dialysis_record::Column::Status.eq("draft")) + .count(db) + .await?; + + let type_distribution = count_by_field( + db, tenant_id, + "SELECT dialysis_type AS name, COUNT(*) AS value FROM dialysis_record \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + AND created_at >= date_trunc('month', NOW()) \ + GROUP BY dialysis_type ORDER BY value DESC", + ).await?; + + let complication_rate = compute_complication_rate(db, tenant_id).await?; + let avg_ultrafiltration = compute_avg_field(db, tenant_id, "ultrafiltration_volume").await?; + let avg_duration = compute_avg_field(db, tenant_id, "dialysis_duration").await?; + + Ok(DialysisStatisticsResp { + total_records: total_records as i64, + this_month: this_month as i64, + type_distribution, + complication_rate, + avg_ultrafiltration, + avg_duration, + pending_review: pending_review as i64, + }) +} + +pub async fn get_lab_report_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_reports = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let this_month = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .filter(Expr::col(lab_report::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let pending_review = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .filter(lab_report::Column::Status.eq("pending")) + .count(db) + .await?; + + let reviewed = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .filter(lab_report::Column::Status.eq("reviewed")) + .count(db) + .await?; + + let type_distribution = count_by_field( + db, tenant_id, + "SELECT report_type AS name, COUNT(*) AS value FROM lab_report \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + AND created_at >= date_trunc('month', NOW()) \ + GROUP BY report_type ORDER BY value DESC", + ).await?; + + let abnormal_items = count_abnormal_lab_items(db, tenant_id).await?; + + Ok(LabReportStatisticsResp { + total_reports: total_reports as i64, + this_month: this_month as i64, + type_distribution, + abnormal_items, + pending_review: pending_review as i64, + reviewed: reviewed as i64, + }) +} + +pub async fn get_appointment_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_appointments = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let this_month = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let status_distribution = count_by_field( + db, tenant_id, + "SELECT status AS name, COUNT(*) AS value FROM appointment \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + AND created_at >= date_trunc('month', NOW()) \ + GROUP BY status ORDER BY value DESC", + ).await?; + + let type_distribution = count_by_field( + db, tenant_id, + "SELECT appointment_type AS name, COUNT(*) AS value FROM appointment \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + AND created_at >= date_trunc('month', NOW()) \ + GROUP BY appointment_type ORDER BY value DESC", + ).await?; + + let cancelled = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .filter(appointment::Column::Status.eq("cancelled")) + .count(db) + .await?; + + let cancel_rate = if this_month > 0 { + (cancelled as f64 / this_month as f64) * 100.0 + } else { + 0.0 + }; + + Ok(AppointmentStatisticsResp { + total_appointments: total_appointments as i64, + this_month: this_month as i64, + status_distribution, + type_distribution, + cancel_rate, + }) +} + +pub async fn get_vital_signs_report_rate( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_patients = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let total_records = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .filter(Expr::col(vital_signs::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let reported_patients = count_distinct_patients_vital_signs(db, tenant_id).await?; + + let report_rate = if total_patients > 0 { + (reported_patients as f64 / total_patients as f64) * 100.0 + } else { + 0.0 + }; + + let daily_trend = compute_daily_report_rate(db, tenant_id).await?; + + Ok(VitalSignsReportRateResp { + total_patients: total_patients as i64, + reported_patients: reported_patients as i64, + report_rate, + total_records: total_records as i64, + daily_trend, + }) +} + +pub async fn get_health_data_stats( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let dialysis = get_dialysis_statistics(state, tenant_id).await?; + let lab_reports = get_lab_report_statistics(state, tenant_id).await?; + let appointments = get_appointment_statistics(state, tenant_id).await?; + let vital_signs_report_rate = get_vital_signs_report_rate(state, tenant_id).await?; + + Ok(HealthDataStatsResp { + dialysis, + lab_reports, + appointments, + vital_signs_report_rate, + }) +} + +// --------------------------------------------------------------------------- +// 辅助查询 +// --------------------------------------------------------------------------- + +#[derive(Debug, FromQueryResult)] +struct NameValueRow { + name: String, + value: i64, +} + +async fn count_by_field( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, + sql: &str, +) -> AppResult> { + let rows: Vec = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .all(db) + .await?; + + Ok(rows.into_iter().map(|r| NameValue { name: r.name, value: r.value }).collect()) +} + +#[derive(Debug, FromQueryResult)] +struct AvgFieldResult { + avg_val: Option, +} + +async fn compute_avg_field( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, + field: &str, +) -> AppResult> { + let sql = format!( + "SELECT AVG({field}) AS avg_val FROM dialysis_record \ + WHERE tenant_id = $1 AND deleted_at IS NULL AND {field} IS NOT NULL \ + AND created_at >= date_trunc('month', NOW())" + ); + let result: Option = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + Ok(result.and_then(|r| r.avg_val)) +} + +async fn compute_complication_rate( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult { + let sql = r#" + SELECT + COUNT(*) FILTER (WHERE complication_notes IS NOT NULL AND complication_notes != '') AS with_comp, + COUNT(*) AS total + FROM dialysis_record + WHERE tenant_id = $1 AND deleted_at IS NULL + AND created_at >= date_trunc('month', NOW()) + "#; + + #[derive(Debug, FromQueryResult)] + struct CompResult { + with_comp: i64, + total: i64, + } + + let result: Option = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + Ok(match result { + Some(r) if r.total > 0 => (r.with_comp as f64 / r.total as f64) * 100.0, + _ => 0.0, + }) +} + +async fn count_abnormal_lab_items( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult { + let sql = r#" + SELECT COALESCE(SUM(jsonb_array_length( + COALESCE( + (SELECT jsonb_agg(elem) FROM jsonb_array_elements(items) elem WHERE elem->>'is_abnormal' = 'true'), + '[]'::jsonb + ) + )), 0) AS total + FROM lab_report + WHERE tenant_id = $1 AND deleted_at IS NULL AND items IS NOT NULL + AND created_at >= date_trunc('month', NOW()) + "#; + + #[derive(Debug, FromQueryResult)] + struct AbnormalCount { + total: Option, + } + + let result: Option = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + Ok(result.and_then(|r| r.total).unwrap_or(0)) +} + +async fn count_distinct_patients_vital_signs( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult { + let sql = r#" + SELECT COUNT(DISTINCT patient_id) AS cnt + FROM vital_signs + WHERE tenant_id = $1 AND deleted_at IS NULL + AND created_at >= date_trunc('month', NOW()) + "#; + + #[derive(Debug, FromQueryResult)] + struct DistinctCount { + cnt: i64, + } + + let result: Option = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + Ok(result.map(|r| r.cnt as u64).unwrap_or(0)) +} + +async fn compute_daily_report_rate( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult> { + let sql = r#" + SELECT d::date::text AS date, + COUNT(DISTINCT vs.patient_id) AS reported, + 0 AS total + FROM generate_series( + CURRENT_DATE - INTERVAL '6 days', + CURRENT_DATE, + INTERVAL '1 day' + ) d + LEFT JOIN vital_signs vs ON vs.record_date = d::date + AND vs.tenant_id = $1 AND vs.deleted_at IS NULL + GROUP BY d::date + ORDER BY d::date + "#; + + #[derive(Debug, FromQueryResult)] + struct DailyRow { + date: String, + reported: i64, + total: i64, + } + + let rows: Vec = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .all(db) + .await?; + + let total_patients = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .count(db) + .await?; + + Ok(rows.into_iter().map(|r| { + let total = total_patients as i64; + let rate = if total > 0 { (r.reported as f64 / total as f64) * 100.0 } else { 0.0 }; + DailyReportRate { date: r.date, reported: r.reported, total, rate } + }).collect()) +}