feat(health): 积分触发扩展 + 随访模板关联 — Phase 3

- 新增 follow_up.completed 事件积分消费者,随访完成触发 30 积分
- follow_up_task 新增 template_id FK 关联随访模板
- follow_up_record 新增 form_data JSONB 存储结构化表单数据
- 新增 POST /health/follow-up-tasks/from-template 基于模板创建随访任务端点
- 数据库迁移 160:follow_up_task.template_id + follow_up_record.form_data + 积分规则种子
This commit is contained in:
iven
2026-05-21 00:50:29 +08:00
parent 5877342a4d
commit ec7f76127d
11 changed files with 341 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use validator::Validate;
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct FollowUpTaskListQuery {
@@ -25,6 +26,8 @@ pub struct CreateFollowUpTaskReq {
pub source_type: Option<String>,
#[serde(default)]
pub source_id: Option<Uuid>,
#[serde(default)]
pub template_id: Option<Uuid>,
}
impl CreateFollowUpTaskReq {
@@ -65,6 +68,8 @@ pub struct FollowUpTaskResp {
pub source_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_id: Option<Uuid>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
@@ -87,6 +92,8 @@ pub struct CreateFollowUpRecordReq {
pub patient_condition: Option<String>,
pub medical_advice: Option<String>,
pub next_follow_up_date: Option<NaiveDate>,
#[serde(default)]
pub form_data: Option<serde_json::Value>,
}
impl CreateFollowUpRecordReq {
@@ -144,7 +151,23 @@ pub struct FollowUpRecordResp {
pub patient_condition: Option<String>,
pub medical_advice: Option<String>,
pub next_follow_up_date: Option<NaiveDate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub form_data: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
// ---------------------------------------------------------------------------
// 基于模板创建随访任务
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct CreateTaskFromTemplateReq {
pub patient_id: Uuid,
pub template_id: Uuid,
pub planned_date: NaiveDate,
pub assigned_to: Option<Uuid>,
pub follow_up_type: Option<String>,
pub notes: Option<String>,
}

View File

@@ -29,6 +29,8 @@ pub struct Model {
pub version: i32,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub key_version: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub form_data: Option<serde_json::Value>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

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

View File

@@ -231,6 +231,7 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::Subscri
related_appointment_id: None,
source_type: Some("critical_alert".to_string()),
source_id: Some(event.id),
template_id: None,
};
match crate::service::follow_up_service::create_task(

View File

@@ -1,6 +1,7 @@
/// points.earned/exchanged/expired → 积分变动通知
/// daily_monitoring.created → 健康数据上报积分
/// lab_report.uploaded → 化验报告上传积分
/// follow_up.completed → 随访完成积分
pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::SubscriptionHandle> {
let mut handles = Vec::new();
@@ -263,5 +264,73 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::Subscri
}
});
// follow_up.completed → 随访完成积分
let (mut fu_rx, fu_handle) = state.event_bus.subscribe_filtered("follow_up.".to_string());
handles.push(fu_handle);
let fu_state = state.clone();
tokio::spawn(async move {
loop {
match fu_rx.recv().await {
Some(event) if event.event_type == super::FOLLOW_UP_COMPLETED => {
if erp_core::events::is_event_processed(
&fu_state.db,
event.id,
"follow_up_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(
&fu_state,
event.tenant_id,
pid,
"follow_up_completion",
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(
&fu_state.db,
event.id,
"follow_up_points",
)
.await;
}
Some(_) => {}
None => break,
}
}
});
handles
}

View File

@@ -3,6 +3,7 @@ use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
@@ -250,3 +251,25 @@ where
.await?;
Ok(Json(ApiResponse::ok(result)))
}
// ---------------------------------------------------------------------------
// 基于模板创建随访任务
// ---------------------------------------------------------------------------
pub async fn create_task_from_template<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateTaskFromTemplateReq>,
) -> Result<Json<ApiResponse<FollowUpTaskResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
req.validate()
.map_err(|e: validator::ValidationErrors| AppError::Validation(e.to_string()))?;
let result =
follow_up_service::create_task_from_template(&state, ctx.tenant_id, Some(ctx.user_id), req)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -40,6 +40,10 @@ where
axum::routing::get(follow_up_handler::list_records),
)
// 随访批量操作
.route(
"/health/follow-up-tasks/from-template",
axum::routing::post(follow_up_handler::create_task_from_template),
)
.route(
"/health/follow-up-tasks/batch-create",
axum::routing::post(follow_up_handler::batch_create_tasks),

View File

@@ -900,6 +900,7 @@ pub async fn create_follow_up_from_session(
related_appointment_id: None,
source_type: Some("consultation".to_string()),
source_id: Some(session_id),
template_id: None,
};
let task = crate::service::follow_up_service::create_task(

View File

@@ -131,6 +131,7 @@ pub async fn list_tasks(
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
template_id: m.template_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -173,6 +174,7 @@ pub async fn get_task(
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
template_id: m.template_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -211,6 +213,7 @@ pub async fn create_task(
related_appointment_id: Set(req.related_appointment_id),
source_type: Set(req.source_type),
source_id: Set(req.source_id),
template_id: Set(req.template_id),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
@@ -257,6 +260,7 @@ pub async fn create_task(
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
template_id: m.template_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -356,6 +360,7 @@ pub async fn update_task(
related_appointment_id: m.related_appointment_id,
source_type: m.source_type,
source_id: m.source_id,
template_id: m.template_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -456,6 +461,7 @@ pub async fn batch_create_tasks(
related_appointment_id: Set(None),
source_type: Set(None),
source_id: Set(None),
template_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
@@ -722,6 +728,7 @@ pub async fn create_record(
deleted_at: Set(None),
version: Set(1),
key_version: Set(Some(1)),
form_data: Set(req.form_data),
};
let record = record_active.insert(&txn).await?;
@@ -750,6 +757,7 @@ pub async fn create_record(
related_appointment_id: Set(None),
source_type: Set(None),
source_id: Set(None),
template_id: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
@@ -799,6 +807,7 @@ pub async fn create_record(
.as_ref()
.map(|v| pii::decrypt(kek, v).unwrap_or(v.clone())),
next_follow_up_date: record.next_follow_up_date,
form_data: record.form_data,
created_at: record.created_at,
updated_at: record.updated_at,
version: record.version,
@@ -873,6 +882,7 @@ pub async fn list_records(
patient_condition,
medical_advice,
next_follow_up_date: m.next_follow_up_date,
form_data: m.form_data,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -889,6 +899,121 @@ pub async fn list_records(
})
}
// ---------------------------------------------------------------------------
// 基于模板创建随访任务
// ---------------------------------------------------------------------------
/// 从随访模板创建随访任务。加载模板获取 follow_up_type用模板名称作为 content_template。
pub async fn create_task_from_template(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateTaskFromTemplateReq,
) -> HealthResult<FollowUpTaskResp> {
tracing::info!(
action = "create_task_from_template",
patient_id = %req.patient_id,
template_id = %req.template_id,
"Creating follow-up task from template"
);
// 加载模板
let template = crate::entity::follow_up_template::Entity::find()
.filter(crate::entity::follow_up_template::Column::Id.eq(req.template_id))
.filter(crate::entity::follow_up_template::Column::TenantId.eq(tenant_id))
.filter(crate::entity::follow_up_template::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::Validation("随访模板不存在".to_string()))?;
if template.status != "active" {
return Err(HealthError::Validation(
"随访模板未启用,无法创建任务".to_string(),
));
}
// 确定随访类型:请求中显式指定 > 模板定义
let follow_up_type = req
.follow_up_type
.unwrap_or(template.follow_up_type.clone());
validate_follow_up_type(&follow_up_type)?;
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(req.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 = follow_up_task::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
assigned_to: Set(req.assigned_to),
follow_up_type: Set(follow_up_type),
planned_date: Set(req.planned_date),
status: Set("pending".to_string()),
content_template: Set(Some(template.name.clone())),
related_appointment_id: Set(None),
source_type: Set(Some("template".to_string())),
source_id: Set(Some(req.template_id)),
template_id: Set(Some(req.template_id)),
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(
crate::event::FOLLOW_UP_CREATED,
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"task_id": m.id.to_string(),
"patient_id": m.patient_id.to_string(),
"template_id": req.template_id.to_string(),
"planned_date": m.planned_date.to_string(),
})),
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"follow_up_task.created_from_template",
"follow_up_task",
)
.with_resource_id(m.id),
&state.db,
)
.await;
Ok(FollowUpTaskResp {
id: m.id,
patient_id: m.patient_id,
assigned_to: m.assigned_to,
patient_name: None,
assigned_to_name: None,
follow_up_type: m.follow_up_type,
planned_date: m.planned_date,
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,
template_id: m.template_id,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
})
}
/// 随访任务状态机(委托给 validation 模块公共函数)
fn validate_follow_up_status_transition(current: &str, new_status: &str) -> HealthResult<()> {
crate::service::validation::validate_follow_up_status_transition(current, new_status)

View File

@@ -159,6 +159,9 @@ 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;
mod m20260521_000158_alerts_add_source_columns;
mod m20260521_000159_patient_phone_and_consent_seed;
mod m20260521_000160_follow_up_task_template_id_and_record_form_data;
pub struct Migrator;
@@ -325,6 +328,9 @@ impl MigratorTrait for Migrator {
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),
Box::new(m20260521_000158_alerts_add_source_columns::Migration),
Box::new(m20260521_000159_patient_phone_and_consent_seed::Migration),
Box::new(m20260521_000160_follow_up_task_template_id_and_record_form_data::Migration),
]
}
}

View File

@@ -0,0 +1,85 @@
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 新增 template_id 字段,关联随访模板
manager
.alter_table(
Table::alter()
.table(Alias::new("follow_up_task"))
.add_column(ColumnDef::new(Alias::new("template_id")).uuid().null())
.to_owned(),
)
.await?;
// 2. follow_up_record 新增 form_data JSONB 字段,存储模板结构化表单数据
manager
.alter_table(
Table::alter()
.table(Alias::new("follow_up_record"))
.add_column(ColumnDef::new(Alias::new("form_data")).json_binary().null())
.to_owned(),
)
.await?;
// 3. 新增积分规则种子:随访完成
let insert_sql = r#"
INSERT INTO points_rule (id, tenant_id, name, event_type, points_value, daily_cap, is_active, created_at, updated_at, version)
SELECT
gen_random_uuid(),
t.id,
'随访完成',
'follow_up_completion',
30,
60,
true,
NOW(),
NOW(),
1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM points_rule pr
WHERE pr.event_type = 'follow_up_completion' 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("template_id"))
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(Alias::new("follow_up_record"))
.drop_column(Alias::new("form_data"))
.to_owned(),
)
.await?;
manager
.get_connection()
.execute_unprepared(
"DELETE FROM points_rule WHERE event_type = 'follow_up_completion';",
)
.await?;
Ok(())
}
}