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,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;