Files
hms/crates/erp-health/src/health_provider_impl.rs
iven 6d5a711d2c
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
fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
功能修复:
1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查
2. 仪表盘统计容错:单个查询失败返回零值而非 500
3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致
4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径
5. 积分端点权限码:health.health-data.list → health.points.list
6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage
7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档

Clippy 全 workspace 清零(14→0 errors):
- erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处
- erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处
- erp-ai: 修复 dead_code、unused import 等 11 处
- erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处
- erp-server-migration: 修复 enum_variant_names 5 处
- erp-auth/config/workflow/message: 各 1-3 处

工程改进:
- lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy)
- cargo fmt 统一格式化
2026-05-07 23:43:14 +08:00

561 lines
20 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, HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto,
MetricTrendAnalysis, PatientSummaryDto, RegressionStats, ReportSectionDto, TimeRange,
TrendAnalysisDto, TrendDirection, VitalSignDto,
};
use num_traits::ToPrimitive;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use uuid::Uuid;
use crate::entity::{
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,
})
}
}