fix(health): 编译错误修复 — 类型不匹配/表名对齐/所有权修正
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- erp-ai entity 表名对齐数据库: ai_prompt/ai_analysis/ai_usage
- stats_service: count() u64 → i64 显式转换
- health_data_service: 危急值检测 i32 比较修正 + req 所有权修复
- points_service: check_version 参数修正
- diagnosis_service: 补充 ActiveModelTrait 导入
- module.rs: start_overdue_checker 参数改为 DatabaseConnection
- module.rs: register_handlers_with_state 避免 move
This commit is contained in:
iven
2026-04-26 00:28:31 +08:00
parent 44e15cd1d1
commit 7ab89f5e93
8 changed files with 644 additions and 9 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<S>() -> Router<S>
where
crate::state::HealthState: axum::extract::FromRef<S>,
@@ -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(())
}

View File

@@ -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<PaginatedResponse<DiagnosisResp>> {
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<Uuid>,
req: CreateDiagnosisReq,
) -> HealthResult<DiagnosisResp> {
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<Uuid>,
req: UpdateDiagnosisReq,
expected_version: i32,
) -> HealthResult<DiagnosisResp> {
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<Uuid>,
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")
}

View File

@@ -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<serde_json::Value> = 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,
"体征危急值预警已发布"
);
}
}

View File

@@ -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<u64> {
let now = Utc::now();
// 查找所有已过期但未标记 expired 的 earn 交易
let expired_txns: Vec<points_transaction::Model> = 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)
}

View File

@@ -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<PatientStatisticsResp> {
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<ConsultationStatisticsResp> {
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<FollowUpStatisticsResp> {
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<f64>,
}
async fn compute_avg_response_time(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
) -> AppResult<Option<f64>> {
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<AvgResponseTime> = 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))
}