1. CRITICAL: 前端路由权限守卫 — routePermissions 从 3 条扩展到 31 条, 覆盖全部 /health/* 路由;匹配逻辑从宽松模块级前缀改为精确权限码匹配 2. HIGH: health-data API 500 — jsonb_array_elements() 添加 CASE WHEN 类型守卫, 防止 items 字段为非数组 JSON 时崩溃 3. MEDIUM: Doctor 补充 ai.prompt.list、ai.usage.list、follow-up-templates 权限 4. Operator 清理 AI 分析、统计报表菜单关联 5. 更新 5 角色测试计划文档
321 lines
10 KiB
Rust
321 lines
10 KiB
Rust
//! 统计 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<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 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<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())
|
|
}
|
|
|
|
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(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<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::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<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())
|
|
}
|