From dc5879228e18d4e2fa4cc5eb90eff2ca5510e2cc Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 27 Apr 2026 14:40:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E9=9A=8F=E8=AE=BF=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=B3=BB=E7=BB=9F=20=E2=80=94=20follow=5Fup=5Ftemplat?= =?UTF-8?q?e=20+=20template=5Ffield=20=E5=85=A8=E6=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增随访模板和模板字段两张表及完整 CRUD: - 迁移 083: follow_up_template + follow_up_template_field - Entity: 模板(名称/类型/适用范围/状态) + 字段(标签/键名/类型/选项/校验) - DTO: 创建时内嵌字段列表、更新支持全量替换字段 - Service: 随访类型+字段类型校验、级联软删除 - Handler: 5 端点 + RBAC 权限 - 路由: /api/v1/health/follow-up-templates --- .../src/dto/follow_up_template_dto.rs | 117 ++++++ crates/erp-health/src/dto/mod.rs | 1 + .../src/entity/follow_up_template.rs | 45 +++ .../src/entity/follow_up_template_field.rs | 54 +++ crates/erp-health/src/entity/mod.rs | 2 + crates/erp-health/src/error.rs | 6 +- .../src/handler/follow_up_template_handler.rs | 119 ++++++ crates/erp-health/src/handler/mod.rs | 1 + crates/erp-health/src/module.rs | 14 +- .../src/service/follow_up_template_service.rs | 346 ++++++++++++++++++ crates/erp-health/src/service/mod.rs | 1 + crates/erp-server/migration/src/lib.rs | 2 + ...260427_000083_create_follow_up_template.rs | 163 +++++++++ 13 files changed, 869 insertions(+), 2 deletions(-) create mode 100644 crates/erp-health/src/dto/follow_up_template_dto.rs create mode 100644 crates/erp-health/src/entity/follow_up_template.rs create mode 100644 crates/erp-health/src/entity/follow_up_template_field.rs create mode 100644 crates/erp-health/src/handler/follow_up_template_handler.rs create mode 100644 crates/erp-health/src/service/follow_up_template_service.rs create mode 100644 crates/erp-server/migration/src/m20260427_000083_create_follow_up_template.rs diff --git a/crates/erp-health/src/dto/follow_up_template_dto.rs b/crates/erp-health/src/dto/follow_up_template_dto.rs new file mode 100644 index 0000000..d34a29a --- /dev/null +++ b/crates/erp-health/src/dto/follow_up_template_dto.rs @@ -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, + pub placeholder: Option, + pub validation: Option, + #[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, + pub placeholder: Option, + pub validation: Option, + pub sort_order: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub version: i32, +} + +// ── 模板 DTO ── + +#[derive(Debug, Clone, Deserialize, IntoParams)] +pub struct FollowUpTemplateListQuery { + pub page: Option, + pub page_size: Option, + pub follow_up_type: Option, + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateFollowUpTemplateReq { + pub name: String, + pub description: Option, + /// phone/outpatient/home_visit/online/wechat + pub follow_up_type: String, + pub applicable_scope: Option, + #[serde(default)] + pub fields: Vec, +} + +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, + pub description: Option, + pub follow_up_type: Option, + pub applicable_scope: Option, + pub status: Option, + /// 全量替换字段列表 + pub fields: Option>, +} + +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, + pub follow_up_type: String, + pub applicable_scope: Option, + pub status: String, + pub fields: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FollowUpTemplateListItemResp { + pub id: Uuid, + pub name: String, + pub description: Option, + pub follow_up_type: String, + pub status: String, + pub field_count: i64, + pub created_at: DateTime, + pub updated_at: DateTime, + pub version: i32, +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 0bceb30..9b1fc3a 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -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; diff --git a/crates/erp-health/src/entity/follow_up_template.rs b/crates/erp-health/src/entity/follow_up_template.rs new file mode 100644 index 0000000..6af743b --- /dev/null +++ b/crates/erp-health/src/entity/follow_up_template.rs @@ -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, + // 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, + // 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, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::Fields.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/follow_up_template_field.rs b/crates/erp-health/src/entity/follow_up_template_field.rs new file mode 100644 index 0000000..d4a9d1d --- /dev/null +++ b/crates/erp-health/src/entity/follow_up_template_field.rs @@ -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, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub validation: Option, + pub sort_order: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::Template.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index c07e1c1..a2466a9 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -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; diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 3d9fbd2..1acbbba 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -77,6 +77,9 @@ pub enum HealthError { #[error("透析方案不存在")] DialysisPrescriptionNotFound, + #[error("随访模板不存在")] + FollowUpTemplateNotFound, + #[error("状态转换无效: {0}")] InvalidStatusTransition(String), @@ -113,7 +116,8 @@ impl From 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, diff --git a/crates/erp-health/src/handler/follow_up_template_handler.rs b/crates/erp-health/src/handler/follow_up_template_handler.rs new file mode 100644 index 0000000..8b0734a --- /dev/null +++ b/crates/erp-health/src/handler/follow_up_template_handler.rs @@ -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, + pub page_size: Option, + pub follow_up_type: Option, + pub status: Option, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateFollowUpTemplateWithVersion { + #[serde(flatten)] + pub data: UpdateFollowUpTemplateReq, + pub version: i32, +} + +pub async fn list_templates( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + 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-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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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(()))) +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 2195c0f..8843483 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -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; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index f3eda93..aa094c8 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -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", diff --git a/crates/erp-health/src/service/follow_up_template_service.rs b/crates/erp-health/src/service/follow_up_template_service.rs new file mode 100644 index 0000000..974052e --- /dev/null +++ b/crates/erp-health/src/service/follow_up_template_service.rs @@ -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, + status: Option, +) -> HealthResult> { + 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 { + 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, + req: CreateFollowUpTemplateReq, +) -> HealthResult { + 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, + req: UpdateFollowUpTemplateReq, + 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)?; + + 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, + 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> { + 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, + req: TemplateFieldReq, + sort_order: i32, +) -> HealthResult { + 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) -> 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(()) +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 10665f7..8863900 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -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; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index e6376a6..82537f4 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -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), ] } } diff --git a/crates/erp-server/migration/src/m20260427_000083_create_follow_up_template.rs b/crates/erp-server/migration/src/m20260427_000083_create_follow_up_template.rs new file mode 100644 index 0000000..7393ae0 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000083_create_follow_up_template.rs @@ -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 + } +}