feat(health): 业务链路打通 — 告警自动随访 + 健康数据积分激励

- 迁移 000157: follow_up_task 新增 source_type/source_id 字段追踪任务来源
- 迁移 000157: points_rule 新增 health_data_report/lab_report_upload/streak_7_days 种子规则
- P0-2: follow_up 事件处理器新增 health_data.critical_alert 消费,告警触发时自动创建随访任务
  - 自动查找管床医生分配,critical 级别 1 天内、warning 级别 3 天内
  - 新增 alert_auto 随访类型,source_type 标记来源为 critical_alert
- P1-1: points 事件处理器新增 daily_monitoring.created 消费,日常监测上报自动获取积分
- P1-1: points 事件处理器新增 lab_report.uploaded 消费,化验报告上传自动获取积分
- 更新 HMS 系统设计思路文档 v2.0(实体数/链路图/业务链路章节全面更新)
- 新增业务链路打通讨论记录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iven
2026-05-20 12:25:28 +08:00
parent e83101dd23
commit 17114d492e
12 changed files with 744 additions and 58 deletions

View File

@@ -21,6 +21,10 @@ pub struct CreateFollowUpTaskReq {
pub planned_date: NaiveDate,
pub content_template: Option<String>,
pub related_appointment_id: Option<Uuid>,
#[serde(default)]
pub source_type: Option<String>,
#[serde(default)]
pub source_id: Option<Uuid>,
}
impl CreateFollowUpTaskReq {
@@ -57,6 +61,10 @@ pub struct FollowUpTaskResp {
pub status: String,
pub content_template: Option<String>,
pub related_appointment_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_id: Option<Uuid>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,

View File

@@ -17,6 +17,10 @@ pub struct Model {
pub content_template: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub related_appointment_id: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub source_type: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub source_id: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]

View File

@@ -1,4 +1,7 @@
/// follow_up.overdue → 升级通知 + follow_up.created → 通知执行人
/// health_data.critical_alert → 自动创建告警随访任务
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::SubscriptionHandle> {
let mut handles = Vec::new();
@@ -139,5 +142,134 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::Subscri
}
});
// health_data.critical_alert → 自动创建告警随访任务
let (mut critical_rx, critical_handle) = state
.event_bus
.subscribe_filtered("health_data.".to_string());
handles.push(critical_handle);
let critical_state = state.clone();
tokio::spawn(async move {
loop {
match critical_rx.recv().await {
Some(event) if event.event_type == super::HEALTH_DATA_CRITICAL_ALERT => {
if erp_core::events::is_event_processed(
&critical_state.db,
event.id,
"critical_alert_followup_creator",
)
.await
.unwrap_or(false)
{
continue;
}
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let Some(pid) = patient_id else {
let _ = erp_core::events::mark_event_processed(
&critical_state.db,
event.id,
"critical_alert_followup_creator",
)
.await;
continue;
};
let tenant_id = event.tenant_id;
let alert_obj = event.payload.get("alert");
let metric_name = alert_obj
.and_then(|a| a.get("indicator"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let metric_value = alert_obj
.and_then(|a| a.get("value"))
.and_then(|v| v.as_f64())
.map(|v| v.to_string())
.unwrap_or_default();
let severity = alert_obj
.and_then(|a| a.get("level"))
.and_then(|v| v.as_str())
.unwrap_or("critical");
// 查找管床医生
let assigned_doctor: Option<uuid::Uuid> =
crate::entity::patient_doctor_relation::Entity::find()
.filter(
crate::entity::patient_doctor_relation::Column::PatientId.eq(pid),
)
.filter(
crate::entity::patient_doctor_relation::Column::TenantId
.eq(tenant_id),
)
.filter(
crate::entity::patient_doctor_relation::Column::DeletedAt.is_null(),
)
.one(&critical_state.db)
.await
.ok()
.flatten()
.map(|r| r.doctor_id);
let planned_date = chrono::Local::now().date_naive()
+ chrono::Duration::days(if severity == "critical" { 1 } else { 3 });
let content = format!(
"系统自动创建:患者健康数据异常告警({}: {},严重程度: {}",
metric_name, metric_value, severity
);
let req = crate::dto::follow_up_dto::CreateFollowUpTaskReq {
patient_id: pid,
assigned_to: assigned_doctor,
follow_up_type: "alert_auto".to_string(),
planned_date,
content_template: Some(content),
related_appointment_id: None,
source_type: Some("critical_alert".to_string()),
source_id: Some(event.id),
};
match crate::service::follow_up_service::create_task(
&critical_state,
tenant_id,
None,
req,
)
.await
{
Ok(task) => {
tracing::info!(
patient_id = %pid,
task_id = %task.id,
metric = %metric_name,
"告警自动随访任务已创建"
);
}
Err(e) => {
tracing::warn!(
patient_id = %pid,
error = %e,
"告警自动随访任务创建失败"
);
}
}
let _ = erp_core::events::mark_event_processed(
&critical_state.db,
event.id,
"critical_alert_followup_creator",
)
.await;
}
Some(_) => {}
None => break,
}
}
});
handles
}

View File

@@ -1,4 +1,6 @@
/// points.earned/exchanged/expired → 积分变动通知
/// daily_monitoring.created → 健康数据上报积分
/// lab_report.uploaded → 化验报告上传积分
pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::SubscriptionHandle> {
let mut handles = Vec::new();
@@ -120,5 +122,146 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::Subscri
}
});
// daily_monitoring.created → 健康数据上报积分
let (mut dm_rx, dm_handle) = state
.event_bus
.subscribe_filtered("daily_monitoring.".to_string());
handles.push(dm_handle);
let dm_state = state.clone();
tokio::spawn(async move {
loop {
match dm_rx.recv().await {
Some(event) if event.event_type == super::DAILY_MONITORING_CREATED => {
if erp_core::events::is_event_processed(
&dm_state.db,
event.id,
"daily_monitoring_points",
)
.await
.unwrap_or(false)
{
continue;
}
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
if let Some(pid) = patient_id {
match crate::service::points_service::earn_points(
&dm_state,
event.tenant_id,
pid,
"health_data_report",
None,
)
.await
{
Ok(tx) => {
tracing::info!(
patient_id = %pid,
points = tx.amount,
"健康数据上报积分已发放"
);
}
Err(e) => {
// 无匹配规则时不告警(租户可能未配置该规则)
let err_str = e.to_string();
if !err_str.contains("无匹配的积分规则") {
tracing::warn!(
patient_id = %pid,
error = %e,
"健康数据上报积分发放失败"
);
}
}
}
}
let _ = erp_core::events::mark_event_processed(
&dm_state.db,
event.id,
"daily_monitoring_points",
)
.await;
}
Some(_) => {}
None => break,
}
}
});
// lab_report.uploaded → 化验报告上传积分
let (mut lr_rx, lr_handle) = state
.event_bus
.subscribe_filtered("lab_report.".to_string());
handles.push(lr_handle);
let lr_state = state.clone();
tokio::spawn(async move {
loop {
match lr_rx.recv().await {
Some(event) if event.event_type == super::LAB_REPORT_UPLOADED => {
if erp_core::events::is_event_processed(
&lr_state.db,
event.id,
"lab_report_points",
)
.await
.unwrap_or(false)
{
continue;
}
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
if let Some(pid) = patient_id {
match crate::service::points_service::earn_points(
&lr_state,
event.tenant_id,
pid,
"lab_report_upload",
None,
)
.await
{
Ok(tx) => {
tracing::info!(
patient_id = %pid,
points = tx.amount,
"化验报告上传积分已发放"
);
}
Err(e) => {
let err_str = e.to_string();
if !err_str.contains("无匹配的积分规则") {
tracing::warn!(
patient_id = %pid,
error = %e,
"化验报告上传积分发放失败"
);
}
}
}
}
let _ = erp_core::events::mark_event_processed(
&lr_state.db,
event.id,
"lab_report_points",
)
.await;
}
Some(_) => {}
None => break,
}
}
});
handles
}

