refactor(dialysis+health): 透析统计从 erp-health 迁移到 erp-dialysis,消除跨 crate 残留
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-dialysis: 新建 dialysis_stats_dto/handler/service,注册 /health/admin/statistics/dialysis 路由
- erp-health: 删除 get_dialysis_statistics 及 helper、DialysisStatisticsResp、
  DialysisRecordNotFound/DialysisPrescriptionNotFound、validate_dialysis_status* 及 9 个测试、
  DoctorDashboard.pending_dialysis_review、module 路由
- Web: HealthDataStats 移除 dialysis 字段,新增 getDialysisStats() 独立 API,
  useStatsData 并行 fetch,HealthDataCenter 接受独立 dialysisData prop
- 小程序: DoctorDashboard 移除 pending_dialysis_review,医护工作台移除"待审透析"卡片
This commit is contained in:
iven
2026-04-29 07:56:21 +08:00
parent cb6f5cc651
commit facc8b0d24
20 changed files with 234 additions and 265 deletions

View File

@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[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 NameValue {
pub name: String,
pub value: i64,
}

View File

@@ -1,5 +1,6 @@
pub mod dialysis_dto;
pub mod dialysis_prescription_dto;
pub mod dialysis_stats_dto;
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteWithVersion {

View File

@@ -0,0 +1,23 @@
use axum::extract::{Extension, FromRef, State};
use axum::Json;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::dialysis_stats_dto::DialysisStatisticsResp;
use crate::service::dialysis_stats_service;
use crate::state::DialysisState;
pub async fn get_dialysis_stats<S>(
State(state): State<S>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<DialysisStatisticsResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis.list")?;
let dialysis_state = DialysisState::from_ref(&state);
let stats = dialysis_stats_service::get_dialysis_statistics(&dialysis_state, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(stats)))
}

View File

@@ -1,2 +1,3 @@
pub mod dialysis_handler;
pub mod dialysis_prescription_handler;
pub mod dialysis_stats_handler;

View File

@@ -3,7 +3,7 @@ use axum::Router;
use erp_core::error::AppResult;
use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor};
use crate::handler::{dialysis_handler, dialysis_prescription_handler};
use crate::handler::{dialysis_handler, dialysis_prescription_handler, dialysis_stats_handler};
use crate::state::DialysisState;
pub struct DialysisModule;
@@ -54,6 +54,11 @@ impl DialysisModule {
.put(dialysis_prescription_handler::update_prescription)
.delete(dialysis_prescription_handler::delete_prescription),
)
// 透析统计
.route(
"/health/admin/statistics/dialysis",
axum::routing::get(dialysis_stats_handler::get_dialysis_stats),
)
}
}

View File

