feat(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

- 新增 6 个统计端点: dialysis, lab-reports, appointments,
  vital-signs-report-rate, health-data(综合)
- 透析统计: 类型分布/并发症率/平均超滤/平均时长
- 化验统计: 类型分布/异常项计数/审核状态
- 预约统计: 状态/类型分布/取消率
- 体征上报率: 月度上报率 + 近 7 天趋势
- Web 统计面板增加健康数据中心区块
This commit is contained in:
iven
2026-04-26 14:19:38 +08:00
parent 55ec57b2c0
commit c9bf5f6139
6 changed files with 832 additions and 3 deletions

View File

@@ -1,6 +1,10 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
// ---------------------------------------------------------------------------
// 患者统计
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct PatientStatisticsResp {
pub total_patients: i64,
@@ -33,3 +37,90 @@ pub struct DashboardStatsResp {
pub consultations: ConsultationStatisticsResp,
pub follow_ups: FollowUpStatisticsResp,
}
// ---------------------------------------------------------------------------
// 健康数据统计
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DialysisStatisticsResp {
pub total_records: i64,
pub this_month: i64,
/// 本月各透析类型分布
pub type_distribution: Vec<NameValue>,
/// 本月并发症发生率 (%)
pub complication_rate: f64,
/// 平均超滤量 (ml)
pub avg_ultrafiltration: Option<f64>,
/// 平均透析时长 (分钟)
pub avg_duration: Option<f64>,
/// 待审核数量
pub pending_review: i64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LabReportStatisticsResp {
pub total_reports: i64,
pub this_month: i64,
/// 各报告类型分布
pub type_distribution: Vec<NameValue>,
/// 异常项数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<NameValue>,
/// 各预约类型分布
pub type_distribution: Vec<NameValue>,
/// 取消率 (%)
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<DailyReportRate>,
}
#[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,
}

View File

@@ -65,3 +65,72 @@ where
follow_ups,
})))
}
// ---------------------------------------------------------------------------
// 健康数据统计
// ---------------------------------------------------------------------------
pub async fn get_dialysis_stats<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<DialysisStatisticsResp>>, AppError>
where
HealthState: FromRef<S>,
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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<LabReportStatisticsResp>>, AppError>
where
HealthState: FromRef<S>,
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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<AppointmentStatisticsResp>>, AppError>
where
HealthState: FromRef<S>,
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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<VitalSignsReportRateResp>>, AppError>
where
HealthState: FromRef<S>,
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<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<HealthDataStatsResp>>, AppError>
where
HealthState: FromRef<S>,
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)))
}

View File

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

View File

@@ -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<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>,
}
async fn compute_avg_field(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
field: &str,
) -> AppResult<Option<f64>> {
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<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())
}