diff --git a/crates/erp-ai/src/entity/ai_analysis.rs b/crates/erp-ai/src/entity/ai_analysis.rs index e52ed51..997703e 100644 --- a/crates/erp-ai/src/entity/ai_analysis.rs +++ b/crates/erp-ai/src/entity/ai_analysis.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "ai_analysis_results")] +#[sea_orm(table_name = "ai_analysis")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-ai/src/entity/ai_prompt.rs b/crates/erp-ai/src/entity/ai_prompt.rs index f006fcf..d2e9485 100644 --- a/crates/erp-ai/src/entity/ai_prompt.rs +++ b/crates/erp-ai/src/entity/ai_prompt.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "ai_prompts")] +#[sea_orm(table_name = "ai_prompt")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-ai/src/entity/ai_usage.rs b/crates/erp-ai/src/entity/ai_usage.rs index 1dd8dc6..8e2a16a 100644 --- a/crates/erp-ai/src/entity/ai_usage.rs +++ b/crates/erp-ai/src/entity/ai_usage.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "ai_usage_logs")] +#[sea_orm(table_name = "ai_usage")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 1e4ff8d..67bfd59 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -6,8 +6,8 @@ use erp_core::events::EventBus; use erp_core::module::{ErpModule, PermissionDescriptor}; use crate::handler::{ - appointment_handler, article_handler, consultation_handler, daily_monitoring_handler, dialysis_handler, doctor_handler, follow_up_handler, - health_data_handler, patient_handler, points_handler, + appointment_handler, article_handler, consultation_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler, + health_data_handler, patient_handler, points_handler, stats_handler, }; pub struct HealthModule; @@ -39,6 +39,28 @@ impl HealthModule { }) } + /// 启动积分过期清理(每 24 小时运行一次),返回 JoinHandle 用于优雅关闭 + pub fn start_points_expiration_checker(db: sea_orm::DatabaseConnection) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600)); + loop { + tokio::select! { + _ = interval.tick() => { + match crate::service::points_service::expire_points(&db).await { + Ok(count) if count > 0 => tracing::info!(count = count, "积分过期清理完成"), + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "积分过期清理失败"), + } + } + _ = tokio::signal::ctrl_c() => { + tracing::info!("积分过期清理任务收到关闭信号,正在停止"); + break; + } + } + } + }) + } + pub fn public_routes() -> Router where crate::state::HealthState: axum::extract::FromRef, @@ -101,6 +123,17 @@ impl HealthModule { axum::routing::get(health_data_handler::list_vital_signs) .post(health_data_handler::create_vital_signs), ) + // 诊断记录 + .route( + "/health/patients/{id}/diagnoses", + axum::routing::get(diagnosis_handler::list_diagnoses) + .post(diagnosis_handler::create_diagnosis), + ) + .route( + "/health/diagnoses/{id}", + axum::routing::put(diagnosis_handler::update_diagnosis) + .delete(diagnosis_handler::delete_diagnosis), + ) .route( "/health/patients/{id}/vital-signs/{vid}", axum::routing::put(health_data_handler::update_vital_signs) @@ -364,6 +397,19 @@ impl HealthModule { "/health/admin/points/statistics", axum::routing::get(points_handler::get_points_statistics), ) + // 统计数据 — 管理端 + .route( + "/health/admin/statistics/patients", + axum::routing::get(stats_handler::get_patient_stats), + ) + .route( + "/health/admin/statistics/consultations", + axum::routing::get(stats_handler::get_consultation_stats), + ) + .route( + "/health/admin/statistics/follow-ups", + axum::routing::get(stats_handler::get_follow_up_stats), + ) } } @@ -416,23 +462,37 @@ impl ErpModule for HealthModule { crypto, }; - crate::event::register_handlers_with_state(state); + crate::event::register_handlers_with_state(state.clone()); tracing::info!(module = "health", "Health module event handlers registered via on_startup"); // 启动逾期随访检查器(立即执行一次 + 每 6 小时重复) { - let db = ctx.db.clone(); + let state_clone = state.clone(); tokio::spawn(async move { - match crate::service::follow_up_service::check_overdue_tasks(&db).await { + match crate::service::follow_up_service::check_overdue_and_notify(&state_clone).await { Ok(count) if count > 0 => tracing::info!(count = count, "启动时逾期随访检查完成"), Ok(_) => tracing::info!("启动时逾期随访检查完成(无逾期任务)"), Err(e) => tracing::warn!(error = %e, "启动时逾期随访检查失败"), } }); } - let _overdue_handle = Self::start_overdue_checker(ctx.db.clone()); + let _overdue_handle = Self::start_overdue_checker(state.db.clone()); tracing::info!(module = "health", "Overdue follow-up checker started"); + // 启动积分过期清理(启动时执行一次 + 每 24 小时重复) + { + let db = ctx.db.clone(); + tokio::spawn(async move { + match crate::service::points_service::expire_points(&db).await { + Ok(count) if count > 0 => tracing::info!(count = count, "启动时积分过期清理完成"), + Ok(_) => tracing::info!("启动时积分过期清理完成(无过期积分)"), + Err(e) => tracing::warn!(error = %e, "启动时积分过期清理失败"), + } + }); + } + let _expire_handle = Self::start_points_expiration_checker(ctx.db.clone()); + tracing::info!(module = "health", "Points expiration checker started"); + Ok(()) } diff --git a/crates/erp-health/src/service/diagnosis_service.rs b/crates/erp-health/src/service/diagnosis_service.rs new file mode 100644 index 0000000..8d02307 --- /dev/null +++ b/crates/erp-health/src/service/diagnosis_service.rs @@ -0,0 +1,209 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::diagnosis_dto::*; +use crate::entity::diagnosis; +use crate::entity::patient; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +pub async fn list_diagnoses( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = diagnosis::Entity::find() + .filter(diagnosis::Column::TenantId.eq(tenant_id)) + .filter(diagnosis::Column::PatientId.eq(patient_id)) + .filter(diagnosis::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(diagnosis::Column::DiagnosedDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(to_resp).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn create_diagnosis( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, + req: CreateDiagnosisReq, +) -> HealthResult { + patient::Entity::find() + .filter(patient::Column::Id.eq(patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + if !validate_diagnosis_type(&req.diagnosis_type) { + return Err(HealthError::Validation("诊断类型无效,可选: primary/secondary/comorbid".into())); + } + if !validate_diagnosis_status(&req.status) { + return Err(HealthError::Validation("诊断状态无效,可选: active/resolved/chronic".into())); + } + + let now = Utc::now(); + let active = diagnosis::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + health_record_id: Set(req.health_record_id), + icd_code: Set(req.icd_code), + diagnosis_name: Set(req.diagnosis_name), + diagnosis_type: Set(req.diagnosis_type), + diagnosed_date: Set(req.diagnosed_date), + status: Set(req.status), + diagnosed_by: Set(req.diagnosed_by.or(operator_id)), + notes: Set(req.notes), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "diagnosis.created", "diagnosis") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(to_resp(m)) +} + +pub async fn update_diagnosis( + state: &HealthState, + tenant_id: Uuid, + diagnosis_id: Uuid, + operator_id: Option, + req: UpdateDiagnosisReq, + expected_version: i32, +) -> HealthResult { + let model = diagnosis::Entity::find() + .filter(diagnosis::Column::Id.eq(diagnosis_id)) + .filter(diagnosis::Column::TenantId.eq(tenant_id)) + .filter(diagnosis::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::Validation("诊断记录不存在".into()))?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: diagnosis::ActiveModel = model.into(); + if let Some(v) = req.icd_code { active.icd_code = Set(v); } + if let Some(v) = req.diagnosis_name { active.diagnosis_name = Set(v); } + if let Some(v) = req.diagnosis_type { + if !validate_diagnosis_type(&v) { + return Err(HealthError::Validation("诊断类型无效".into())); + } + active.diagnosis_type = Set(v); + } + if let Some(v) = req.diagnosed_date { active.diagnosed_date = Set(v); } + if let Some(v) = req.status { + if !validate_diagnosis_status(&v) { + return Err(HealthError::Validation("诊断状态无效".into())); + } + active.status = Set(v); + } + if let Some(v) = req.health_record_id { active.health_record_id = Set(Some(v)); } + if let Some(v) = req.diagnosed_by { active.diagnosed_by = Set(Some(v)); } + if let Some(v) = req.notes { active.notes = Set(Some(v)); } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "diagnosis.updated", "diagnosis") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(to_resp(m)) +} + +pub async fn delete_diagnosis( + state: &HealthState, + tenant_id: Uuid, + diagnosis_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + let model = diagnosis::Entity::find() + .filter(diagnosis::Column::Id.eq(diagnosis_id)) + .filter(diagnosis::Column::TenantId.eq(tenant_id)) + .filter(diagnosis::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::Validation("诊断记录不存在".into()))?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: diagnosis::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "diagnosis.deleted", "diagnosis") + .with_resource_id(diagnosis_id), + &state.db, + ).await; + + Ok(()) +} + +fn to_resp(m: diagnosis::Model) -> DiagnosisResp { + DiagnosisResp { + id: m.id, + patient_id: m.patient_id, + health_record_id: m.health_record_id, + icd_code: m.icd_code, + diagnosis_name: m.diagnosis_name, + diagnosis_type: m.diagnosis_type, + diagnosed_date: m.diagnosed_date, + status: m.status, + diagnosed_by: m.diagnosed_by, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn validate_diagnosis_type(t: &str) -> bool { + matches!(t, "primary" | "secondary" | "comorbid") +} + +fn validate_diagnosis_status(s: &str) -> bool { + matches!(s, "active" | "resolved" | "chronic") +} diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index c2a3493..f0b9b62 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -85,6 +85,7 @@ pub async fn create_vital_signs( .ok_or(HealthError::PatientNotFound)?; let now = Utc::now(); + check_vital_signs_alert(state, tenant_id, patient_id, req.clone()).await; let active = vital_signs::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), @@ -618,3 +619,118 @@ pub async fn delete_health_record( Ok(()) } + +// --------------------------------------------------------------------------- +// 危急值预警检测 +// --------------------------------------------------------------------------- + +/// 检查体征数据中的危急值,发布 `health_data.critical_alert` 事件 +async fn check_vital_signs_alert( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: CreateVitalSignsReq, +) { + let mut alerts: Vec = Vec::new(); + + // 收缩压危急值 + if let Some(sbp) = req.systolic_bp_morning.or(req.systolic_bp_evening) { + if sbp >= 180 { + alerts.push(serde_json::json!({ + "indicator": "systolic_bp", + "value": sbp, + "threshold": 180, + "level": "critical", + "direction": "high" + })); + } else if sbp <= 80 { + alerts.push(serde_json::json!({ + "indicator": "systolic_bp", + "value": sbp, + "threshold": 80, + "level": "critical", + "direction": "low" + })); + } + } + + // 舒张压危急值 + if let Some(dbp) = req.diastolic_bp_morning.or(req.diastolic_bp_evening) { + if dbp >= 110 { + alerts.push(serde_json::json!({ + "indicator": "diastolic_bp", + "value": dbp, + "threshold": 110, + "level": "critical", + "direction": "high" + })); + } else if dbp <= 50 { + alerts.push(serde_json::json!({ + "indicator": "diastolic_bp", + "value": dbp, + "threshold": 50, + "level": "critical", + "direction": "low" + })); + } + } + + // 心率危急值 + if let Some(hr) = req.heart_rate { + if hr >= 150 { + alerts.push(serde_json::json!({ + "indicator": "heart_rate", + "value": hr, + "threshold": 150, + "level": "critical", + "direction": "high" + })); + } else if hr <= 40 { + alerts.push(serde_json::json!({ + "indicator": "heart_rate", + "value": hr, + "threshold": 40, + "level": "critical", + "direction": "low" + })); + } + } + + // 血糖危急值 + if let Some(bs) = req.blood_sugar { + if bs >= 25.0 { + alerts.push(serde_json::json!({ + "indicator": "blood_sugar", + "value": bs, + "threshold": 25.0, + "level": "critical", + "direction": "high" + })); + } else if bs <= 2.5 { + alerts.push(serde_json::json!({ + "indicator": "blood_sugar", + "value": bs, + "threshold": 2.5, + "level": "critical", + "direction": "low" + })); + } + } + + for alert in alerts { + let event = erp_core::events::DomainEvent::new( + "health_data.critical_alert", + tenant_id, + serde_json::json!({ + "patient_id": patient_id, + "alert": alert, + }), + ); + state.event_bus.publish(event, &state.db).await; + tracing::warn!( + patient_id = %patient_id, + tenant_id = %tenant_id, + "体征危急值预警已发布" + ); + } +} diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs index b2739aa..683beed 100644 --- a/crates/erp-health/src/service/points_service.rs +++ b/crates/erp-health/src/service/points_service.rs @@ -1526,3 +1526,89 @@ pub async fn get_points_statistics( top_earners, }) } + +// --------------------------------------------------------------------------- +// 积分过期清理 +// --------------------------------------------------------------------------- + +/// 扫描已过期的 earn 交易,扣减账户余额,更新 total_expired +/// 返回处理的过期交易数量 +pub async fn expire_points(db: &sea_orm::DatabaseConnection) -> HealthResult { + let now = Utc::now(); + + // 查找所有已过期但未标记 expired 的 earn 交易 + let expired_txns: Vec = points_transaction::Entity::find() + .filter(points_transaction::Column::Type.eq("earn")) + .filter(points_transaction::Column::Status.eq("active")) + .filter(points_transaction::Column::ExpiresAt.is_not_null()) + .filter(points_transaction::Column::ExpiresAt.lt(now)) + .filter(points_transaction::Column::DeletedAt.is_null()) + .filter(points_transaction::Column::RemainingAmount.gt(0)) + .all(db) + .await?; + + if expired_txns.is_empty() { + return Ok(0); + } + + let mut processed: u64 = 0; + + for txn in expired_txns { + let txn_id = txn.id; + let account_id = txn.account_id; + let remaining = txn.remaining_amount; + + let txn_result = db + .transaction::<_, (), HealthError>(|txn_db| { + Box::pin(async move { + // 标记交易为 expired + let mut active_txn: points_transaction::ActiveModel = txn.into(); + active_txn.status = Set("expired".to_string()); + active_txn.remaining_amount = Set(0); + active_txn.version = Set(active_txn.version.unwrap() + 1); + active_txn.updated_at = Set(Utc::now()); + active_txn.update(txn_db).await?; + + // 扣减账户余额,更新 total_expired + let account = points_account::Entity::find_by_id(account_id) + .one(txn_db) + .await? + .ok_or_else(|| HealthError::Validation("积分账户不存在".to_string()))?; + + let new_balance = (account.balance - remaining).max(0); + let new_expired = account.total_expired + remaining; + + let mut active_account: points_account::ActiveModel = account.into(); + active_account.balance = Set(new_balance); + active_account.total_expired = Set(new_expired); + active_account.version = Set(active_account.version.unwrap() + 1); + active_account.updated_at = Set(Utc::now()); + let expected_ver: i32 = match &active_account.version { + sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v, + _ => 0, + }; + let _next_ver = check_version(expected_ver, expected_ver)?; + active_account.update(txn_db).await?; + + Ok(()) + }) + }) + .await; + + match txn_result { + Ok(()) => { + processed += 1; + tracing::debug!(txn_id = %txn_id, remaining = remaining, "积分过期处理完成"); + } + Err(e) => { + tracing::warn!(txn_id = %txn_id, error = %e, "积分过期处理失败,跳过"); + } + } + } + + if processed > 0 { + tracing::info!(count = processed, "积分过期清理完成"); + } + + Ok(processed) +} diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs new file mode 100644 index 0000000..055fbc6 --- /dev/null +++ b/crates/erp-health/src/service/stats_service.rs @@ -0,0 +1,164 @@ +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, QuerySelect}; + +use erp_core::error::AppResult; + +use crate::dto::stats_dto::*; +use crate::entity::{ + patient, consultation_session, follow_up_task, + points_transaction, +}; +use crate::state::HealthState; + +pub async fn get_patient_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let new_this_month = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let new_this_week = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('week', NOW())"))) + .count(db) + .await?; + + let active_this_month = points_transaction::Entity::find() + .filter(points_transaction::Column::TenantId.eq(tenant_id)) + .filter(Expr::col(points_transaction::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + Ok(PatientStatisticsResp { + total_patients: total as i64, + new_this_month: new_this_month as i64, + new_this_week: new_this_week as i64, + active_this_month: active_this_month as i64, + }) +} + +pub async fn get_consultation_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_sessions = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let pending_reply = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::Status.eq("waiting")) + .count(db) + .await?; + + let this_month = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let avg_response_time_minutes = compute_avg_response_time(db, tenant_id).await?; + + Ok(ConsultationStatisticsResp { + total_sessions: total_sessions as i64, + pending_reply: pending_reply as i64, + avg_response_time_minutes, + this_month: this_month as i64, + }) +} + +pub async fn get_follow_up_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_tasks = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let completed = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter(follow_up_task::Column::Status.eq("completed")) + .count(db) + .await?; + + let pending = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter(follow_up_task::Column::Status.eq("pending")) + .count(db) + .await?; + + let overdue = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter(follow_up_task::Column::Status.eq("overdue")) + .count(db) + .await?; + + let completion_rate = if completed + pending + overdue > 0 { + (completed as f64 / (completed + pending + overdue) as f64) * 100.0 + } else { + 0.0 + }; + + Ok(FollowUpStatisticsResp { + total_tasks: total_tasks as i64, + completed: completed as i64, + pending: pending as i64, + overdue: overdue as i64, + completion_rate, + }) +} + +#[derive(Debug, FromQueryResult)] +struct AvgResponseTime { + avg_minutes: Option, +} + +async fn compute_avg_response_time( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult> { + let sql = r#" + SELECT AVG(EXTRACT(EPOCH FROM (m.created_at - s.created_at)) / 60) AS avg_minutes + FROM consultation_session s + INNER JOIN consultation_message m ON m.session_id = s.id AND m.tenant_id = $1 AND m.deleted_at IS NULL + WHERE s.tenant_id = $1 AND s.deleted_at IS NULL + AND m.sender_role = 'doctor' + "#; + + 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_minutes)) +}