feat(health): 随访模板系统 — follow_up_template + template_field 全栈
新增随访模板和模板字段两张表及完整 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:
346
crates/erp-health/src/service/follow_up_template_service.rs
Normal file
346
crates/erp-health/src/service/follow_up_template_service.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user