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:
117
crates/erp-health/src/dto/follow_up_template_dto.rs
Normal file
117
crates/erp-health/src/dto/follow_up_template_dto.rs
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
45
crates/erp-health/src/entity/follow_up_template.rs
Normal file
45
crates/erp-health/src/entity/follow_up_template.rs
Normal 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 {}
|
||||
54
crates/erp-health/src/entity/follow_up_template_field.rs
Normal file
54
crates/erp-health/src/entity/follow_up_template_field.rs
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
119
crates/erp-health/src/handler/follow_up_template_handler.rs
Normal file
119
crates/erp-health/src/handler/follow_up_template_handler.rs
Normal 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(())))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user