feat(health): 随访模板系统 — follow_up_template + template_field 全栈
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

新增随访模板和模板字段两张表及完整 CRUD:
- 迁移 083: follow_up_template + follow_up_template_field
- Entity: 模板(名称/类型/适用范围/状态) + 字段(标签/键名/类型/选项/校验)
- DTO: 创建时内嵌字段列表、更新支持全量替换字段
- Service: 随访类型+字段类型校验、级联软删除
- Handler: 5 端点 + RBAC 权限
- 路由: /api/v1/health/follow-up-templates
This commit is contained in:
iven
2026-04-27 14:40:28 +08:00
parent ca96310a84
commit dc5879228e
13 changed files with 869 additions and 2 deletions

View File

@@ -0,0 +1,117 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use erp_core::sanitize::sanitize_option;
// ── 模板字段 DTO ──
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TemplateFieldReq {
pub label: String,
pub field_key: String,
/// text/number/date/select/checkbox/textarea/scale
pub field_type: String,
#[serde(default)]
pub required: bool,
pub options: Option<String>,
pub placeholder: Option<String>,
pub validation: Option<String>,
#[serde(default)]
pub sort_order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TemplateFieldResp {
pub id: Uuid,
pub template_id: Uuid,
pub label: String,
pub field_key: String,
pub field_type: String,
pub required: bool,
pub options: Option<String>,
pub placeholder: Option<String>,
pub validation: Option<String>,
pub sort_order: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
// ── 模板 DTO ──
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct FollowUpTemplateListQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub follow_up_type: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateFollowUpTemplateReq {
pub name: String,
pub description: Option<String>,
/// phone/outpatient/home_visit/online/wechat
pub follow_up_type: String,
pub applicable_scope: Option<String>,
#[serde(default)]
pub fields: Vec<TemplateFieldReq>,
}
impl CreateFollowUpTemplateReq {
pub fn sanitize(&mut self) {
self.name = self.name.trim().to_string();
self.description = sanitize_option(self.description.take());
self.applicable_scope = sanitize_option(self.applicable_scope.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateFollowUpTemplateReq {
pub name: Option<String>,
pub description: Option<String>,
pub follow_up_type: Option<String>,
pub applicable_scope: Option<String>,
pub status: Option<String>,
/// 全量替换字段列表
pub fields: Option<Vec<TemplateFieldReq>>,
}
impl UpdateFollowUpTemplateReq {
pub fn sanitize(&mut self) {
if let Some(ref mut n) = self.name {
*n = n.trim().to_string();
}
self.description = sanitize_option(self.description.take());
self.applicable_scope = sanitize_option(self.applicable_scope.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FollowUpTemplateResp {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub follow_up_type: String,
pub applicable_scope: Option<String>,
pub status: String,
pub fields: Vec<TemplateFieldResp>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FollowUpTemplateListItemResp {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub follow_up_type: String,
pub status: String,
pub field_count: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub version: i32,
}

View File

@@ -10,6 +10,7 @@ pub mod dialysis_dto;
pub mod dialysis_prescription_dto;
pub mod doctor_dto;
pub mod follow_up_dto;
pub mod follow_up_template_dto;
pub mod health_data_dto;
pub mod patient_dto;
pub mod points_dto;

View File

@@ -0,0 +1,45 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "follow_up_template")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
// phone/outpatient/home_visit/online/wechat
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub follow_up_type: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub applicable_scope: Option<String>,
// active/disabled
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub status: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::follow_up_template_field::Entity")]
Fields,
}
impl Related<super::follow_up_template_field::Entity> for Entity {
fn to() -> RelationDef {
Relation::Fields.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,54 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "follow_up_template_field")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub template_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub label: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub field_key: String,
// text/number/date/select/checkbox/textarea/scale
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub field_type: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub required: bool,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub options: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub validation: Option<String>,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::follow_up_template::Entity",
from = "Column::TemplateId",
to = "super::follow_up_template::Column::Id"
)]
Template,
}
impl Related<super::follow_up_template::Entity> for Entity {
fn to() -> RelationDef {
Relation::Template.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -19,6 +19,8 @@ pub mod doctor_profile;
pub mod doctor_schedule;
pub mod follow_up_record;
pub mod follow_up_task;
pub mod follow_up_template;
pub mod follow_up_template_field;
pub mod health_record;
pub mod health_trend;
pub mod lab_report;

View File

@@ -77,6 +77,9 @@ pub enum HealthError {
#[error("透析方案不存在")]
DialysisPrescriptionNotFound,
#[error("随访模板不存在")]
FollowUpTemplateNotFound,
#[error("状态转换无效: {0}")]
InvalidStatusTransition(String),
@@ -113,7 +116,8 @@ impl From<HealthError> for AppError {
| HealthError::ConsentNotFound
| HealthError::AlertRuleNotFound
| HealthError::AlertNotFound
| HealthError::DialysisPrescriptionNotFound => AppError::NotFound(err.to_string()),
| HealthError::DialysisPrescriptionNotFound
| HealthError::FollowUpTemplateNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
HealthError::VersionMismatch => AppError::VersionMismatch,

View File

@@ -0,0 +1,119 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::follow_up_template_dto::*;
use crate::dto::DeleteWithVersion;
use crate::service::follow_up_template_service;
use crate::state::HealthState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct FollowUpTemplateListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub follow_up_type: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateFollowUpTemplateWithVersion {
#[serde(flatten)]
pub data: UpdateFollowUpTemplateReq,
pub version: i32,
}
pub async fn list_templates<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<FollowUpTemplateListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<FollowUpTemplateListItemResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up-template.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = follow_up_template_service::list_templates(
&state, ctx.tenant_id, page, page_size, params.follow_up_type, params.status,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_template<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<FollowUpTemplateResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up-template.list")?;
let result = follow_up_template_service::get_template(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_template<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateFollowUpTemplateReq>,
) -> Result<Json<ApiResponse<FollowUpTemplateResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up-template.manage")?;
let mut req = req;
req.sanitize();
let result = follow_up_template_service::create_template(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_template<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateFollowUpTemplateWithVersion>,
) -> Result<Json<ApiResponse<FollowUpTemplateResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up-template.manage")?;
let mut data = req.data;
data.sanitize();
let result = follow_up_template_service::update_template(
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn delete_template<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up-template.manage")?;
follow_up_template_service::delete_template(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
)
.await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -15,6 +15,7 @@ pub mod dialysis_handler;
pub mod dialysis_prescription_handler;
pub mod doctor_handler;
pub mod follow_up_handler;
pub mod follow_up_template_handler;
pub mod health_data_handler;
pub mod patient_handler;
pub mod points_handler;

View File

@@ -7,7 +7,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
alert_handler, alert_rule_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, dialysis_handler, dialysis_prescription_handler, doctor_handler, follow_up_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, dialysis_handler, dialysis_prescription_handler, doctor_handler, follow_up_handler, follow_up_template_handler,
health_data_handler, medication_record_handler, patient_handler, points_handler, stats_handler,
};
@@ -234,6 +234,18 @@ impl HealthModule {
.put(dialysis_prescription_handler::update_prescription)
.delete(dialysis_prescription_handler::delete_prescription),
)
// 随访模板
.route(
"/health/follow-up-templates",
axum::routing::get(follow_up_template_handler::list_templates)
.post(follow_up_template_handler::create_template),
)
.route(
"/health/follow-up-templates/{id}",
axum::routing::get(follow_up_template_handler::get_template)
.put(follow_up_template_handler::update_template)
.delete(follow_up_template_handler::delete_template),
)
// 日常监测
.route(
"/health/patients/{id}/daily-monitoring",

View File

@@ -0,0 +1,346 @@
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::follow_up_template_dto::*;
use crate::entity::{follow_up_template, follow_up_template_field};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
const VALID_FOLLOW_UP_TYPES: &[&str] = &["phone", "outpatient", "home_visit", "online", "wechat"];
const VALID_FIELD_TYPES: &[&str] = &["text", "number", "date", "select", "checkbox", "textarea", "scale"];
pub async fn list_templates(
state: &HealthState,
tenant_id: Uuid,
page: u64,
page_size: u64,
follow_up_type: Option<String>,
status: Option<String>,
) -> HealthResult<PaginatedResponse<FollowUpTemplateListItemResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = follow_up_template::Entity::find()
.filter(follow_up_template::Column::TenantId.eq(tenant_id))
.filter(follow_up_template::Column::DeletedAt.is_null());
if let Some(ref t) = follow_up_type {
query = query.filter(follow_up_template::Column::FollowUpType.eq(t));
}
if let Some(ref s) = status {
query = query.filter(follow_up_template::Column::Status.eq(s));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(follow_up_template::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let mut data = Vec::with_capacity(models.len());
for m in models {
let field_count = follow_up_template_field::Entity::find()
.filter(follow_up_template_field::Column::TemplateId.eq(m.id))
.filter(follow_up_template_field::Column::DeletedAt.is_null())
.count(&state.db)
.await?;
data.push(FollowUpTemplateListItemResp {
id: m.id,
name: m.name,
description: m.description,
follow_up_type: m.follow_up_type,
status: m.status,
field_count: field_count as i64,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
});
}
let total_pages = total.div_ceil(limit.max(1));
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn get_template(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<FollowUpTemplateResp> {
let m = follow_up_template::Entity::find()
.filter(follow_up_template::Column::Id.eq(id))
.filter(follow_up_template::Column::TenantId.eq(tenant_id))
.filter(follow_up_template::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::FollowUpTemplateNotFound)?;
let fields = load_fields(&state, id).await?;
Ok(template_to_resp(m, fields))
}
pub async fn create_template(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateFollowUpTemplateReq,
) -> HealthResult<FollowUpTemplateResp> {
validate_follow_up_type(&req.follow_up_type)?;
for f in &req.fields {
validate_field_type(&f.field_type)?;
}
let now = Utc::now();
let template_id = Uuid::now_v7();
let active = follow_up_template::ActiveModel {
id: Set(template_id),
tenant_id: Set(tenant_id),
name: Set(req.name),
description: Set(req.description),
follow_up_type: Set(req.follow_up_type),
applicable_scope: Set(req.applicable_scope),
status: Set("active".to_string()),
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 mut field_resps = Vec::with_capacity(req.fields.len());
for (i, f) in req.fields.into_iter().enumerate() {
let field = insert_field(&state, tenant_id, template_id, operator_id, f, i as i32).await?;
field_resps.push(field);
}
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_template.created", "follow_up_template")
.with_resource_id(m.id),
&state.db,
).await;
Ok(template_to_resp(m, field_resps))
}
pub async fn update_template(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateFollowUpTemplateReq,
expected_version: i32,
) -> HealthResult<FollowUpTemplateResp> {
let model = follow_up_template::Entity::find()
.filter(follow_up_template::Column::Id.eq(id))
.filter(follow_up_template::Column::TenantId.eq(tenant_id))
.filter(follow_up_template::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::FollowUpTemplateNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
if let Some(ref t) = req.follow_up_type { validate_follow_up_type(t)?; }
if let Some(ref fields) = req.fields {
for f in fields { validate_field_type(&f.field_type)?; }
}
let mut active: follow_up_template::ActiveModel = model.into();
if let Some(v) = req.name { active.name = Set(v); }
if let Some(v) = req.description { active.description = Set(Some(v)); }
if let Some(v) = req.follow_up_type { active.follow_up_type = Set(v); }
if let Some(v) = req.applicable_scope { active.applicable_scope = Set(Some(v)); }
if let Some(v) = req.status { active.status = Set(v); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
// 全量替换字段
let field_resps = if let Some(fields) = req.fields {
// 软删除旧字段
let old_fields = follow_up_template_field::Entity::find()
.filter(follow_up_template_field::Column::TemplateId.eq(id))
.filter(follow_up_template_field::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
for old in old_fields {
let mut af: follow_up_template_field::ActiveModel = old.into();
af.deleted_at = Set(Some(Utc::now()));
af.update(&state.db).await?;
}
// 插入新字段
let mut resps = Vec::with_capacity(fields.len());
for (i, f) in fields.into_iter().enumerate() {
let resp = insert_field(&state, tenant_id, id, operator_id, f, i as i32).await?;
resps.push(resp);
}
resps
} else {
load_fields(&state, id).await?
};
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_template.updated", "follow_up_template")
.with_resource_id(m.id),
&state.db,
).await;
Ok(template_to_resp(m, field_resps))
}
pub async fn delete_template(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = follow_up_template::Entity::find()
.filter(follow_up_template::Column::Id.eq(id))
.filter(follow_up_template::Column::TenantId.eq(tenant_id))
.filter(follow_up_template::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::FollowUpTemplateNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: follow_up_template::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
// 同时软删除所有字段
let fields = follow_up_template_field::Entity::find()
.filter(follow_up_template_field::Column::TemplateId.eq(id))
.filter(follow_up_template_field::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
for f in fields {
let mut af: follow_up_template_field::ActiveModel = f.into();
af.deleted_at = Set(Some(Utc::now()));
af.update(&state.db).await?;
}
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_template.deleted", "follow_up_template")
.with_resource_id(id),
&state.db,
).await;
Ok(())
}
// ── 内部辅助 ──
async fn load_fields(
state: &HealthState,
template_id: Uuid,
) -> HealthResult<Vec<TemplateFieldResp>> {
let fields = follow_up_template_field::Entity::find()
.filter(follow_up_template_field::Column::TemplateId.eq(template_id))
.filter(follow_up_template_field::Column::DeletedAt.is_null())
.order_by_asc(follow_up_template_field::Column::SortOrder)
.all(&state.db)
.await?;
Ok(fields.into_iter().map(field_to_resp).collect())
}
async fn insert_field(
state: &HealthState,
tenant_id: Uuid,
template_id: Uuid,
operator_id: Option<Uuid>,
req: TemplateFieldReq,
sort_order: i32,
) -> HealthResult<TemplateFieldResp> {
let now = Utc::now();
let active = follow_up_template_field::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
template_id: Set(template_id),
label: Set(req.label),
field_key: Set(req.field_key),
field_type: Set(req.field_type),
required: Set(req.required),
options: Set(req.options),
placeholder: Set(req.placeholder),
validation: Set(req.validation),
sort_order: Set(sort_order),
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?;
Ok(field_to_resp(m))
}
fn template_to_resp(m: follow_up_template::Model, fields: Vec<TemplateFieldResp>) -> FollowUpTemplateResp {
FollowUpTemplateResp {
id: m.id,
name: m.name,
description: m.description,
follow_up_type: m.follow_up_type,
applicable_scope: m.applicable_scope,
status: m.status,
fields,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
fn field_to_resp(m: follow_up_template_field::Model) -> TemplateFieldResp {
TemplateFieldResp {
id: m.id,
template_id: m.template_id,
label: m.label,
field_key: m.field_key,
field_type: m.field_type,
required: m.required,
options: m.options,
placeholder: m.placeholder,
validation: m.validation,
sort_order: m.sort_order,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
fn validate_follow_up_type(val: &str) -> HealthResult<()> {
if !VALID_FOLLOW_UP_TYPES.contains(&val) {
return Err(HealthError::Validation(format!(
"follow_up_type 必须为: {}", VALID_FOLLOW_UP_TYPES.join(", ")
)));
}
Ok(())
}
fn validate_field_type(val: &str) -> HealthResult<()> {
if !VALID_FIELD_TYPES.contains(&val) {
return Err(HealthError::Validation(format!(
"field_type 必须为: {}", VALID_FIELD_TYPES.join(", ")
)));
}
Ok(())
}

View File

@@ -16,6 +16,7 @@ pub mod dialysis_prescription_service;
pub mod dialysis_service;
pub mod doctor_service;
pub mod follow_up_service;
pub mod follow_up_template_service;
pub mod health_data_service;
pub mod masking;
pub mod patient_service;

View File

@@ -82,6 +82,7 @@ mod m20260427_000079_add_vital_signs_fields;
mod m20260427_000080_create_medication_record;
mod m20260427_000081_create_dialysis_prescription;
mod m20260427_000082_seed_ai_prompts;
mod m20260427_000083_create_follow_up_template;
pub struct Migrator;
@@ -171,6 +172,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260427_000080_create_medication_record::Migration),
Box::new(m20260427_000081_create_dialysis_prescription::Migration),
Box::new(m20260427_000082_seed_ai_prompts::Migration),
Box::new(m20260427_000083_create_follow_up_template::Migration),
]
}
}

View File

@@ -0,0 +1,163 @@
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> {
// 随访模板主表
manager
.create_table(
Table::create()
.table(Alias::new("follow_up_template"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
// 模板名称
.col(ColumnDef::new(Alias::new("name")).string_len(200).not_null())
// 模板描述
.col(ColumnDef::new(Alias::new("description")).text().null())
// 随访类型: phone/outpatient/home_visit/online/wechat
.col(ColumnDef::new(Alias::new("follow_up_type")).string_len(20).not_null())
// 适用疾病/科室JSON 数组)
.col(ColumnDef::new(Alias::new("applicable_scope")).text().null())
// 状态: active/disabled
.col(
ColumnDef::new(Alias::new("status"))
.string_len(20)
.not_null()
.default("active"),
)
// 标准审计字段
.col(
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone().null())
.col(
ColumnDef::new(Alias::new("version"))
.integer()
.not_null()
.default(1),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_follow_up_template_tenant_id")
.table(Alias::new("follow_up_template"))
.col(Alias::new("tenant_id"))
.to_owned(),
)
.await?;
// 模板字段表
manager
.create_table(
Table::create()
.table(Alias::new("follow_up_template_field"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("template_id")).uuid().not_null())
// 字段标签
.col(ColumnDef::new(Alias::new("label")).string_len(200).not_null())
// 字段键名(用于程序引用)
.col(ColumnDef::new(Alias::new("field_key")).string_len(100).not_null())
// 字段类型: text/number/date/select/checkbox/textarea/scale
.col(ColumnDef::new(Alias::new("field_type")).string_len(20).not_null())
// 是否必填
.col(
ColumnDef::new(Alias::new("required"))
.boolean()
.not_null()
.default(false),
)
// 选项JSON 数组select/checkbox 时使用)
.col(ColumnDef::new(Alias::new("options")).text().null())
// 占位提示
.col(ColumnDef::new(Alias::new("placeholder")).string_len(200).null())
// 校验规则JSON
.col(ColumnDef::new(Alias::new("validation")).text().null())
// 排序序号
.col(
ColumnDef::new(Alias::new("sort_order"))
.integer()
.not_null()
.default(0),
)
// 标准审计字段
.col(
ColumnDef::new(Alias::new("created_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(Alias::new("created_by")).uuid().null())
.col(ColumnDef::new(Alias::new("updated_by")).uuid().null())
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone().null())
.col(
ColumnDef::new(Alias::new("version"))
.integer()
.not_null()
.default(1),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_follow_up_template_field_template_id")
.table(Alias::new("follow_up_template_field"))
.col(Alias::new("template_id"))
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_follow_up_template_field_tenant_id")
.table(Alias::new("follow_up_template_field"))
.col(Alias::new("tenant_id"))
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Alias::new("follow_up_template_field")).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Alias::new("follow_up_template")).to_owned())
.await
}
}