Files
hms/crates/erp-health/src/health_provider_impl.rs

627 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use async_trait::async_trait;
use chrono::Datelike;
use erp_core::crypto::{self as pii, PiiCrypto};
use erp_core::error::{AppError, AppResult};
use erp_core::health_provider::{
AnomalyInfo, AppointmentSummaryDto, HealthDataProvider, HealthReportDto, LabItemDto,
LabReportDto, MedicationSummaryDto, MetricTrendAnalysis, PatientSummaryDto, RegressionStats,
ReportSectionDto, TimeRange, TrendAnalysisDto, TrendDirection, VitalSignDto,
};
use num_traits::ToPrimitive;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use uuid::Uuid;
use crate::entity::{
appointment, diagnosis, health_record, lab_report, medication_record, patient, vital_signs,
};
pub struct HealthDataProviderImpl {
pub db: sea_orm::DatabaseConnection,
pub crypto: PiiCrypto,
}
fn compute_age_group(birth_date: Option<chrono::NaiveDate>) -> String {
let Some(bd) = birth_date else {
return "未知".to_string();
};
let age = chrono::Utc::now().date_naive().year() - bd.year();
match age {
a if a < 14 => "儿童",
a if a < 36 => "青年",
a if a < 56 => "中年",
_ => "老年",
}
.to_string()
}
async fn find_patient(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<patient::Model> {
patient::Entity::find_by_id(patient_id)
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("患者 {patient_id} 不存在")))
}
async fn find_lab_report(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
report_id: Uuid,
) -> AppResult<lab_report::Model> {
lab_report::Entity::find_by_id(report_id)
.filter(lab_report::Column::TenantId.eq(tenant_id))
.filter(lab_report::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| AppError::NotFound(format!("化验报告 {report_id} 不存在")))
}
fn parse_lab_items(items_json: &Option<serde_json::Value>) -> Vec<LabItemDto> {
let Some(arr) = items_json.as_ref().and_then(|v| v.as_array()) else {
return vec![];
};
arr.iter()
.filter_map(|item| {
// 兼容两种存储格式item_name/name
let name = item
.get("item_name")
.or_else(|| item.get("name"))?
.as_str()?
.to_string();
// 兼容 value 为字符串或数字
let value = item
.get("value")
.and_then(|v| v.as_f64())
.or_else(|| item.get("value")?.as_str()?.parse::<f64>().ok())
.unwrap_or(0.0);
let unit = item
.get("unit")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// 兼容两种参考范围格式reference_range(string) 或 reference_low/reference_high(number)
let reference_range = item
.get("reference_range")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
let low = item
.get("reference_low")
.and_then(|v| v.as_f64())
.map(|l| l.to_string());
let high = item
.get("reference_high")
.and_then(|v| v.as_f64())
.map(|h| h.to_string());
match (low, high) {
(Some(l), Some(h)) => format!("{l}-{h}"),
(Some(l), None) => format!(">={l}"),
(None, Some(h)) => format!("<={h}"),
_ => "-".to_string(),
}
});
let is_abnormal = item
.get("is_abnormal")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Some(LabItemDto {
name,
value,
unit,
reference_range,
is_abnormal,
})
})
.collect()
}
fn report_type_to_department(report_type: &str) -> &str {
match report_type {
"kidney_function" => "肾内科",
"blood_routine" => "血液科",
"electrolyte" => "检验科",
"liver_function" => "肝胆科",
_ => "检验科",
}
}
#[async_trait]
impl HealthDataProvider for HealthDataProviderImpl {
async fn get_lab_report(&self, tenant_id: Uuid, report_id: Uuid) -> AppResult<LabReportDto> {
let report = find_lab_report(&self.db, tenant_id, report_id).await?;
let patient = find_patient(&self.db, tenant_id, report.patient_id).await?;
// 解密 items加密时存储为 Value::String(ciphertext)
let kek = self.crypto.kek();
let decrypted_items = report
.items
.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(report.items.clone());
Ok(LabReportDto {
age_group: compute_age_group(patient.birth_date),
sex: patient.gender.unwrap_or_else(|| "未知".to_string()),
department: report_type_to_department(&report.report_type).to_string(),
report_date: report.report_date.to_string(),
items: parse_lab_items(&decrypted_items),
})
}
async fn get_vital_signs(
&self,
tenant_id: Uuid,
patient_id: Uuid,
metrics: &[String],
range: &TimeRange,
) -> AppResult<Vec<VitalSignDto>> {
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 result = Vec::new();
for (metric_name, extractor) in &metric_extractors {
if !metrics.is_empty() && !metrics.iter().any(|m| m == *metric_name) {
continue;
}
let values: Vec<(String, f64)> = records
.iter()
.filter_map(|r| extractor(r).map(|v| (r.record_date.to_string(), v)))
.collect();
if values.is_empty() {
continue;
}
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",
_ => "",
};
result.push(VitalSignDto {
metric: metric_name.to_string(),
values,
unit: unit.to_string(),
});
}
Ok(result)
}
async fn get_patient_summary(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<PatientSummaryDto> {
let patient = find_patient(&self.db, tenant_id, patient_id).await?;
let diagnoses: Vec<String> = diagnosis::Entity::find()
.filter(diagnosis::Column::TenantId.eq(tenant_id))
.filter(diagnosis::Column::PatientId.eq(patient_id))
.filter(diagnosis::Column::DeletedAt.is_null())
.filter(diagnosis::Column::Status.eq("active"))
.order_by_desc(diagnosis::Column::DiagnosedDate)
.all(&self.db)
.await?
.iter()
.map(|d| format!("{}({})", d.diagnosis_name, d.icd_code))
.collect();
let medications: Vec<String> = medication_record::Entity::find()
.filter(medication_record::Column::TenantId.eq(tenant_id))
.filter(medication_record::Column::PatientId.eq(patient_id))
.filter(medication_record::Column::DeletedAt.is_null())
.filter(medication_record::Column::IsCurrent.eq(true))
.all(&self.db)
.await?
.iter()
.map(|m| {
let mut s = m.medication_name.clone();
if let Some(ref dosage) = m.dosage {
s.push_str(&format!(" {dosage}"));
}
s
})
.collect();
let family_history = patient
.medical_history_summary
.as_ref()
.map(|h| {
h.split('')
.chain(h.split(';'))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let last_checkup = health_record::Entity::find()
.filter(health_record::Column::TenantId.eq(tenant_id))
.filter(health_record::Column::PatientId.eq(patient_id))
.filter(health_record::Column::DeletedAt.is_null())
.order_by_desc(health_record::Column::RecordDate)
.one(&self.db)
.await?;
let last_checkup_date = last_checkup
.map(|r| r.record_date.to_string())
.unwrap_or_else(|| "".to_string());
Ok(PatientSummaryDto {
age_group: compute_age_group(patient.birth_date),
sex: patient.gender.unwrap_or_else(|| "未知".to_string()),
chronic_conditions: diagnoses,
medications,
family_history,
last_checkup_date,
})
}
async fn get_full_report(
&self,
tenant_id: Uuid,
report_id: Uuid,
) -> AppResult<HealthReportDto> {
let record = health_record::Entity::find_by_id(report_id)
.filter(health_record::Column::TenantId.eq(tenant_id))
.filter(health_record::Column::DeletedAt.is_null())
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("健康报告 {report_id} 不存在")))?;
let patient = find_patient(&self.db, tenant_id, record.patient_id).await?;
let mut sections = Vec::new();
let findings: Vec<String> = record
.overall_assessment
.as_ref()
.map(|a| {
a.split('')
.chain(a.split(';'))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
if !findings.is_empty() {
sections.push(ReportSectionDto {
title: "总体评估".to_string(),
findings,
abnormal_items: vec![],
});
}
let report_diagnoses = diagnosis::Entity::find()
.filter(diagnosis::Column::TenantId.eq(tenant_id))
.filter(diagnosis::Column::PatientId.eq(record.patient_id))
.filter(diagnosis::Column::DeletedAt.is_null())
.filter(
diagnosis::Column::HealthRecordId
.eq(report_id)
.or(diagnosis::Column::Status.eq("active")),
)
.all(&self.db)
.await?;
if !report_diagnoses.is_empty() {
let (abnormal, findings): (Vec<_>, Vec<_>) =
report_diagnoses.iter().partition(|d| d.status == "active");
sections.push(ReportSectionDto {
title: "诊断记录".to_string(),
findings: findings
.iter()
.map(|d| {
format!(
"{}({}) — {}",
d.diagnosis_name, d.icd_code, d.diagnosed_date
)
})
.collect(),
abnormal_items: abnormal
.iter()
.map(|d| format!("{}({})", d.diagnosis_name, d.icd_code))
.collect(),
});
}
let lab_reports = lab_report::Entity::find()
.filter(lab_report::Column::TenantId.eq(tenant_id))
.filter(lab_report::Column::PatientId.eq(record.patient_id))
.filter(lab_report::Column::DeletedAt.is_null())
.filter(
lab_report::Column::ReportDate.gte(record.record_date - chrono::Duration::days(30)),
)
.filter(lab_report::Column::ReportDate.lte(record.record_date))
.order_by_desc(lab_report::Column::ReportDate)
.all(&self.db)
.await?;
for lr in &lab_reports {
let items = parse_lab_items(&lr.items);
let abnormal: Vec<String> = items
.iter()
.filter(|i| i.is_abnormal)
.map(|i| format!("{} {}{}", i.name, i.value, i.unit))
.collect();
let findings: Vec<String> = items
.iter()
.map(|i| format!("{}: {}{} ({})", i.name, i.value, i.unit, i.reference_range))
.collect();
if !findings.is_empty() {
sections.push(ReportSectionDto {
title: format!("化验报告 — {} ({})", lr.report_type, lr.report_date),
findings,
abnormal_items: abnormal,
});
}
}
Ok(HealthReportDto {
age_group: compute_age_group(patient.birth_date),
sex: patient.gender.unwrap_or_else(|| "未知".to_string()),
department: record.record_type.clone(),
report_date: record.record_date.to_string(),
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,
})
}
async fn get_upcoming_appointments(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<Vec<AppointmentSummaryDto>> {
let _ = find_patient(&self.db, tenant_id, patient_id).await?;
let today = chrono::Utc::now().date_naive();
let records = appointment::Entity::find()
.filter(appointment::Column::TenantId.eq(tenant_id))
.filter(appointment::Column::PatientId.eq(patient_id))
.filter(appointment::Column::DeletedAt.is_null())
.filter(appointment::Column::AppointmentDate.gte(today))
.filter(
appointment::Column::Status
.is_in(vec!["scheduled".to_string(), "confirmed".to_string()]),
)
.order_by_asc(appointment::Column::AppointmentDate)
.order_by_asc(appointment::Column::StartTime)
.limit(10)
.all(&self.db)
.await?;
let result = records
.into_iter()
.map(|r| AppointmentSummaryDto {
id: r.id,
department: r.appointment_type,
doctor_name: r
.doctor_id
.map_or("待定".to_string(), |_| "医生".to_string()),
scheduled_at: format!("{} {}", r.appointment_date, r.start_time),
status: r.status,
})
.collect();
Ok(result)
}
async fn get_medication_list(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<Vec<MedicationSummaryDto>> {
let _ = find_patient(&self.db, tenant_id, patient_id).await?;
let records = medication_record::Entity::find()
.filter(medication_record::Column::TenantId.eq(tenant_id))
.filter(medication_record::Column::PatientId.eq(patient_id))
.filter(medication_record::Column::DeletedAt.is_null())
.filter(medication_record::Column::IsCurrent.eq(true))
.all(&self.db)
.await?;
let result = records
.into_iter()
.map(|m| MedicationSummaryDto {
name: m.medication_name,
dosage: m.dosage.unwrap_or_default(),
frequency: m.frequency.unwrap_or_default(),
})
.collect();
Ok(result)
}
}