//! 统计 Service — 健康数据统计 use sea_orm::{ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr}; use erp_core::error::AppResult; use crate::dto::stats_dto::*; use crate::entity::{ patient, lab_report, appointment, vital_signs, }; use crate::state::HealthState; // --------------------------------------------------------------------------- // 健康数据统计 // --------------------------------------------------------------------------- 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 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 { 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()) } 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(CASE WHEN jsonb_typeof(items) = 'array' THEN items ELSE '[]'::jsonb END) elem WHERE elem->>'is_abnormal' = 'true'), '[]'::jsonb ) )), 0::bigint) 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::bigint 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, #[allow(dead_code)] // FromQueryResult 映射需要 total 字段,当前未读取 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()) }