Files
hms/crates/erp-health/src/service/stats_service/health.rs
iven 43f0ba7057
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
fix(web): 修复角色测试发现的权限守卫、API 500、权限配置问题
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 角色测试计划文档
2026-05-06 22:29:54 +08:00

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())
}