From ec7f76127d09c5ad82b66e5710a67d73f290688d Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 21 May 2026 00:50:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E7=A7=AF=E5=88=86=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=89=A9=E5=B1=95=20+=20=E9=9A=8F=E8=AE=BF=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=85=B3=E8=81=94=20=E2=80=94=20Phase=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 + 积分规则种子 --- crates/erp-health/src/dto/follow_up_dto.rs | 23 ++++ .../erp-health/src/entity/follow_up_record.rs | 2 + .../erp-health/src/entity/follow_up_task.rs | 2 + crates/erp-health/src/event/follow_up.rs | 1 + crates/erp-health/src/event/points.rs | 69 ++++++++++ .../src/handler/follow_up_handler.rs | 23 ++++ crates/erp-health/src/routes/follow_up.rs | 4 + .../src/service/consultation_service.rs | 1 + .../src/service/follow_up_service.rs | 125 ++++++++++++++++++ crates/erp-server/migration/src/lib.rs | 6 + ...p_task_template_id_and_record_form_data.rs | 85 ++++++++++++ 11 files changed, 341 insertions(+) create mode 100644 crates/erp-server/migration/src/m20260521_000160_follow_up_task_template_id_and_record_form_data.rs diff --git a/crates/erp-health/src/dto/follow_up_dto.rs b/crates/erp-health/src/dto/follow_up_dto.rs index ef35dc8..157cc1e 100644 --- a/crates/erp-health/src/dto/follow_up_dto.rs +++ b/crates/erp-health/src/dto/follow_up_dto.rs @@ -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, #[serde(default)] pub source_id: Option, + #[serde(default)] + pub template_id: Option, } impl CreateFollowUpTaskReq { @@ -65,6 +68,8 @@ pub struct FollowUpTaskResp { pub source_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_id: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub version: i32, @@ -87,6 +92,8 @@ pub struct CreateFollowUpRecordReq { pub patient_condition: Option, pub medical_advice: Option, pub next_follow_up_date: Option, + #[serde(default)] + pub form_data: Option, } impl CreateFollowUpRecordReq { @@ -144,7 +151,23 @@ pub struct FollowUpRecordResp { pub patient_condition: Option, pub medical_advice: Option, pub next_follow_up_date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub form_data: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, 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, + pub follow_up_type: Option, + pub notes: Option, +} diff --git a/crates/erp-health/src/entity/follow_up_record.rs b/crates/erp-health/src/entity/follow_up_record.rs index ad5cd80..6292ece 100644 --- a/crates/erp-health/src/entity/follow_up_record.rs +++ b/crates/erp-health/src/entity/follow_up_record.rs @@ -29,6 +29,8 @@ pub struct Model { pub version: i32, #[sea_orm(skip_serializing_if = "Option::is_none")] pub key_version: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub form_data: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/erp-health/src/entity/follow_up_task.rs b/crates/erp-health/src/entity/follow_up_task.rs index 5698726..c427074 100644 --- a/crates/erp-health/src/entity/follow_up_task.rs +++ b/crates/erp-health/src/entity/follow_up_task.rs @@ -21,6 +21,8 @@ pub struct Model { pub source_type: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub source_id: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub template_id: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] diff --git a/crates/erp-health/src/event/follow_up.rs b/crates/erp-health/src/event/follow_up.rs index be316d5..ce2a835 100644 --- a/crates/erp-health/src/event/follow_up.rs +++ b/crates/erp-health/src/event/follow_up.rs @@ -231,6 +231,7 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec Vec { let mut handles = Vec::new(); @@ -263,5 +264,73 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec { + 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 } diff --git a/crates/erp-health/src/handler/follow_up_handler.rs b/crates/erp-health/src/handler/follow_up_handler.rs index 76d7a60..f1c24f4 100644 --- a/crates/erp-health/src/handler/follow_up_handler.rs +++ b/crates/erp-health/src/handler/follow_up_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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))) +} diff --git a/crates/erp-health/src/routes/follow_up.rs b/crates/erp-health/src/routes/follow_up.rs index dabb149..930a836 100644 --- a/crates/erp-health/src/routes/follow_up.rs +++ b/crates/erp-health/src/routes/follow_up.rs @@ -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), diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index 88d11a6..74b25ea 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -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( diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index a204f4b..3bfc85b 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -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, + req: CreateTaskFromTemplateReq, +) -> HealthResult { + 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) diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 88b8152..24acc2e 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -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), ] } } diff --git a/crates/erp-server/migration/src/m20260521_000160_follow_up_task_template_id_and_record_form_data.rs b/crates/erp-server/migration/src/m20260521_000160_follow_up_task_template_id_and_record_form_data.rs new file mode 100644 index 0000000..73e5bc0 --- /dev/null +++ b/crates/erp-server/migration/src/m20260521_000160_follow_up_task_template_id_and_record_form_data.rs @@ -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(()) + } +}