Files
hms/crates/erp-health/src/service/health_data_service.rs
iven 5cb4e5e0ec
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(health): 审计修复 — alert 时序 + outbox 幂等性
1. CRITICAL: check_vital_signs_alert 移至 insert 之后执行,
   防止数据未持久化就触发告警
2. CRITICAL: send_system 添加 business_id 幂等检查,
   防止 outbox relay 重放导致重复消息通知
3. 修复 consent_service unused_mut 警告
2026-04-26 03:54:45 +08:00

794 lines
29 KiB
Rust

//! 健康数据 Service — 体征记录、化验报告、体检记录
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::events::DomainEvent;
use num_traits::ToPrimitive;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::health_data_dto::*;
use crate::entity::{doctor_profile, health_record, lab_report, patient, patient_doctor_relation, vital_signs};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_record_type;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 体征记录 (Vital Signs)
// ---------------------------------------------------------------------------
pub async fn list_vital_signs(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<VitalSignsResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = 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());
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(vital_signs::Column::RecordDate)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data: Vec<VitalSignsResp> = models.into_iter().map(|m| VitalSignsResp {
id: m.id,
patient_id: m.patient_id,
record_date: m.record_date,
source: m.source,
systolic_bp_morning: m.systolic_bp_morning,
diastolic_bp_morning: m.diastolic_bp_morning,
systolic_bp_evening: m.systolic_bp_evening,
diastolic_bp_evening: m.diastolic_bp_evening,
heart_rate: m.heart_rate,
weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
water_intake_ml: m.water_intake_ml,
urine_output_ml: m.urine_output_ml,
notes: m.notes,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn create_vital_signs(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
req: CreateVitalSignsReq,
) -> HealthResult<VitalSignsResp> {
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let now = Utc::now();
let alert_req = req.clone();
let active = vital_signs::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
record_date: Set(req.record_date),
systolic_bp_morning: Set(req.systolic_bp_morning),
diastolic_bp_morning: Set(req.diastolic_bp_morning),
systolic_bp_evening: Set(req.systolic_bp_evening),
diastolic_bp_evening: Set(req.diastolic_bp_evening),
heart_rate: Set(req.heart_rate),
weight: Set(req.weight.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())),
blood_sugar: Set(req.blood_sugar.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())),
water_intake_ml: Set(req.water_intake_ml),
urine_output_ml: Set(req.urine_output_ml),
notes: Set(req.notes),
source: Set(req.source.unwrap_or_else(|| "manual".to_string())),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
// 数据持久化成功后再触发危急值检测
check_vital_signs_alert(state, tenant_id, patient_id, operator_id, alert_req).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "vital_signs.created", "vital_signs")
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(VitalSignsResp {
id: m.id, patient_id: m.patient_id, record_date: m.record_date,
source: m.source,
systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning,
systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening,
heart_rate: m.heart_rate,
weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml,
notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn update_vital_signs(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
vital_signs_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateVitalSignsReq,
expected_version: i32,
) -> HealthResult<VitalSignsResp> {
let model = vital_signs::Entity::find()
.filter(vital_signs::Column::Id.eq(vital_signs_id))
.filter(vital_signs::Column::PatientId.eq(patient_id))
.filter(vital_signs::Column::TenantId.eq(tenant_id))
.filter(vital_signs::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::VitalSignsNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: vital_signs::ActiveModel = model.into();
if let Some(v) = req.record_date { active.record_date = Set(v); }
if let Some(v) = req.systolic_bp_morning { active.systolic_bp_morning = Set(Some(v)); }
if let Some(v) = req.diastolic_bp_morning { active.diastolic_bp_morning = Set(Some(v)); }
if let Some(v) = req.systolic_bp_evening { active.systolic_bp_evening = Set(Some(v)); }
if let Some(v) = req.diastolic_bp_evening { active.diastolic_bp_evening = Set(Some(v)); }
if let Some(v) = req.heart_rate { active.heart_rate = Set(Some(v)); }
if let Some(v) = req.weight { active.weight = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.blood_sugar { active.blood_sugar = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.water_intake_ml { active.water_intake_ml = Set(Some(v)); }
if let Some(v) = req.urine_output_ml { active.urine_output_ml = Set(Some(v)); }
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
// 更新后也触发危急值检测(修改后的值可能触发告警)
let check_req = CreateVitalSignsReq {
record_date: m.record_date,
systolic_bp_morning: m.systolic_bp_morning,
diastolic_bp_morning: m.diastolic_bp_morning,
systolic_bp_evening: m.systolic_bp_evening,
diastolic_bp_evening: m.diastolic_bp_evening,
heart_rate: m.heart_rate,
weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
water_intake_ml: m.water_intake_ml,
urine_output_ml: m.urine_output_ml,
notes: m.notes.clone(),
source: Some(m.source.clone()),
};
check_vital_signs_alert(state, tenant_id, patient_id, operator_id, check_req).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "vital_signs.updated", "vital_signs")
.with_resource_id(m.id),
&state.db,
).await;
Ok(VitalSignsResp {
id: m.id, patient_id: m.patient_id, record_date: m.record_date,
source: m.source,
systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning,
systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening,
heart_rate: m.heart_rate,
weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)),
water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml,
notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_vital_signs(
state: &HealthState,
tenant_id: Uuid,
vital_signs_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = vital_signs::Entity::find()
.filter(vital_signs::Column::Id.eq(vital_signs_id))
.filter(vital_signs::Column::TenantId.eq(tenant_id))
.filter(vital_signs::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::VitalSignsNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: vital_signs::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "vital_signs.deleted", "vital_signs")
.with_resource_id(vital_signs_id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 化验报告 (Lab Reports)
// ---------------------------------------------------------------------------
pub async fn list_lab_reports(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<LabReportResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = lab_report::Entity::find()
.filter(lab_report::Column::TenantId.eq(tenant_id))
.filter(lab_report::Column::PatientId.eq(patient_id))
.filter(lab_report::Column::DeletedAt.is_null());
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(lab_report::Column::ReportDate)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(|m| LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn create_lab_report(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
req: CreateLabReportReq,
) -> HealthResult<LabReportResp> {
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let now = Utc::now();
let active = lab_report::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
report_date: Set(req.report_date),
report_type: Set(req.report_type),
source: Set(req.source),
items: Set(req.items),
image_urls: Set(req.image_urls),
doctor_notes: Set(req.doctor_notes),
status: Set("pending".to_string()),
reviewed_by: Set(None),
reviewed_at: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
let event = DomainEvent::new(
"lab_report.uploaded",
tenant_id,
serde_json::json!({ "report_id": m.id, "patient_id": m.patient_id, "report_type": m.report_type }),
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "lab_report.created", "lab_report")
.with_resource_id(m.id),
&state.db,
).await;
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn update_lab_report(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
report_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateLabReportReq,
expected_version: i32,
) -> HealthResult<LabReportResp> {
let model = lab_report::Entity::find()
.filter(lab_report::Column::Id.eq(report_id))
.filter(lab_report::Column::PatientId.eq(patient_id))
.filter(lab_report::Column::TenantId.eq(tenant_id))
.filter(lab_report::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::LabReportNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: lab_report::ActiveModel = model.into();
if let Some(v) = req.report_date { active.report_date = Set(v); }
if let Some(v) = req.report_type { active.report_type = Set(v); }
if let Some(v) = req.source { active.source = Set(Some(v)); }
if let Some(v) = req.items { active.items = Set(Some(v)); }
if let Some(v) = req.image_urls { active.image_urls = Set(Some(v)); }
if let Some(v) = req.doctor_notes { active.doctor_notes = Set(Some(v)); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "lab_report.updated", "lab_report")
.with_resource_id(m.id),
&state.db,
).await;
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_lab_report(
state: &HealthState,
tenant_id: Uuid,
report_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = lab_report::Entity::find()
.filter(lab_report::Column::Id.eq(report_id))
.filter(lab_report::Column::TenantId.eq(tenant_id))
.filter(lab_report::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::LabReportNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: lab_report::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "lab_report.deleted", "lab_report")
.with_resource_id(report_id),
&state.db,
).await;
Ok(())
}
pub async fn review_lab_report(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
report_id: Uuid,
reviewer_id: Uuid,
req: crate::dto::dialysis_dto::ReviewLabReportReq,
expected_version: i32,
) -> HealthResult<LabReportResp> {
let model = lab_report::Entity::find()
.filter(lab_report::Column::Id.eq(report_id))
.filter(lab_report::Column::PatientId.eq(patient_id))
.filter(lab_report::Column::TenantId.eq(tenant_id))
.filter(lab_report::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::LabReportNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: lab_report::ActiveModel = model.into();
active.status = Set("reviewed".to_string());
active.reviewed_by = Set(Some(reviewer_id));
active.reviewed_at = Set(Some(Utc::now()));
if let Some(v) = req.doctor_notes { active.doctor_notes = Set(Some(v)); }
if let Some(v) = req.items { active.items = Set(Some(v)); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(reviewer_id));
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(reviewer_id), "lab_report.reviewed", "lab_report")
.with_resource_id(m.id),
&state.db,
).await;
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, source: m.source,
items: m.items, image_urls: m.image_urls, doctor_notes: m.doctor_notes,
status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
// ---------------------------------------------------------------------------
// 体检记录 (Health Records)
// ---------------------------------------------------------------------------
pub async fn list_health_records(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<HealthRecordResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = 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());
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(health_record::Column::RecordDate)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(|m| HealthRecordResp {
id: m.id, patient_id: m.patient_id, record_type: m.record_type,
record_date: m.record_date, source: m.source,
overall_assessment: m.overall_assessment, report_file_url: m.report_file_url,
notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
}).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn create_health_record(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
req: CreateHealthRecordReq,
) -> HealthResult<HealthRecordResp> {
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let now = Utc::now();
let record_type = req.record_type.unwrap_or_else(|| "checkup".to_string());
validate_record_type(&record_type)?;
let active = health_record::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
record_type: Set(record_type),
record_date: Set(req.record_date),
source: Set(req.source),
overall_assessment: Set(req.overall_assessment),
report_file_url: Set(req.report_file_url),
notes: Set(req.notes),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "health_record.created", "health_record")
.with_resource_id(m.id),
&state.db,
).await;
Ok(HealthRecordResp {
id: m.id, patient_id: m.patient_id, record_type: m.record_type,
record_date: m.record_date, source: m.source,
overall_assessment: m.overall_assessment, report_file_url: m.report_file_url,
notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn update_health_record(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
record_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateHealthRecordReq,
expected_version: i32,
) -> HealthResult<HealthRecordResp> {
let model = health_record::Entity::find()
.filter(health_record::Column::Id.eq(record_id))
.filter(health_record::Column::PatientId.eq(patient_id))
.filter(health_record::Column::TenantId.eq(tenant_id))
.filter(health_record::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::HealthRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: health_record::ActiveModel = model.into();
if let Some(ref v) = req.record_type { validate_record_type(v)?; active.record_type = Set(v.clone()); }
if let Some(v) = req.record_date { active.record_date = Set(v); }
if let Some(v) = req.source { active.source = Set(Some(v)); }
if let Some(v) = req.overall_assessment { active.overall_assessment = Set(Some(v)); }
if let Some(v) = req.report_file_url { active.report_file_url = Set(Some(v)); }
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "health_record.updated", "health_record")
.with_resource_id(m.id),
&state.db,
).await;
Ok(HealthRecordResp {
id: m.id, patient_id: m.patient_id, record_type: m.record_type,
record_date: m.record_date, source: m.source,
overall_assessment: m.overall_assessment, report_file_url: m.report_file_url,
notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_health_record(
state: &HealthState,
tenant_id: Uuid,
record_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = health_record::Entity::find()
.filter(health_record::Column::Id.eq(record_id))
.filter(health_record::Column::TenantId.eq(tenant_id))
.filter(health_record::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::HealthRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: health_record::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "health_record.deleted", "health_record")
.with_resource_id(record_id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 危急值预警检测
// ---------------------------------------------------------------------------
/// 检查体征数据中的危急值,发布 `health_data.critical_alert` 事件。
///
/// 阈值从 `critical_value_threshold` 表加载,支持按科室/年龄差异化配置。
/// 事件 payload 包含:患者信息、责任医生、操作人信息、告警详情。
async fn check_vital_signs_alert(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
operator_id: Option<Uuid>,
req: CreateVitalSignsReq,
) {
// 从数据库加载阈值配置
let thresholds = match crate::service::critical_value_threshold_service::find_thresholds(
&state.db, tenant_id,
)
.await
{
Ok(t) if !t.is_empty() => t,
Ok(_) => {
tracing::warn!(tenant_id = %tenant_id, "无危急值阈值配置,跳过告警检测");
return;
}
Err(e) => {
tracing::warn!(error = %e, "加载危急值阈值失败,跳过告警检测");
return;
}
};
let mut alerts: Vec<serde_json::Value> = Vec::new();
// 收缩压危急值
if let Some(sbp) = req.systolic_bp_morning.or(req.systolic_bp_evening) {
check_indicator(&thresholds, "systolic_bp", sbp as f64, &mut alerts);
}
// 舒张压危急值
if let Some(dbp) = req.diastolic_bp_morning.or(req.diastolic_bp_evening) {
check_indicator(&thresholds, "diastolic_bp", dbp as f64, &mut alerts);
}
// 心率危急值
if let Some(hr) = req.heart_rate {
check_indicator(&thresholds, "heart_rate", hr as f64, &mut alerts);
}
// 血糖危急值
if let Some(bs) = req.blood_sugar {
check_indicator(&thresholds, "blood_sugar", bs.to_f64().unwrap_or(0.0), &mut alerts);
}
if alerts.is_empty() {
return;
}
// 查询患者信息
let patient_model = patient::Entity::find_by_id(patient_id)
.one(&state.db)
.await
.ok()
.flatten();
let patient_name = patient_model
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or("未知患者");
// 查询责任医生(通过 patient_doctor_relation 的 attending 类型)
let attending_relation = patient_doctor_relation::Entity::find()
.filter(patient_doctor_relation::Column::PatientId.eq(patient_id))
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
.filter(patient_doctor_relation::Column::DeletedAt.is_null())
.filter(patient_doctor_relation::Column::RelationshipType.eq("attending"))
.one(&state.db)
.await
.ok()
.flatten();
let doctor_user_id: Option<Uuid> = if let Some(rel) = attending_relation {
doctor_profile::Entity::find_by_id(rel.doctor_id)
.one(&state.db)
.await
.ok()
.flatten()
.and_then(|d| d.user_id)
} else {
None
};
for alert in &alerts {
let mut payload = serde_json::json!({
"patient_id": patient_id,
"patient_name": patient_name,
"operator_id": operator_id,
"alert": alert,
});
if let Some(did) = doctor_user_id {
payload["doctor_user_id"] = serde_json::json!(did);
}
let event = DomainEvent::new(
"health_data.critical_alert",
tenant_id,
payload,
);
state.event_bus.publish(event, &state.db).await;
tracing::warn!(
patient_id = %patient_id,
tenant_id = %tenant_id,
indicator = %alert["indicator"],
value = %alert["value"],
"体征危急值预警已发布"
);
}
}
/// 根据阈值配置检查单个指标值,匹配则添加到 alerts。
fn check_indicator(
thresholds: &[crate::entity::critical_value_threshold::Model],
indicator: &str,
value: f64,
alerts: &mut Vec<serde_json::Value>,
) {
for t in thresholds {
if t.indicator != indicator {
continue;
}
let triggered = match t.direction.as_str() {
"high" => value >= t.threshold_value,
"low" => value <= t.threshold_value,
_ => false,
};
if triggered {
alerts.push(serde_json::json!({
"indicator": indicator,
"value": value,
"threshold": t.threshold_value,
"level": t.level,
"direction": t.direction,
}));
return;
}
}
}