后端: - 新增 GET /health/admin/statistics/personal-stats 接口 - PersonalStatsResp: 13个个人维度统计字段 - 按医生/护士/管理员/运营角色聚合工作量数据 前端: - useDashboardRole hook: 按优先级 doctor>nurse>admin>operator 匹配角色 - DoctorDashboard: 今日工作台(日程/审核/消息/统计卡) - NurseDashboard: 随访监控台(异常提醒/队列/上报率) - AdminDashboard: 管理中心(5KPI + 健康数据Tab) - OperatorDashboard: 运营中心(积分/文章/活动) - StatisticsDashboard.tsx 重写为角色路由组件 - 删除旧区块:快捷入口/积分排行Top10/最近活动
875 lines
28 KiB
Rust
875 lines
28 KiB
Rust
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult};
|
||
|
||
use erp_core::error::AppResult;
|
||
|
||
use crate::dto::stats_dto::*;
|
||
use crate::entity::{
|
||
patient, consultation_session, follow_up_task,
|
||
points_transaction, dialysis_record, lab_report,
|
||
appointment, vital_signs, patient_doctor_relation, doctor_profile,
|
||
};
|
||
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;
|
||
|
||
// 单次 GROUP BY 查询替代 4 次独立 COUNT
|
||
let sql = r#"
|
||
SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt
|
||
FROM follow_up_task
|
||
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||
GROUP BY GROUPING SETS ((status), ())
|
||
"#;
|
||
|
||
#[derive(Debug, sea_orm::FromQueryResult)]
|
||
struct StatusCount {
|
||
status: String,
|
||
cnt: i64,
|
||
}
|
||
|
||
let rows: Vec<StatusCount> = 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 mut total_tasks: i64 = 0;
|
||
let mut completed: i64 = 0;
|
||
let mut pending: i64 = 0;
|
||
let mut overdue: i64 = 0;
|
||
|
||
for row in &rows {
|
||
match row.status.as_str() {
|
||
"__total" => total_tasks = row.cnt,
|
||
"completed" => completed = row.cnt,
|
||
"pending" => pending = row.cnt,
|
||
"overdue" => overdue = row.cnt,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
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,
|
||
completed,
|
||
pending,
|
||
overdue,
|
||
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))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 健康数据统计
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub async fn get_dialysis_statistics(
|
||
state: &HealthState,
|
||
tenant_id: uuid::Uuid,
|
||
) -> AppResult<DialysisStatisticsResp> {
|
||
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<LabReportStatisticsResp> {
|
||
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<AppointmentStatisticsResp> {
|
||
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<VitalSignsReportRateResp> {
|
||
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<HealthDataStatsResp> {
|
||
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<Vec<NameValue>> {
|
||
let rows: Vec<NameValueRow> = 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<f64>,
|
||
}
|
||
|
||
macro_rules! avg_field_sql {
|
||
($field:literal) => {
|
||
concat!(
|
||
"SELECT AVG(", $field, ")::FLOAT8 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())"
|
||
)
|
||
};
|
||
}
|
||
|
||
async fn compute_avg_field(
|
||
db: &sea_orm::DatabaseConnection,
|
||
tenant_id: uuid::Uuid,
|
||
field: &str,
|
||
) -> AppResult<Option<f64>> {
|
||
let sql = match field {
|
||
"ultrafiltration_volume" => avg_field_sql!("ultrafiltration_volume"),
|
||
"dialysis_duration" => avg_field_sql!("dialysis_duration"),
|
||
"uf_volume" => avg_field_sql!("uf_volume"),
|
||
"uf_rate" => avg_field_sql!("uf_rate"),
|
||
"blood_flow_rate" => avg_field_sql!("blood_flow_rate"),
|
||
"dialysate_flow_rate" => avg_field_sql!("dialysate_flow_rate"),
|
||
"pre_weight" => avg_field_sql!("pre_weight"),
|
||
"post_weight" => avg_field_sql!("post_weight"),
|
||
"pre_bp_systolic" => avg_field_sql!("pre_bp_systolic"),
|
||
"pre_bp_diastolic" => avg_field_sql!("pre_bp_diastolic"),
|
||
"post_bp_systolic" => avg_field_sql!("post_bp_systolic"),
|
||
"post_bp_diastolic" => avg_field_sql!("post_bp_diastolic"),
|
||
_ => return Err(erp_core::error::AppError::Validation(format!("不允许的字段名: {field}"))),
|
||
};
|
||
let result: Option<AvgFieldResult> = 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<f64> {
|
||
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<CompResult> = 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<i64> {
|
||
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<i64>,
|
||
}
|
||
|
||
let result: Option<AbnormalCount> = 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<u64> {
|
||
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<DistinctCount> = 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<Vec<DailyReportRate>> {
|
||
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<DailyRow> = 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())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 个人维度统计
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub async fn get_personal_stats(
|
||
state: &HealthState,
|
||
user_id: uuid::Uuid,
|
||
tenant_id: uuid::Uuid,
|
||
) -> AppResult<PersonalStatsResp> {
|
||
let db = &state.db;
|
||
|
||
// 通过 user_id 查找 doctor_profile 以获得 doctor_id
|
||
let doctor_profile = doctor_profile::Entity::find()
|
||
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_profile::Column::DeletedAt.is_null())
|
||
.filter(doctor_profile::Column::UserId.eq(user_id))
|
||
.one(db)
|
||
.await?;
|
||
|
||
let doctor_id = doctor_profile.map(|p| p.id);
|
||
|
||
// my_patients: 通过 patient_doctor_relation 统计
|
||
let my_patients = if let Some(did) = doctor_id {
|
||
patient_doctor_relation::Entity::find()
|
||
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
|
||
.filter(patient_doctor_relation::Column::DeletedAt.is_null())
|
||
.filter(patient_doctor_relation::Column::DoctorId.eq(did))
|
||
.count(db)
|
||
.await? as i64
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// new_patients_this_month: 本月新增关联患者
|
||
let new_patients_this_month = if let Some(did) = doctor_id {
|
||
let sql = r#"
|
||
SELECT COUNT(*) AS cnt
|
||
FROM patient_doctor_relation pdr
|
||
INNER JOIN patient p ON p.id = pdr.patient_id AND p.deleted_at IS NULL AND p.tenant_id = $1
|
||
WHERE pdr.tenant_id = $1 AND pdr.deleted_at IS NULL
|
||
AND pdr.doctor_id = $2
|
||
AND p.created_at >= date_trunc('month', NOW())
|
||
"#;
|
||
|
||
#[derive(Debug, FromQueryResult)]
|
||
struct Cnt {
|
||
cnt: i64,
|
||
}
|
||
|
||
let result: Option<Cnt> = FromQueryResult::find_by_statement(
|
||
sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
[tenant_id.into(), did.into()],
|
||
),
|
||
)
|
||
.one(db)
|
||
.await?;
|
||
|
||
result.map(|r| r.cnt).unwrap_or(0)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// follow_up_rate: 分配给当前用户的随访完成率
|
||
let sql = r#"
|
||
SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt
|
||
FROM follow_up_task
|
||
WHERE tenant_id = $1 AND deleted_at IS NULL AND assigned_to = $2
|
||
GROUP BY GROUPING SETS ((status), ())
|
||
"#;
|
||
|
||
#[derive(Debug, FromQueryResult)]
|
||
struct StatusCount {
|
||
status: String,
|
||
cnt: i64,
|
||
}
|
||
|
||
let fu_rows: Vec<StatusCount> = FromQueryResult::find_by_statement(
|
||
sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
sql,
|
||
[tenant_id.into(), user_id.into()],
|
||
),
|
||
)
|
||
.all(db)
|
||
.await?;
|
||
|
||
let mut fu_total: i64 = 0;
|
||
let mut fu_completed: i64 = 0;
|
||
let mut overdue_follow_ups: i64 = 0;
|
||
for row in &fu_rows {
|
||
match row.status.as_str() {
|
||
"__total" => fu_total = row.cnt,
|
||
"completed" => fu_completed = row.cnt,
|
||
"overdue" => overdue_follow_ups = row.cnt,
|
||
_ => {}
|
||
}
|
||
}
|
||
let follow_up_rate = if fu_total > 0 {
|
||
(fu_completed as f64 / fu_total as f64) * 100.0
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// consultations_this_month / pending_consultations: 咨询统计
|
||
let consultations_this_month = if let Some(did) = doctor_id {
|
||
consultation_session::Entity::find()
|
||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||
.filter(consultation_session::Column::DoctorId.eq(did))
|
||
.filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||
.count(db)
|
||
.await? as i64
|
||
} else {
|
||
0
|
||
};
|
||
|
||
let pending_consultations = if let Some(did) = doctor_id {
|
||
consultation_session::Entity::find()
|
||
.filter(consultation_session::Column::TenantId.eq(tenant_id))
|
||
.filter(consultation_session::Column::DeletedAt.is_null())
|
||
.filter(consultation_session::Column::DoctorId.eq(did))
|
||
.filter(consultation_session::Column::Status.eq("active"))
|
||
.count(db)
|
||
.await? as i64
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// today_appointments: 今日预约
|
||
let today_appointments = if let Some(did) = doctor_id {
|
||
appointment::Entity::find()
|
||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||
.filter(appointment::Column::DeletedAt.is_null())
|
||
.filter(appointment::Column::DoctorId.eq(did))
|
||
.filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE")))
|
||
.count(db)
|
||
.await? as i64
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// today_follow_ups: 今日随访任务
|
||
let today_follow_ups = 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::AssignedTo.eq(user_id))
|
||
.filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE")))
|
||
.count(db)
|
||
.await? as i64;
|
||
|
||
// vital_signs_report_rate: 当前医生的患者体征上报率
|
||
let (vital_signs_reported, vital_signs_total, vital_signs_report_rate) = if my_patients > 0 {
|
||
let vs_sql = r#"
|
||
SELECT
|
||
COUNT(DISTINCT vs.patient_id) AS reported,
|
||
$3::bigint AS total
|
||
FROM vital_signs vs
|
||
WHERE vs.tenant_id = $1 AND vs.deleted_at IS NULL
|
||
AND vs.created_at >= date_trunc('month', NOW())
|
||
AND vs.patient_id IN (
|
||
SELECT patient_id FROM patient_doctor_relation
|
||
WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL
|
||
)
|
||
"#;
|
||
|
||
#[derive(Debug, FromQueryResult)]
|
||
struct VsCount {
|
||
reported: i64,
|
||
total: i64,
|
||
}
|
||
|
||
let result: Option<VsCount> = FromQueryResult::find_by_statement(
|
||
sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
vs_sql,
|
||
[tenant_id.into(), doctor_id.unwrap_or_default().into(), my_patients.into()],
|
||
),
|
||
)
|
||
.one(db)
|
||
.await?;
|
||
|
||
match result {
|
||
Some(r) => {
|
||
let rate = if r.total > 0 {
|
||
(r.reported as f64 / r.total as f64) * 100.0
|
||
} else {
|
||
0.0
|
||
};
|
||
(r.reported, r.total, rate)
|
||
}
|
||
None => (0, my_patients, 0.0),
|
||
}
|
||
} else {
|
||
(0, 0, 0.0)
|
||
};
|
||
|
||
// pending_lab_reviews: 待审核化验报告(与当前医生的患者关联)
|
||
let pending_lab_reviews = if doctor_id.is_some() {
|
||
let lr_sql = r#"
|
||
SELECT COUNT(*) AS cnt
|
||
FROM lab_report lr
|
||
WHERE lr.tenant_id = $1 AND lr.deleted_at IS NULL
|
||
AND lr.status = 'pending'
|
||
AND lr.patient_id IN (
|
||
SELECT patient_id FROM patient_doctor_relation
|
||
WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL
|
||
)
|
||
"#;
|
||
|
||
#[derive(Debug, FromQueryResult)]
|
||
struct LrCnt {
|
||
cnt: i64,
|
||
}
|
||
|
||
let result: Option<LrCnt> = FromQueryResult::find_by_statement(
|
||
sea_orm::Statement::from_sql_and_values(
|
||
sea_orm::DatabaseBackend::Postgres,
|
||
lr_sql,
|
||
[tenant_id.into(), doctor_id.unwrap_or_default().into()],
|
||
),
|
||
)
|
||
.one(db)
|
||
.await?;
|
||
|
||
result.map(|r| r.cnt).unwrap_or(0)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
// abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置)
|
||
let abnormal_vital_signs: i64 = 0;
|
||
|
||
Ok(PersonalStatsResp {
|
||
my_patients,
|
||
new_patients_this_month,
|
||
follow_up_rate,
|
||
consultations_this_month,
|
||
pending_consultations,
|
||
vital_signs_report_rate,
|
||
today_appointments,
|
||
overdue_follow_ups,
|
||
today_follow_ups,
|
||
abnormal_vital_signs,
|
||
vital_signs_reported,
|
||
vital_signs_total,
|
||
pending_lab_reviews,
|
||
})
|
||
}
|