diff --git a/crates/erp-core/src/health_provider.rs b/crates/erp-core/src/health_provider.rs index b31d995..55708a5 100644 --- a/crates/erp-core/src/health_provider.rs +++ b/crates/erp-core/src/health_provider.rs @@ -37,6 +37,15 @@ pub trait HealthDataProvider: Send + Sync { tenant_id: Uuid, report_id: Uuid, ) -> AppResult; + + /// 获取趋势分析预计算数据(统计摘要 + 异常检测) + async fn get_trend_analysis_data( + &self, + tenant_id: Uuid, + patient_id: Uuid, + metrics: &[String], + range: &TimeRange, + ) -> AppResult; } // === DTO 定义 === @@ -97,3 +106,56 @@ pub struct ReportSectionDto { pub findings: Vec, pub abnormal_items: Vec, } + +// === 趋势分析 DTO === + +/// 趋势方向 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TrendDirection { + Rising, + Falling, + Stable, +} + +/// 趋势分析整体 DTO +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrendAnalysisDto { + pub patient_id: Uuid, + pub period_start: String, + pub period_end: String, + pub metrics: Vec, +} + +/// 单个指标的趋势分析结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetricTrendAnalysis { + pub metric: String, + pub unit: String, + pub data_point_count: usize, + /// 线性回归统计(数据不足时为 None) + pub regression: Option, + /// 检测到的异常点 + pub anomalies: Vec, +} + +/// 线性回归统计摘要 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegressionStats { + pub slope: f64, + pub intercept: f64, + pub r_squared: f64, + pub direction: TrendDirection, + pub daily_change: f64, + pub period_change: f64, +} + +/// 异常点信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalyInfo { + pub date: String, + pub value: f64, + pub mean: f64, + pub std_dev: f64, + pub deviation: f64, +} diff --git a/crates/erp-health/src/health_provider_impl.rs b/crates/erp-health/src/health_provider_impl.rs index 07ed3f0..b5badf0 100644 --- a/crates/erp-health/src/health_provider_impl.rs +++ b/crates/erp-health/src/health_provider_impl.rs @@ -3,8 +3,9 @@ use chrono::Datelike; use erp_core::error::{AppError, AppResult}; use num_traits::ToPrimitive; use erp_core::health_provider::{ - HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto, PatientSummaryDto, - ReportSectionDto, TimeRange, VitalSignDto, + AnomalyInfo, HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto, + MetricTrendAnalysis, PatientSummaryDto, RegressionStats, ReportSectionDto, TimeRange, + TrendAnalysisDto, TrendDirection, VitalSignDto, }; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use uuid::Uuid; @@ -395,4 +396,111 @@ impl HealthDataProvider for HealthDataProviderImpl { sections, }) } + + async fn get_trend_analysis_data( + &self, + tenant_id: Uuid, + patient_id: Uuid, + metrics: &[String], + range: &TimeRange, + ) -> AppResult { + let _ = find_patient(&self.db, tenant_id, patient_id).await?; + + let start_date = range.start.date_naive(); + let end_date = range.end.date_naive(); + + let records = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .filter(vital_signs::Column::RecordDate.gte(start_date)) + .filter(vital_signs::Column::RecordDate.lte(end_date)) + .order_by_asc(vital_signs::Column::RecordDate) + .all(&self.db) + .await?; + + let metric_extractors: [(&str, Box Option>); 8] = [ + ("systolic_bp_morning", Box::new(|r| r.systolic_bp_morning.map(|v| v as f64))), + ("diastolic_bp_morning", Box::new(|r| r.diastolic_bp_morning.map(|v| v as f64))), + ("heart_rate", Box::new(|r| r.heart_rate.map(|v| v as f64))), + ("weight", Box::new(|r| r.weight.map(|v| v.to_f64().unwrap_or(0.0)))), + ("blood_sugar", Box::new(|r| r.blood_sugar.map(|v| v.to_f64().unwrap_or(0.0)))), + ("body_temperature", Box::new(|r| r.body_temperature.map(|v| v.to_f64().unwrap_or(0.0)))), + ("spo2", Box::new(|r| r.spo2.map(|v| v as f64))), + ("urine_output_ml", Box::new(|r| r.urine_output_ml.map(|v| v as f64))), + ]; + + let mut metric_results = Vec::new(); + + for (metric_name, extractor) in &metric_extractors { + if !metrics.is_empty() && !metrics.iter().any(|m| m == *metric_name) { + continue; + } + + // 构建时间序列数据 + let time_series: Vec<(chrono::NaiveDate, f64)> = records + .iter() + .filter_map(|r| extractor(r).map(|v| (r.record_date, v))) + .collect(); + + let data_point_count = time_series.len(); + if data_point_count == 0 { + continue; + } + + // 线性回归 + let regression = crate::service::trend_stats::compute_linear_regression(&time_series) + .map(|r| RegressionStats { + slope: r.slope, + intercept: r.intercept, + r_squared: r.r_squared, + direction: match r.direction { + crate::service::trend_stats::TrendDirection::Rising => TrendDirection::Rising, + crate::service::trend_stats::TrendDirection::Falling => TrendDirection::Falling, + crate::service::trend_stats::TrendDirection::Stable => TrendDirection::Stable, + }, + daily_change: r.daily_change, + period_change: r.period_change, + }); + + // 异常检测(使用 2.0 倍标准差阈值) + let anomaly_points = crate::service::trend_stats::detect_anomalies(&time_series, 2.0); + let anomalies: Vec = anomaly_points + .into_iter() + .map(|a| AnomalyInfo { + date: a.date.to_string(), + value: a.value, + mean: a.mean, + std_dev: a.std_dev, + deviation: a.deviation, + }) + .collect(); + + let unit = match *metric_name { + "systolic_bp_morning" | "diastolic_bp_morning" => "mmHg", + "heart_rate" => "bpm", + "weight" => "kg", + "blood_sugar" => "mmol/L", + "body_temperature" => "°C", + "spo2" => "%", + "urine_output_ml" => "ml", + _ => "", + }; + + metric_results.push(MetricTrendAnalysis { + metric: metric_name.to_string(), + unit: unit.to_string(), + data_point_count, + regression, + anomalies, + }); + } + + Ok(TrendAnalysisDto { + patient_id, + period_start: start_date.to_string(), + period_end: end_date.to_string(), + metrics: metric_results, + }) + } }