feat(core+health): HealthDataProvider 扩展趋势分析预计算数据
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-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:
iven
2026-04-28 19:55:06 +08:00
parent 8aac96b62f
commit 1c9e7ccf1d
2 changed files with 172 additions and 2 deletions

View File

@@ -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,
}

View File

@@ -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,
})
}
}