feat(core+health): HealthDataProvider 扩展趋势分析预计算数据
- erp-core: 新增 get_trend_analysis_data() trait 方法和配套 DTO (TrendAnalysisDto, MetricTrendAnalysis, RegressionStats, AnomalyInfo) - erp-health: 实现 get_trend_analysis_data(),查询 vital_signs 时间序列 后调用 trend_stats 模块计算线性回归和异常检测,返回结构化统计摘要
This commit is contained in:
@@ -37,6 +37,15 @@ pub trait HealthDataProvider: Send + Sync {
|
||||
tenant_id: Uuid,
|
||||
report_id: Uuid,
|
||||
) -> AppResult<HealthReportDto>;
|
||||
|
||||
/// 获取趋势分析预计算数据(统计摘要 + 异常检测)
|
||||
async fn get_trend_analysis_data(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
metrics: &[String],
|
||||
range: &TimeRange,
|
||||
) -> AppResult<TrendAnalysisDto>;
|
||||
}
|
||||
|
||||
// === DTO 定义 ===
|
||||
@@ -97,3 +106,56 @@ pub struct ReportSectionDto {
|
||||
pub findings: Vec<String>,
|
||||
pub abnormal_items: Vec<String>,
|
||||
}
|
||||
|
||||
// === 趋势分析 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<MetricTrendAnalysis>,
|
||||
}
|
||||
|
||||
/// 单个指标的趋势分析结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetricTrendAnalysis {
|
||||
pub metric: String,
|
||||
pub unit: String,
|
||||
pub data_point_count: usize,
|
||||
/// 线性回归统计(数据不足时为 None)
|
||||
pub regression: Option<RegressionStats>,
|
||||
/// 检测到的异常点
|
||||
pub anomalies: Vec<AnomalyInfo>,
|
||||
}
|
||||
|
||||
/// 线性回归统计摘要
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -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<TrendAnalysisDto> {
|
||||
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<dyn Fn(&vital_signs::Model) -> Option<f64>>); 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<AnomalyInfo> = 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user