@@ -0,0 +1,134 @@
use sea_orm::{DatabaseBackend, FromQueryResult, Statement};
use uuid::Uuid;
use crate::dto::dialysis_stats_dto::{DialysisStatisticsResp, NameValue};
use crate::error::{DialysisResult, DialysisError};
use crate::state::DialysisState;
pub async fn get_dialysis_statistics(
state: &DialysisState,
tenant_id: Uuid,
) -> DialysisResult<DialysisStatisticsResp> {
let db = &state.db;
#[derive(FromQueryResult)]
struct CountRow { count: i64 }
let total_records = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL",
[tenant_id.into()],
)).one(db).await?.map(|r| r.count).unwrap_or(0);
let this_month = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND created_at >= date_trunc('month', NOW())",
[tenant_id.into()],
)).one(db).await?.map(|r| r.count).unwrap_or(0);
let pending_review = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'draft'",
[tenant_id.into()],
)).one(db).await?.map(|r| r.count).unwrap_or(0);
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,
this_month,
type_distribution,
complication_rate,
avg_ultrafiltration,
avg_duration,
pending_review,
})
}
async fn count_by_field(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
sql: &str,
) -> DialysisResult<Vec<NameValue>> {
#[derive(FromQueryResult)]
struct NameValueRow { name: String, value: i64 }
let rows: Vec<NameValueRow> = FromQueryResult::find_by_statement(
Statement::from_sql_and_values(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,
field: &str,
) -> DialysisResult<Option<f64>> {
let sql = match field {
"ultrafiltration_volume" => avg_field_sql!("ultrafiltration_volume"),
"dialysis_duration" => avg_field_sql!("dialysis_duration"),
"blood_flow_rate" => avg_field_sql!("blood_flow_rate"),
_ => return Err(DialysisError::Validation(format!("不允许的字段名: {field}"))),
};
let result: Option<AvgFieldResult> = FromQueryResult::find_by_statement(
Statement::from_sql_and_values(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,
) -> DialysisResult<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> = FromQueryResult::find_by_statement(
Statement::from_sql_and_values(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,
})
}

View File

@@ -1,2 +1,3 @@
pub mod dialysis_service;
pub mod dialysis_prescription_service;
pub mod dialysis_stats_service;

View File

@@ -42,22 +42,6 @@ pub struct DashboardStatsResp {
// 健康数据统计
// ---------------------------------------------------------------------------
#[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,
@@ -109,7 +93,6 @@ pub struct DailyReportRate {
/// 健康数据中心综合统计。
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct HealthDataStatsResp {
pub dialysis: DialysisStatisticsResp,
pub lab_reports: LabReportStatisticsResp,
pub appointments: AppointmentStatisticsResp,
pub vital_signs_report_rate: VitalSignsReportRateResp,

View File

@@ -23,9 +23,6 @@ pub enum HealthError {
#[error("化验报告不存在")]
LabReportNotFound,
#[error("透析记录不存在")]
DialysisRecordNotFound,
#[error("日常监测记录不存在")]
DailyMonitoringNotFound,
@@ -77,9 +74,6 @@ pub enum HealthError {
#[error("告警记录不存在")]
AlertNotFound,
#[error("透析方案不存在")]
DialysisPrescriptionNotFound,
#[error("随访模板不存在")]
FollowUpTemplateNotFound,
@@ -106,7 +100,6 @@ impl From<HealthError> for AppError {
| HealthError::ScheduleNotFound
| HealthError::VitalSignsNotFound
| HealthError::LabReportNotFound
| HealthError::DialysisRecordNotFound
| HealthError::HealthRecordNotFound
| HealthError::FamilyMemberNotFound
| HealthError::TagNotFound
@@ -123,7 +116,6 @@ impl From<HealthError> for AppError {
| HealthError::AlertRuleNotFound
| HealthError::DeviceNotFound
| HealthError::AlertNotFound
| HealthError::DialysisPrescriptionNotFound
| HealthError::FollowUpTemplateNotFound
| HealthError::CriticalAlertNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),

View File

@@ -70,19 +70,6 @@ where
// 健康数据统计
// ---------------------------------------------------------------------------
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>,

View File

@@ -549,10 +549,6 @@ 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),

View File

@@ -462,7 +462,6 @@ pub struct DoctorDashboard {
pub unread_messages: i64,
pub pending_follow_ups: i64,
pub today_consultations: i64,
pub pending_dialysis_review: i64,
pub pending_lab_review: i64,
pub today_appointments: i64,
}
@@ -495,7 +494,6 @@ pub async fn get_doctor_dashboard(
unread_messages: 0,
pending_follow_ups: 0,
today_consultations: 0,
pending_dialysis_review: 0,
pending_lab_review: 0,
today_appointments: 0,
});
@@ -567,7 +565,6 @@ pub async fn get_doctor_dashboard(
unread_messages,
pending_follow_ups: pending_follow_ups as i64,
today_consultations: today_consultations as i64,
pending_dialysis_review: 0,
pending_lab_review: 0,
today_appointments: 0,
})
@@ -584,16 +581,6 @@ pub async fn enrich_doctor_dashboard_health(
use crate::entity::{lab_report, appointment};
use sea_orm::{FromQueryResult, Statement, DatabaseBackend};
// 待审核透析记录raw SQL — entity 已拆分到 erp-dialysis crate
#[derive(FromQueryResult)]
struct DialysisCount { count: i64 }
let pending_dialysis = DialysisCount::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'draft'",
[tenant_id.into()],
)).one(&state.db).await?.map(|r| r.count).unwrap_or(0);
dashboard.pending_dialysis_review = pending_dialysis;
// 待审核化验报告
let pending_lab = lab_report::Entity::find()
.filter(lab_report::Column::TenantId.eq(tenant_id))

View File

@@ -184,57 +184,6 @@ async fn compute_avg_response_time(
// 健康数据统计
// ---------------------------------------------------------------------------
pub async fn get_dialysis_statistics(
state: &HealthState,
tenant_id: uuid::Uuid,
) -> AppResult<DialysisStatisticsResp> {
let db = &state.db;
// 使用 raw SQL 替代 dialysis_record entity已拆分到 erp-dialysis crate
#[derive(FromQueryResult)]
struct CountRow { count: i64 }
let total_records = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL",
[tenant_id.into()],
)).one(db).await?.map(|r| r.count).unwrap_or(0);
let this_month = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND created_at >= date_trunc('month', NOW())",
[tenant_id.into()],
)).one(db).await?.map(|r| r.count).unwrap_or(0);
let pending_review = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
"SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'draft'",
[tenant_id.into()],
)).one(db).await?.map(|r| r.count).unwrap_or(0);
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,
this_month,
type_distribution,
complication_rate,
avg_ultrafiltration,
avg_duration,
pending_review,
})
}
pub async fn get_lab_report_statistics(
state: &HealthState,
tenant_id: uuid::Uuid,
@@ -388,13 +337,11 @@ 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,
@@ -429,89 +376,6 @@ async fn count_by_field(
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,

View File

@@ -163,32 +163,6 @@ pub fn validate_article_status_transition(current: &str, new: &str) -> HealthRes
}
}
/// dialysis_record.status 枚举白名单
pub fn validate_dialysis_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "dialysis_record.status", ["draft", "completed", "reviewed"]);
Ok(())
}
/// dialysis_record.status 状态转换
/// draft → completed → reviewed
pub fn validate_dialysis_status_transition(current: &str, new: &str) -> HealthResult<()> {
if current == new {
return Ok(());
}
let allowed = match current {
"draft" => matches!(new, "completed"),
"completed" => matches!(new, "reviewed"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"dialysis_record.status: 不允许从 '{}' 转换到 '{}'", current, new
)))
}
}
/// lab_report.status 状态转换
/// pending → reviewed
pub fn validate_lab_report_status_transition(current: &str, new: &str) -> HealthResult<()> {
@@ -433,28 +407,6 @@ mod tests {
#[test]
fn art_same_status_ok() { assert!(validate_article_status_transition("draft", "draft").is_ok()); }
// --- dialysis_status ---
#[test]
fn dialysis_draft() { assert!(validate_dialysis_status("draft").is_ok()); }
#[test]
fn dialysis_reviewed() { assert!(validate_dialysis_status("reviewed").is_ok()); }
#[test]
fn dialysis_invalid() { assert!(validate_dialysis_status("approved").is_err()); }
// --- dialysis_status_transition ---
#[test]
fn dial_draft_to_completed() { assert!(validate_dialysis_status_transition("draft", "completed").is_ok()); }
#[test]
fn dial_draft_to_reviewed_fails() { assert!(validate_dialysis_status_transition("draft", "reviewed").is_err()); }
#[test]
fn dial_completed_to_reviewed() { assert!(validate_dialysis_status_transition("completed", "reviewed").is_ok()); }
#[test]
fn dial_completed_to_draft_fails() { assert!(validate_dialysis_status_transition("completed", "draft").is_err()); }
#[test]
fn dial_reviewed_to_any_fails() { assert!(validate_dialysis_status_transition("reviewed", "draft").is_err()); }
#[test]
fn dial_same_status_ok() { assert!(validate_dialysis_status_transition("draft", "draft").is_ok()); }
// --- lab_report_status_transition ---
#[test]
fn lab_pending_to_reviewed() { assert!(validate_lab_report_status_transition("pending", "reviewed").is_ok()); }