1. CRITICAL: check_vital_signs_alert 移至 insert 之后执行, 防止数据未持久化就触发告警 2. CRITICAL: send_system 添加 business_id 幂等检查, 防止 outbox relay 重放导致重复消息通知 3. 修复 consent_service unused_mut 警告
794 lines
29 KiB
Rust
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;
|
|
}
|
|
}
|
|
}
|