diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 1acbbba..c803e86 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -80,6 +80,9 @@ pub enum HealthError { #[error("随访模板不存在")] FollowUpTemplateNotFound, + #[error("危急值告警不存在")] + CriticalAlertNotFound, + #[error("状态转换无效: {0}")] InvalidStatusTransition(String), @@ -117,7 +120,8 @@ impl From for AppError { | HealthError::AlertRuleNotFound | HealthError::AlertNotFound | HealthError::DialysisPrescriptionNotFound - | HealthError::FollowUpTemplateNotFound => AppError::NotFound(err.to_string()), + | HealthError::FollowUpTemplateNotFound + | HealthError::CriticalAlertNotFound => AppError::NotFound(err.to_string()), HealthError::ScheduleFull => AppError::Validation(err.to_string()), HealthError::InvalidStatusTransition(s) => AppError::Validation(s), HealthError::VersionMismatch => AppError::VersionMismatch, diff --git a/crates/erp-health/src/service/critical_alert_service.rs b/crates/erp-health/src/service/critical_alert_service.rs new file mode 100644 index 0000000..b3dfb02 --- /dev/null +++ b/crates/erp-health/src/service/critical_alert_service.rs @@ -0,0 +1,221 @@ +//! 危急值告警 Service — 创建告警、确认、升级 + +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use crate::entity::{critical_alert, critical_alert_response}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +/// 消费 health_data.critical_alert 事件,创建告警记录 +pub async fn handle_critical_alert_event( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + alert_type: &str, + metric_name: &str, + metric_value: &str, + threshold_value: &str, + created_by: Option, +) -> HealthResult { + let id = Uuid::now_v7(); + let now = Utc::now(); + let alert = critical_alert::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + alert_type: Set(alert_type.to_string()), + metric_name: Set(metric_name.to_string()), + metric_value: Set(metric_value.to_string()), + threshold_value: Set(threshold_value.to_string()), + severity: Set("critical".to_string()), + status: Set("pending".to_string()), + acknowledged_by: Set(None), + acknowledged_at: Set(None), + escalation_level: Set(0), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(created_by), + updated_by: Set(None), + deleted_at: Set(None), + version: Set(1), + }; + critical_alert::Entity::insert(alert) + .exec(&state.db) + .await?; + Ok(id) +} + +/// 医生确认告警 +pub async fn acknowledge_alert( + state: &HealthState, + tenant_id: Uuid, + alert_id: Uuid, + responder_id: Uuid, + notes: Option, +) -> HealthResult<()> { + let alert = critical_alert::Entity::find_by_id(alert_id) + .one(&state.db) + .await? + .ok_or(HealthError::CriticalAlertNotFound)?; + + if alert.tenant_id != tenant_id { + return Err(HealthError::CriticalAlertNotFound); + } + if alert.status != "pending" && alert.status != "escalated" { + return Err(HealthError::InvalidStatusTransition(format!( + "告警状态为 {},无法确认", + alert.status + ))); + } + + let now = Utc::now(); + let mut active: critical_alert::ActiveModel = alert.into(); + active.status = Set("acknowledged".to_string()); + active.acknowledged_by = Set(Some(responder_id)); + active.acknowledged_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(Some(responder_id)); + active.version = Set(active.version.unwrap() + 1); + critical_alert::Entity::update(active) + .exec(&state.db) + .await?; + + // 创建响应记录 + let resp_id = Uuid::now_v7(); + let response = critical_alert_response::ActiveModel { + id: Set(resp_id), + tenant_id: Set(tenant_id), + alert_id: Set(alert_id), + responder_id: Set(responder_id), + response_type: Set("acknowledge".to_string()), + notes: Set(notes), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(Some(responder_id)), + updated_by: Set(None), + deleted_at: Set(None), + version: Set(1), + }; + critical_alert_response::Entity::insert(response) + .exec(&state.db) + .await?; + + Ok(()) +} + +/// 升级扫描 — 由定时任务每分钟调用 +pub async fn scan_escalation( + state: &HealthState, + tenant_id: Uuid, +) -> HealthResult> { + let now = Utc::now(); + let mut escalated = Vec::new(); + + // Level 1: pending/escalated 且 created_at < 30min → escalation_level = 1 + let threshold_l1 = now - chrono::Duration::minutes(30); + let alerts_l1 = critical_alert::Entity::find() + .filter(critical_alert::Column::TenantId.eq(tenant_id)) + .filter( + critical_alert::Column::Status + .is_in(vec!["pending".to_string(), "escalated".to_string()]), + ) + .filter(critical_alert::Column::CreatedAt.lt(threshold_l1)) + .filter(critical_alert::Column::EscalationLevel.lt(1)) + .filter(critical_alert::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + for alert in alerts_l1 { + let mut active: critical_alert::ActiveModel = alert.clone().into(); + active.escalation_level = Set(1); + active.status = Set("escalated".to_string()); + active.updated_at = Set(now); + active.version = Set(active.version.unwrap() + 1); + critical_alert::Entity::update(active) + .exec(&state.db) + .await?; + escalated.push(alert.id); + } + + // Level 2: pending/escalated 且 created_at < 60min → escalation_level = 2 + let threshold_l2 = now - chrono::Duration::minutes(60); + let alerts_l2 = critical_alert::Entity::find() + .filter(critical_alert::Column::TenantId.eq(tenant_id)) + .filter( + critical_alert::Column::Status + .is_in(vec!["pending".to_string(), "escalated".to_string()]), + ) + .filter(critical_alert::Column::CreatedAt.lt(threshold_l2)) + .filter(critical_alert::Column::EscalationLevel.lt(2)) + .filter(critical_alert::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + for alert in alerts_l2 { + let mut active: critical_alert::ActiveModel = alert.clone().into(); + active.escalation_level = Set(2); + active.updated_at = Set(now); + active.version = Set(active.version.unwrap() + 1); + critical_alert::Entity::update(active) + .exec(&state.db) + .await?; + if !escalated.contains(&alert.id) { + escalated.push(alert.id); + } + } + + Ok(escalated) +} + +/// 查询待处理告警列表 +pub async fn list_pending_alerts( + state: &HealthState, + tenant_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult<(Vec, u64)> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = critical_alert::Entity::find() + .filter(critical_alert::Column::TenantId.eq(tenant_id)) + .filter(critical_alert::Column::DeletedAt.is_null()) + .filter( + critical_alert::Column::Status + .is_in(vec!["pending".to_string(), "escalated".to_string()]), + ); + + let total = query + .clone() + .count(&state.db) + .await?; + + let items = query + .order_by(critical_alert::Column::CreatedAt, sea_orm::Order::Desc) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + Ok((items, total)) +} + +/// 查询告警详情 +pub async fn get_alert( + state: &HealthState, + tenant_id: Uuid, + alert_id: Uuid, +) -> HealthResult { + let alert = critical_alert::Entity::find_by_id(alert_id) + .one(&state.db) + .await? + .ok_or(HealthError::CriticalAlertNotFound)?; + + if alert.tenant_id != tenant_id { + return Err(HealthError::CriticalAlertNotFound); + } + Ok(alert) +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 8863900..b2d8da6 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -7,6 +7,7 @@ pub mod article_service; pub mod article_tag_service; pub mod consultation_service; pub mod consent_service; +pub mod critical_alert_service; pub mod critical_value_threshold_service; pub mod daily_monitoring_service; pub mod device_reading_service;