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:
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user