View File

@@ -129,6 +129,8 @@ pub async fn list_tasks(
status: m.status,
content_template: m.content_template,
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -169,6 +171,8 @@ pub async fn get_task(
status: m.status,
content_template: m.content_template,
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -205,6 +209,8 @@ pub async fn create_task(
status: Set("pending".to_string()),
content_template: Set(req.content_template),
related_appointment_id: Set(req.related_appointment_id),
source_type: Set(req.source_type),
source_id: Set(req.source_id),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
@@ -249,6 +255,8 @@ pub async fn create_task(
status: m.status,
content_template: m.content_template,
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -346,6 +354,8 @@ pub async fn update_task(
status: m.status,
content_template: m.content_template,
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -444,6 +454,8 @@ pub async fn batch_create_tasks(
status: Set("pending".to_string()),
content_template: Set(req.content_template.clone()),
related_appointment_id: Set(None),
source_type: Set(None),
source_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
@@ -736,6 +748,8 @@ pub async fn create_record(
status: Set("pending".to_string()),
content_template: Set(None),
related_appointment_id: Set(None),
source_type: Set(None),
source_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),

View File

@@ -104,6 +104,7 @@ pub fn validate_follow_up_type(value: &str) -> HealthResult<()> {
"visit",
"online",
"wechat",
"alert_auto",
]
);
Ok(())

View File

@@ -158,6 +158,7 @@ mod m20260518_000153_ai_health_butler_v2;
mod m20260519_000154_seed_ai_knowledge_permissions;
mod m20260519_000155_fix_ai_menus_and_add_chat;
mod m20260519_000156_fix_ai_menus_round2;
mod m20260520_000157_follow_up_source_and_points_rules;
pub struct Migrator;
@@ -323,6 +324,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260519_000154_seed_ai_knowledge_permissions::Migration),
Box::new(m20260519_000155_fix_ai_menus_and_add_chat::Migration),
Box::new(m20260519_000156_fix_ai_menus_round2::Migration),
Box::new(m20260520_000157_follow_up_source_and_points_rules::Migration),
]
}
}

View File

@@ -0,0 +1,104 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 1. follow_up_task 新增 source_type 和 source_id 字段,追踪任务来源
manager
.alter_table(
Table::alter()
.table(Alias::new("follow_up_task"))
.add_column(ColumnDef::new(Alias::new("source_type")).string().null())
.add_column(ColumnDef::new(Alias::new("source_id")).uuid().null())
.to_owned(),
)
.await?;
// 2. 新增积分规则种子:健康数据上报
let insert_sql = r#"
INSERT INTO points_rule (id, tenant_id, name, event_type, points, daily_cap, is_active, created_at, updated_at, version)
SELECT
gen_random_uuid(),
t.id,
'健康数据上报',
'health_data_report',
5,
10,
true,
NOW(),
NOW(),
1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM points_rule pr
WHERE pr.event_type = 'health_data_report' AND pr.tenant_id = t.id
);
INSERT INTO points_rule (id, tenant_id, name, event_type, points, daily_cap, is_active, created_at, updated_at, version)
SELECT
gen_random_uuid(),
t.id,
'化验报告上传',
'lab_report_upload',
20,
40,
true,
NOW(),
NOW(),
1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM points_rule pr
WHERE pr.event_type = 'lab_report_upload' AND pr.tenant_id = t.id
);
INSERT INTO points_rule (id, tenant_id, name, event_type, points, daily_cap, is_active, created_at, updated_at, version)
SELECT
gen_random_uuid(),
t.id,
'连续上报奖励(7天)',
'streak_7_days',
50,
50,
true,
NOW(),
NOW(),
1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM points_rule pr
WHERE pr.event_type = 'streak_7_days' AND pr.tenant_id = t.id
);
"#;
manager
.get_connection()
.execute_unprepared(insert_sql)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("follow_up_task"))
.drop_column(Alias::new("source_type"))
.drop_column(Alias::new("source_id"))
.to_owned(),
)
.await?;
manager
.get_connection()
.execute_unprepared(
"DELETE FROM points_rule WHERE event_type IN ('health_data_report', 'lab_report_upload', 'streak_7_days');",
)
.await?;
Ok(())
}
}

View File

@@ -15,6 +15,8 @@ fn default_create_task(patient_id: uuid::Uuid) -> CreateFollowUpTaskReq {
planned_date: chrono::NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(),
content_template: Some("请询问血压情况".to_string()),
related_appointment_id: None,
source_type: None,
source_id: None,
}
}

View File

@@ -374,6 +374,8 @@ async fn test_follow_up_record_fields_encrypted() {
planned_date: NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(),
content_template: None,
related_appointment_id: None,
source_type: None,
source_id: None,
},
)
.await