diff --git a/apps/miniprogram/src/pages/doctor/index.tsx b/apps/miniprogram/src/pages/doctor/index.tsx index 3587c47..5911e72 100644 --- a/apps/miniprogram/src/pages/doctor/index.tsx +++ b/apps/miniprogram/src/pages/doctor/index.tsx @@ -21,7 +21,6 @@ const CARDS: CardConfig[] = [ ]; const HEALTH_CARDS: CardConfig[] = [ - { key: 'pending_dialysis_review', label: '待审透析', initial: '透', route: '/pages/doctor/patients/index' }, { key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages/doctor/report/index' }, { key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages/doctor/patients/index' }, ]; diff --git a/apps/miniprogram/src/services/doctor.ts b/apps/miniprogram/src/services/doctor.ts index 98f9fa5..50dbac2 100644 --- a/apps/miniprogram/src/services/doctor.ts +++ b/apps/miniprogram/src/services/doctor.ts @@ -8,7 +8,6 @@ export interface DoctorDashboard { unread_messages: number; pending_follow_ups: number; today_consultations: number; - pending_dialysis_review: number; pending_lab_review: number; today_appointments: number; } diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index 8923ab4..ce8da75 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -214,7 +214,6 @@ export interface VitalSignsReportRate { } export interface HealthDataStats { - dialysis: DialysisStatistics; lab_reports: LabReportStatistics; appointments: AppointmentStatistics; vital_signs_report_rate: VitalSignsReportRate; @@ -376,6 +375,14 @@ export const pointsApi = { return data.data; }, + getDialysisStats: async (): Promise => { + const { data } = await client.get<{ + success: boolean; + data: DialysisStatistics; + }>('/health/admin/statistics/dialysis'); + return data.data; + }, + getPersonalStats: async (): Promise => { const { data } = await client.get<{ success: boolean; diff --git a/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx index fc3f919..95a9b0b 100644 --- a/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx +++ b/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx @@ -11,7 +11,7 @@ import { useCountUp } from '../../../hooks/useCountUp'; import HealthDataCenter from './HealthDataCenter'; export function AdminDashboard() { - const { patientStats, followUpStats, healthDataStats, loading } = useStatsData(); + const { patientStats, followUpStats, healthDataStats, dialysisStats, loading } = useStatsData(); const patientCount = useCountUp(patientStats?.total_patients ?? 0); const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0); const doctorCount = useCountUp(0); @@ -72,7 +72,7 @@ export function AdminDashboard() { }, + { key: 'dialysis', label: '透析管理', children: }, { key: 'lab', label: '化验报告', children: }, { key: 'appointments', label: '预约分析', children: }, { key: 'vital-signs', label: '体征数据', children: }, diff --git a/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx b/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx index 6c83d7f..a9f025e 100644 --- a/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx +++ b/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx @@ -1,30 +1,31 @@ import { Row, Col, Card, Statistic, Tag, Typography, Empty } from 'antd'; -import type { HealthDataStats } from '../../../api/health/points'; +import type { HealthDataStats, DialysisStatistics } from '../../../api/health/points'; const { Text } = Typography; interface HealthDataCenterProps { data: HealthDataStats | null; + dialysisData?: DialysisStatistics | null; tab?: string; } -function DialysisPanel({ data }: { data: HealthDataStats | null }) { +function DialysisPanel({ data }: { data: DialysisStatistics | null | undefined }) { return ( 透析记录} style={{ borderRadius: 8 }}> - - - + + + - - - + + + - {(data?.dialysis.type_distribution ?? []).length > 0 && ( + {(data?.type_distribution ?? []).length > 0 && (
类型分布: - {data!.dialysis.type_distribution.map((item) => ( + {data!.type_distribution.map((item) => ( {item.name}: {item.value} ))}
@@ -101,15 +102,24 @@ function VitalSignsPanel({ data }: { data: HealthDataStats | null }) { ); } -const TAB_PANELS: Record> = { - dialysis: DialysisPanel, - lab: LabPanel, - appointments: AppointmentsPanel, - 'vital-signs': VitalSignsPanel, -}; +export default function HealthDataCenter({ data, dialysisData, tab = 'dialysis' }: HealthDataCenterProps) { + if (tab === 'dialysis') { + return ( + + + + + + ); + } -export default function HealthDataCenter({ data, tab = 'dialysis' }: HealthDataCenterProps) { - const Panel = TAB_PANELS[tab]; + const PANELS: Record> = { + lab: LabPanel, + appointments: AppointmentsPanel, + 'vital-signs': VitalSignsPanel, + }; + + const Panel = PANELS[tab]; if (!Panel) { return ; diff --git a/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts b/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts index e404637..b33d7a5 100644 --- a/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts +++ b/apps/web/src/pages/health/StatisticsDashboard/useStatsData.ts @@ -6,6 +6,7 @@ import { type FollowUpStatistics, type PointsStatistics, type HealthDataStats, + type DialysisStatistics, } from '../../../api/health/points'; export interface StatsData { @@ -14,6 +15,7 @@ export interface StatsData { followUpStats: FollowUpStatistics | null; pointsStats: PointsStatistics | null; healthDataStats: HealthDataStats | null; + dialysisStats: DialysisStatistics | null; loading: boolean; error: string | null; refresh: () => void; @@ -28,6 +30,7 @@ export function useStatsData(): StatsData { const [followUpStats, setFollowUpStats] = useState(null); const [pointsStats, setPointsStats] = useState(null); const [healthDataStats, setHealthDataStats] = useState(null); + const [dialysisStats, setDialysisStats] = useState(null); const fetchAllStats = useCallback(async () => { setLoading(true); @@ -52,9 +55,10 @@ export function useStatsData(): StatsData { tryFetch(pointsApi.getFollowUpStats, setFollowUpStats, '随访'), tryFetch(pointsApi.getStatistics, setPointsStats, '积分'), tryFetch(pointsApi.getHealthDataStats, setHealthDataStats, '健康数据'), + tryFetch(pointsApi.getDialysisStats, setDialysisStats, '透析'), ]); - if (hasAnyError && errors.length === 5) { + if (hasAnyError && errors.length === 6) { setError('加载统计数据失败'); } @@ -66,7 +70,7 @@ export function useStatsData(): StatsData { }, [fetchAllStats]); return { - patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, + patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, loading, error, refresh: fetchAllStats, }; } diff --git a/crates/erp-dialysis/src/dto/dialysis_stats_dto.rs b/crates/erp-dialysis/src/dto/dialysis_stats_dto.rs new file mode 100644 index 0000000..8f7c928 --- /dev/null +++ b/crates/erp-dialysis/src/dto/dialysis_stats_dto.rs @@ -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, + /// 本月并发症发生率 (%) + pub complication_rate: f64, + /// 平均超滤量 (ml) + pub avg_ultrafiltration: Option, + /// 平均透析时长 (分钟) + pub avg_duration: Option, + /// 待审核数量 + pub pending_review: i64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct NameValue { + pub name: String, + pub value: i64, +} diff --git a/crates/erp-dialysis/src/dto/mod.rs b/crates/erp-dialysis/src/dto/mod.rs index dbaa8bd..7b2b229 100644 --- a/crates/erp-dialysis/src/dto/mod.rs +++ b/crates/erp-dialysis/src/dto/mod.rs @@ -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 { diff --git a/crates/erp-dialysis/src/handler/dialysis_stats_handler.rs b/crates/erp-dialysis/src/handler/dialysis_stats_handler.rs new file mode 100644 index 0000000..f4c2cbd --- /dev/null +++ b/crates/erp-dialysis/src/handler/dialysis_stats_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + DialysisState: FromRef, + 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))) +} diff --git a/crates/erp-dialysis/src/handler/mod.rs b/crates/erp-dialysis/src/handler/mod.rs index 784162d..ee7fc12 100644 --- a/crates/erp-dialysis/src/handler/mod.rs +++ b/crates/erp-dialysis/src/handler/mod.rs @@ -1,2 +1,3 @@ pub mod dialysis_handler; pub mod dialysis_prescription_handler; +pub mod dialysis_stats_handler; diff --git a/crates/erp-dialysis/src/module.rs b/crates/erp-dialysis/src/module.rs index 7fdea60..13e63d8 100644 --- a/crates/erp-dialysis/src/module.rs +++ b/crates/erp-dialysis/src/module.rs @@ -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), + ) } } diff --git a/crates/erp-dialysis/src/service/dialysis_stats_service.rs b/crates/erp-dialysis/src/service/dialysis_stats_service.rs new file mode 100644 index 0000000..a2dc38e --- /dev/null +++ b/crates/erp-dialysis/src/service/dialysis_stats_service.rs @@ -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 { + 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> { + #[derive(FromQueryResult)] + struct NameValueRow { name: String, value: i64 } + + let rows: Vec = 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 } + +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> { + 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 = 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 { + 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 = 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, + }) +} diff --git a/crates/erp-dialysis/src/service/mod.rs b/crates/erp-dialysis/src/service/mod.rs index a0a12eb..01e8444 100644 --- a/crates/erp-dialysis/src/service/mod.rs +++ b/crates/erp-dialysis/src/service/mod.rs @@ -1,2 +1,3 @@ pub mod dialysis_service; pub mod dialysis_prescription_service; +pub mod dialysis_stats_service; diff --git a/crates/erp-health/src/dto/stats_dto.rs b/crates/erp-health/src/dto/stats_dto.rs index fd72ad8..d50f295 100644 --- a/crates/erp-health/src/dto/stats_dto.rs +++ b/crates/erp-health/src/dto/stats_dto.rs @@ -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, - /// 本月并发症发生率 (%) - pub complication_rate: f64, - /// 平均超滤量 (ml) - pub avg_ultrafiltration: Option, - /// 平均透析时长 (分钟) - pub avg_duration: Option, - /// 待审核数量 - 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, diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index c0fb0a1..aa9f7a8 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -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 for AppError { | HealthError::ScheduleNotFound | HealthError::VitalSignsNotFound | HealthError::LabReportNotFound - | HealthError::DialysisRecordNotFound | HealthError::HealthRecordNotFound | HealthError::FamilyMemberNotFound | HealthError::TagNotFound @@ -123,7 +116,6 @@ impl From 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()), diff --git a/crates/erp-health/src/handler/stats_handler.rs b/crates/erp-health/src/handler/stats_handler.rs index b1bf2b1..e6fea38 100644 --- a/crates/erp-health/src/handler/stats_handler.rs +++ b/crates/erp-health/src/handler/stats_handler.rs @@ -70,19 +70,6 @@ where // 健康数据统计 // --------------------------------------------------------------------------- -pub async fn get_dialysis_stats( - State(state): State, - Extension(ctx): Extension, -) -> Result>, AppError> -where - HealthState: FromRef, - 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( State(state): State, Extension(ctx): Extension, diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 3d188f8..eb12a09 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -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), diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index 4c3f579..65bc37c 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -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)) diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs index f1444a4..c0cfc18 100644 --- a/crates/erp-health/src/service/stats_service.rs +++ b/crates/erp-health/src/service/stats_service.rs @@ -184,57 +184,6 @@ async fn compute_avg_response_time( // 健康数据统计 // --------------------------------------------------------------------------- -pub async fn get_dialysis_statistics( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - 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 { - 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, -} - -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> { - 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 = 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 { - 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 = 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, diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index a11ead6..433ee3d 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -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()); }