From ef422f354d0ff239fc86b884fb1bf79abda2d08d Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 18:40:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E6=8A=A4=E7=90=86=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E5=AE=9E=E4=BD=93=E4=B8=8E=E6=9C=8D=E5=8A=A1=20?= =?UTF-8?q?=E2=80=94=20Phase=201=20=E5=85=B3=E6=80=80=E5=BC=95=E6=93=8E=20?= =?UTF-8?q?MVP=20=E7=AC=AC=E4=B8=80=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增护理计划(Care Plan)完整 CRUD:3 张表(care_plans / care_plan_items / care_plan_outcomes)、3 个 SeaORM Entity、15 个 API 端点、4 个事件常量、 2 个权限码。支持透析/慢性/预防/康复计划类型,条目分干预/监测/目标/教育四类, 预后测量含基线/目标/当前值追踪。 --- crates/erp-health/src/dto/care_plan_dto.rs | 207 ++++++ crates/erp-health/src/dto/mod.rs | 1 + crates/erp-health/src/entity/care_plan.rs | 64 ++ .../erp-health/src/entity/care_plan_item.rs | 47 ++ .../src/entity/care_plan_outcome.rs | 61 ++ crates/erp-health/src/entity/mod.rs | 3 + crates/erp-health/src/error.rs | 14 +- crates/erp-health/src/event.rs | 6 + .../src/handler/care_plan_handler.rs | 304 ++++++++ crates/erp-health/src/handler/mod.rs | 1 + crates/erp-health/src/module.rs | 47 +- .../src/service/care_plan_service.rs | 647 ++++++++++++++++++ crates/erp-health/src/service/mod.rs | 1 + crates/erp-server/migration/src/lib.rs | 2 + .../src/m20260505_000111_create_care_plan.rs | 258 +++++++ .../tests/integration/health_alert_tests.rs | 1 + 16 files changed, 1662 insertions(+), 2 deletions(-) create mode 100644 crates/erp-health/src/dto/care_plan_dto.rs create mode 100644 crates/erp-health/src/entity/care_plan.rs create mode 100644 crates/erp-health/src/entity/care_plan_item.rs create mode 100644 crates/erp-health/src/entity/care_plan_outcome.rs create mode 100644 crates/erp-health/src/handler/care_plan_handler.rs create mode 100644 crates/erp-health/src/service/care_plan_service.rs create mode 100644 crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs diff --git a/crates/erp-health/src/dto/care_plan_dto.rs b/crates/erp-health/src/dto/care_plan_dto.rs new file mode 100644 index 0000000..2c38844 --- /dev/null +++ b/crates/erp-health/src/dto/care_plan_dto.rs @@ -0,0 +1,207 @@ +use chrono::{NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use erp_core::sanitize::{sanitize_option, sanitize_string}; + +// --------------------------------------------------------------------------- +// CarePlan +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CarePlanResp { + pub id: Uuid, + pub patient_id: Uuid, + pub plan_type: String, + pub status: String, + pub title: String, + pub goals: serde_json::Value, + pub start_date: Option, + pub end_date: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateCarePlanReq { + pub patient_id: Uuid, + pub plan_type: String, + pub title: String, + pub goals: Option, + pub start_date: Option, + pub end_date: Option, + pub notes: Option, +} + +impl CreateCarePlanReq { + pub fn sanitize(&mut self) { + self.title = sanitize_string(&self.title); + self.notes = sanitize_option(self.notes.take()); + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateCarePlanReq { + pub plan_type: Option, + pub title: Option, + pub status: Option, + pub goals: Option, + pub start_date: Option, + pub end_date: Option, + pub notes: Option, +} + +impl UpdateCarePlanReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.title { + *v = sanitize_string(v); + } + if let Some(ref mut v) = self.notes { + *v = sanitize_option(Some(std::mem::take(v))).unwrap_or_default(); + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateCarePlanWithVersion { + #[serde(flatten)] + pub data: UpdateCarePlanReq, + pub version: i32, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ListCarePlansParams { + pub patient_id: Option, + pub plan_type: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +// --------------------------------------------------------------------------- +// CarePlanItem +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CarePlanItemResp { + pub id: Uuid, + pub plan_id: Uuid, + pub item_type: String, + pub title: String, + pub description: Option, + pub status: String, + pub schedule: Option, + pub sort_order: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateCarePlanItemReq { + pub item_type: String, + pub title: String, + pub description: Option, + pub schedule: Option, + pub sort_order: Option, +} + +impl CreateCarePlanItemReq { + pub fn sanitize(&mut self) { + self.title = sanitize_string(&self.title); + self.description = sanitize_option(self.description.take()); + self.schedule = sanitize_option(self.schedule.take()); + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateCarePlanItemReq { + pub item_type: Option, + pub title: Option, + pub description: Option, + pub status: Option, + pub schedule: Option, + pub sort_order: Option, +} + +impl UpdateCarePlanItemReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.title { + *v = sanitize_string(v); + } + if let Some(ref mut v) = self.description { + *v = sanitize_option(Some(std::mem::take(v))).unwrap_or_default(); + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateCarePlanItemWithVersion { + #[serde(flatten)] + pub data: UpdateCarePlanItemReq, + pub version: i32, +} + +// --------------------------------------------------------------------------- +// CarePlanOutcome +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CarePlanOutcomeResp { + pub id: Uuid, + pub plan_id: Uuid, + pub item_id: Option, + pub metric: String, + pub baseline_value: String, + pub target_value: String, + pub current_value: Option, + pub measured_at: Option>, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateCarePlanOutcomeReq { + pub item_id: Option, + pub metric: String, + pub baseline_value: String, + pub target_value: String, + pub current_value: Option, + pub measured_at: Option>, + pub notes: Option, +} + +impl CreateCarePlanOutcomeReq { + pub fn sanitize(&mut self) { + self.metric = sanitize_string(&self.metric); + self.notes = sanitize_option(self.notes.take()); + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateCarePlanOutcomeReq { + pub current_value: Option, + pub target_value: Option, + pub measured_at: Option>, + pub notes: Option, +} + +impl UpdateCarePlanOutcomeReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.notes { + *v = sanitize_option(Some(std::mem::take(v))).unwrap_or_default(); + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateCarePlanOutcomeWithVersion { + #[serde(flatten)] + pub data: UpdateCarePlanOutcomeReq, + pub version: i32, +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 4f5db43..6245d55 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -1,5 +1,6 @@ pub mod appointment_dto; pub mod alert_dto; +pub mod care_plan_dto; pub mod article_dto; pub mod consent_dto; pub mod consultation_dto; diff --git a/crates/erp-health/src/entity/care_plan.rs b/crates/erp-health/src/entity/care_plan.rs new file mode 100644 index 0000000..c7fe189 --- /dev/null +++ b/crates/erp-health/src/entity/care_plan.rs @@ -0,0 +1,64 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "care_plans")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub plan_type: String, + pub status: String, + pub title: String, + pub goals: serde_json::Value, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub start_date: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub end_date: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + 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::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, + #[sea_orm(has_many = "super::care_plan_item::Entity")] + Items, + #[sea_orm(has_many = "super::care_plan_outcome::Entity")] + Outcomes, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Items.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Outcomes.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/care_plan_item.rs b/crates/erp-health/src/entity/care_plan_item.rs new file mode 100644 index 0000000..7131199 --- /dev/null +++ b/crates/erp-health/src/entity/care_plan_item.rs @@ -0,0 +1,47 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "care_plan_items")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub plan_id: Uuid, + pub item_type: String, + pub title: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub schedule: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub sort_order: Option, + 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::care_plan::Entity", + from = "Column::PlanId", + to = "super::care_plan::Column::Id" + )] + CarePlan, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::CarePlan.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/care_plan_outcome.rs b/crates/erp-health/src/entity/care_plan_outcome.rs new file mode 100644 index 0000000..8b67a3e --- /dev/null +++ b/crates/erp-health/src/entity/care_plan_outcome.rs @@ -0,0 +1,61 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "care_plan_outcomes")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub plan_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub item_id: Option, + pub metric: String, + pub baseline_value: String, + pub target_value: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub current_value: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub measured_at: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + 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::care_plan::Entity", + from = "Column::PlanId", + to = "super::care_plan::Column::Id" + )] + CarePlan, + #[sea_orm( + belongs_to = "super::care_plan_item::Entity", + from = "Column::ItemId", + to = "super::care_plan_item::Column::Id" + )] + CarePlanItem, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::CarePlan.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::CarePlanItem.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index 5ba1a26..4b54ce2 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -43,5 +43,8 @@ pub mod offline_event_registration; pub mod medication_record; pub mod medication_reminder; pub mod vital_signs; +pub mod care_plan; +pub mod care_plan_item; +pub mod care_plan_outcome; pub mod vital_signs_daily; pub mod vital_signs_hourly; diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 162e5b2..e087617 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -83,6 +83,15 @@ pub enum HealthError { #[error("药物提醒不存在")] MedicationReminderNotFound, + #[error("护理计划不存在")] + CarePlanNotFound, + + #[error("护理计划条目不存在")] + CarePlanItemNotFound, + + #[error("护理计划预后不存在")] + CarePlanOutcomeNotFound, + #[error("状态转换无效: {0}")] InvalidStatusTransition(String), @@ -121,7 +130,10 @@ impl From for AppError { | HealthError::AlertNotFound | HealthError::FollowUpTemplateNotFound | HealthError::CriticalAlertNotFound - | HealthError::MedicationReminderNotFound => AppError::NotFound(err.to_string()), + | HealthError::MedicationReminderNotFound + | HealthError::CarePlanNotFound + | HealthError::CarePlanItemNotFound + | HealthError::CarePlanOutcomeNotFound => 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/event.rs b/crates/erp-health/src/event.rs index 5c7d571..7f74d07 100644 --- a/crates/erp-health/src/event.rs +++ b/crates/erp-health/src/event.rs @@ -57,6 +57,12 @@ pub const POINTS_EXPIRED: &str = "points.expired"; pub const POINTS_EARNED: &str = "points.earned"; pub const POINTS_EXCHANGED: &str = "points.exchanged"; +// 护理计划 +pub const CARE_PLAN_CREATED: &str = "care_plan.created"; +pub const CARE_PLAN_UPDATED: &str = "care_plan.updated"; +pub const CARE_PLAN_ACTIVATED: &str = "care_plan.activated"; +pub const CARE_PLAN_COMPLETED: &str = "care_plan.completed"; + /// 兼容旧签名 — 不做任何实际订阅(逻辑已迁移到 on_startup) pub fn register_handlers(_bus: &EventBus) { // 事件处理器已迁移到 on_startup → register_handlers_with_state diff --git a/crates/erp-health/src/handler/care_plan_handler.rs b/crates/erp-health/src/handler/care_plan_handler.rs new file mode 100644 index 0000000..98e1ea4 --- /dev/null +++ b/crates/erp-health/src/handler/care_plan_handler.rs @@ -0,0 +1,304 @@ +use axum::extract::{FromRef, Json, Path, Query, State}; +use axum::Extension; +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::dto::care_plan_dto::*; +use crate::service::care_plan_service; +use crate::state::HealthState; + +#[derive(Debug, serde::Deserialize)] +pub struct PaginationParams { + pub page: Option, + pub page_size: Option, +} + +// --------------------------------------------------------------------------- +// CarePlan +// --------------------------------------------------------------------------- + +pub async fn list_care_plans( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.list")?; + let result = care_plan_service::list_care_plans(&state, ctx.tenant_id, ¶ms).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_care_plan( + State(state): State, + Extension(ctx): Extension, + Path(plan_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.list")?; + let result = care_plan_service::get_care_plan(&state, ctx.tenant_id, plan_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_care_plan( + State(state): State, + Extension(ctx): Extension, + Json(mut req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + req.sanitize(); + let result = + care_plan_service::create_care_plan(&state, ctx.tenant_id, Some(ctx.user_id), req).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_care_plan( + State(state): State, + Extension(ctx): Extension, + Path(plan_id): Path, + Json(mut req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + req.data.sanitize(); + let result = care_plan_service::update_care_plan( + &state, + ctx.tenant_id, + plan_id, + Some(ctx.user_id), + req, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_care_plan( + State(state): State, + Extension(ctx): Extension, + Path(plan_id): Path, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + care_plan_service::delete_care_plan( + &state, + ctx.tenant_id, + plan_id, + Some(ctx.user_id), + body.version, + ) + .await?; + Ok(Json(ApiResponse::ok(()))) +} + +// --------------------------------------------------------------------------- +// CarePlanItem +// --------------------------------------------------------------------------- + +pub async fn list_care_plan_items( + State(state): State, + Extension(ctx): Extension, + Path(plan_id): Path, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.list")?; + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let result = care_plan_service::list_care_plan_items( + &state, + ctx.tenant_id, + plan_id, + page, + page_size, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_care_plan_item( + State(state): State, + Extension(ctx): Extension, + Path(plan_id): Path, + Json(mut req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + req.sanitize(); + let result = care_plan_service::create_care_plan_item( + &state, + ctx.tenant_id, + plan_id, + Some(ctx.user_id), + req, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_care_plan_item( + State(state): State, + Extension(ctx): Extension, + Path((plan_id, item_id)): Path<(uuid::Uuid, uuid::Uuid)>, + Json(mut req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + req.data.sanitize(); + let result = care_plan_service::update_care_plan_item( + &state, + ctx.tenant_id, + plan_id, + item_id, + Some(ctx.user_id), + req, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_care_plan_item( + State(state): State, + Extension(ctx): Extension, + Path((plan_id, item_id)): Path<(uuid::Uuid, uuid::Uuid)>, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + care_plan_service::delete_care_plan_item( + &state, + ctx.tenant_id, + plan_id, + item_id, + Some(ctx.user_id), + body.version, + ) + .await?; + Ok(Json(ApiResponse::ok(()))) +} + +// --------------------------------------------------------------------------- +// CarePlanOutcome +// --------------------------------------------------------------------------- + +pub async fn list_care_plan_outcomes( + State(state): State, + Extension(ctx): Extension, + Path(plan_id): Path, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.list")?; + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let result = care_plan_service::list_care_plan_outcomes( + &state, + ctx.tenant_id, + plan_id, + page, + page_size, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_care_plan_outcome( + State(state): State, + Extension(ctx): Extension, + Path(plan_id): Path, + Json(mut req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + req.sanitize(); + let result = care_plan_service::create_care_plan_outcome( + &state, + ctx.tenant_id, + plan_id, + Some(ctx.user_id), + req, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_care_plan_outcome( + State(state): State, + Extension(ctx): Extension, + Path((plan_id, outcome_id)): Path<(uuid::Uuid, uuid::Uuid)>, + Json(mut req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + req.data.sanitize(); + let result = care_plan_service::update_care_plan_outcome( + &state, + ctx.tenant_id, + plan_id, + outcome_id, + Some(ctx.user_id), + req, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_care_plan_outcome( + State(state): State, + Extension(ctx): Extension, + Path((plan_id, outcome_id)): Path<(uuid::Uuid, uuid::Uuid)>, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.care-plan.manage")?; + care_plan_service::delete_care_plan_outcome( + &state, + ctx.tenant_id, + plan_id, + outcome_id, + Some(ctx.user_id), + body.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 40340a5..5ce59aa 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -5,6 +5,7 @@ pub mod appointment_handler; pub mod article_category_handler; pub mod article_handler; pub mod article_tag_handler; +pub mod care_plan_handler; pub mod consultation_handler; pub mod consent_handler; pub mod critical_alert_handler; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index cd9651d..466abf5 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -8,7 +8,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor}; use crate::handler::{ action_inbox_handler, alert_handler, alert_rule_handler, - appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, + appointment_handler, article_category_handler, article_handler, article_tag_handler, care_plan_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, health_data_handler, medication_record_handler, medication_reminder_handler, patient_handler, points_handler, stats_handler, vital_signs_daily_handler, }; @@ -820,6 +820,38 @@ impl HealthModule { "/health/oauth/clients/{id}/regenerate-secret", axum::routing::post(crate::oauth::handler::regenerate_secret), ) + // 护理计划 + .route( + "/health/care-plans", + axum::routing::get(care_plan_handler::list_care_plans) + .post(care_plan_handler::create_care_plan), + ) + .route( + "/health/care-plans/{id}", + axum::routing::get(care_plan_handler::get_care_plan) + .put(care_plan_handler::update_care_plan) + .delete(care_plan_handler::delete_care_plan), + ) + .route( + "/health/care-plans/{plan_id}/items", + axum::routing::get(care_plan_handler::list_care_plan_items) + .post(care_plan_handler::create_care_plan_item), + ) + .route( + "/health/care-plans/{plan_id}/items/{item_id}", + axum::routing::put(care_plan_handler::update_care_plan_item) + .delete(care_plan_handler::delete_care_plan_item), + ) + .route( + "/health/care-plans/{plan_id}/outcomes", + axum::routing::get(care_plan_handler::list_care_plan_outcomes) + .post(care_plan_handler::create_care_plan_outcome), + ) + .route( + "/health/care-plans/{plan_id}/outcomes/{outcome_id}", + axum::routing::put(care_plan_handler::update_care_plan_outcome) + .delete(care_plan_handler::delete_care_plan_outcome), + ) } } @@ -1240,6 +1272,19 @@ impl ErpModule for HealthModule { description: "创建/编辑/删除 FHIR API 合作方".into(), module: "health".into(), }, + // 护理计划 + PermissionDescriptor { + code: "health.care-plan.list".into(), + name: "查看护理计划".into(), + description: "查看护理计划、条目和预后测量".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.care-plan.manage".into(), + name: "管理护理计划".into(), + description: "创建/编辑/删除护理计划、条目和预后测量".into(), + module: "health".into(), + }, ] } diff --git a/crates/erp-health/src/service/care_plan_service.rs b/crates/erp-health/src/service/care_plan_service.rs new file mode 100644 index 0000000..d462529 --- /dev/null +++ b/crates/erp-health/src/service/care_plan_service.rs @@ -0,0 +1,647 @@ +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::events::DomainEvent; +use erp_core::types::PaginatedResponse; + +use crate::dto::care_plan_dto::*; +use crate::entity::{care_plan, care_plan_item, care_plan_outcome, patient}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// CarePlan CRUD +// --------------------------------------------------------------------------- + +pub async fn list_care_plans( + state: &HealthState, + tenant_id: Uuid, + params: &ListCarePlansParams, +) -> HealthResult> { + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = care_plan::Entity::find() + .filter(care_plan::Column::TenantId.eq(tenant_id)) + .filter(care_plan::Column::DeletedAt.is_null()); + + if let Some(pid) = params.patient_id { + query = query.filter(care_plan::Column::PatientId.eq(pid)); + } + if let Some(ref pt) = params.plan_type { + query = query.filter(care_plan::Column::PlanType.eq(pt.as_str())); + } + if let Some(ref st) = params.status { + query = query.filter(care_plan::Column::Status.eq(st.as_str())); + } + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_desc(care_plan::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = rows.into_iter().map(plan_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn get_care_plan( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, +) -> HealthResult { + let m = find_plan(state, tenant_id, plan_id).await?; + Ok(plan_to_resp(m)) +} + +pub async fn create_care_plan( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateCarePlanReq, +) -> HealthResult { + patient::Entity::find() + .filter(patient::Column::Id.eq(req.patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + validate_plan_type(&req.plan_type)?; + + let now = Utc::now(); + let active = care_plan::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(req.patient_id), + plan_type: Set(req.plan_type), + status: Set("draft".to_string()), + title: Set(req.title), + goals: Set(req.goals.unwrap_or(serde_json::json!([]))), + start_date: Set(req.start_date), + end_date: Set(req.end_date), + notes: Set(req.notes), + 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?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "care_plan.created", "care_plan") + .with_resource_id(m.id), + &state.db, + ) + .await; + + state + .event_bus + .publish( + DomainEvent::new( + crate::event::CARE_PLAN_CREATED, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "plan_id": m.id, "patient_id": m.patient_id, + "plan_type": m.plan_type, "title": m.title, + })), + ), + &state.db, + ) + .await; + + Ok(plan_to_resp(m)) +} + +pub async fn update_care_plan( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + operator_id: Option, + req: UpdateCarePlanWithVersion, +) -> HealthResult { + let existing = find_plan(state, tenant_id, plan_id).await?; + let _old_status = existing.status.clone(); // 用于后续事件类型判断 + let next_ver = check_version(req.version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let old_status = existing.status.clone(); + let mut active: care_plan::ActiveModel = existing.into(); + let now = Utc::now(); + + if let Some(v) = req.data.plan_type { + validate_plan_type(&v)?; + active.plan_type = Set(v); + } + if let Some(v) = req.data.title { + active.title = Set(v); + } + if let Some(v) = req.data.status { + validate_plan_status(&v)?; + active.status = Set(v); + } + if let Some(v) = req.data.goals { + active.goals = Set(v); + } + if req.data.start_date.is_some() { + active.start_date = Set(req.data.start_date); + } + if req.data.end_date.is_some() { + active.end_date = Set(req.data.end_date); + } + if req.data.notes.is_some() { + active.notes = Set(req.data.notes); + } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "care_plan.updated", "care_plan") + .with_resource_id(m.id), + &state.db, + ) + .await; + + let event_type = match m.status.as_str() { + "active" => crate::event::CARE_PLAN_ACTIVATED, + "completed" => crate::event::CARE_PLAN_COMPLETED, + _ => crate::event::CARE_PLAN_UPDATED, + }; + state + .event_bus + .publish( + DomainEvent::new( + event_type, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "plan_id": m.id, "patient_id": m.patient_id, + "status": m.status, + })), + ), + &state.db, + ) + .await; + + Ok(plan_to_resp(m)) +} + +pub async fn delete_care_plan( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let existing = find_plan(state, tenant_id, plan_id).await?; + let next_ver = check_version(version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let now = Utc::now(); + let mut active: care_plan::ActiveModel = existing.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "care_plan.deleted", "care_plan") + .with_resource_id(plan_id), + &state.db, + ) + .await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// CarePlanItem CRUD +// --------------------------------------------------------------------------- + +pub async fn list_care_plan_items( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let _plan = find_plan(state, tenant_id, plan_id).await?; + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = care_plan_item::Entity::find() + .filter(care_plan_item::Column::TenantId.eq(tenant_id)) + .filter(care_plan_item::Column::PlanId.eq(plan_id)) + .filter(care_plan_item::Column::DeletedAt.is_null()); + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_asc(care_plan_item::Column::SortOrder) + .order_by_desc(care_plan_item::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = rows.into_iter().map(item_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn create_care_plan_item( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + operator_id: Option, + req: CreateCarePlanItemReq, +) -> HealthResult { + let _plan = find_plan(state, tenant_id, plan_id).await?; + validate_item_type(&req.item_type)?; + + let now = Utc::now(); + let active = care_plan_item::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + plan_id: Set(plan_id), + item_type: Set(req.item_type), + title: Set(req.title), + description: Set(req.description), + status: Set("pending".to_string()), + schedule: Set(req.schedule), + sort_order: Set(req.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(item_to_resp(m)) +} + +pub async fn update_care_plan_item( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + item_id: Uuid, + operator_id: Option, + req: UpdateCarePlanItemWithVersion, +) -> HealthResult { + let _plan = find_plan(state, tenant_id, plan_id).await?; + let existing = care_plan_item::Entity::find_by_id(item_id) + .one(&state.db) + .await? + .ok_or(HealthError::CarePlanItemNotFound)?; + + if existing.tenant_id != tenant_id || existing.plan_id != plan_id { + return Err(HealthError::CarePlanItemNotFound); + } + + let next_ver = check_version(req.version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: care_plan_item::ActiveModel = existing.into(); + let now = Utc::now(); + + if let Some(v) = req.data.item_type { + validate_item_type(&v)?; + active.item_type = Set(v); + } + if let Some(v) = req.data.title { + active.title = Set(v); + } + if let Some(v) = req.data.status { + active.status = Set(v); + } + if req.data.description.is_some() { + active.description = Set(req.data.description); + } + if req.data.schedule.is_some() { + active.schedule = Set(req.data.schedule); + } + if req.data.sort_order.is_some() { + active.sort_order = Set(req.data.sort_order); + } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + Ok(item_to_resp(m)) +} + +pub async fn delete_care_plan_item( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + item_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let _plan = find_plan(state, tenant_id, plan_id).await?; + let existing = care_plan_item::Entity::find_by_id(item_id) + .one(&state.db) + .await? + .ok_or(HealthError::CarePlanItemNotFound)?; + + if existing.tenant_id != tenant_id || existing.plan_id != plan_id { + return Err(HealthError::CarePlanItemNotFound); + } + + let next_ver = check_version(version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let now = Utc::now(); + let mut active: care_plan_item::ActiveModel = existing.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// CarePlanOutcome CRUD +// --------------------------------------------------------------------------- + +pub async fn list_care_plan_outcomes( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let _plan = find_plan(state, tenant_id, plan_id).await?; + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = care_plan_outcome::Entity::find() + .filter(care_plan_outcome::Column::TenantId.eq(tenant_id)) + .filter(care_plan_outcome::Column::PlanId.eq(plan_id)) + .filter(care_plan_outcome::Column::DeletedAt.is_null()); + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_desc(care_plan_outcome::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = rows.into_iter().map(outcome_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn create_care_plan_outcome( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + operator_id: Option, + req: CreateCarePlanOutcomeReq, +) -> HealthResult { + let _plan = find_plan(state, tenant_id, plan_id).await?; + + let now = Utc::now(); + let active = care_plan_outcome::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + plan_id: Set(plan_id), + item_id: Set(req.item_id), + metric: Set(req.metric), + baseline_value: Set(req.baseline_value), + target_value: Set(req.target_value), + current_value: Set(req.current_value), + measured_at: Set(req.measured_at), + notes: Set(req.notes), + 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(outcome_to_resp(m)) +} + +pub async fn update_care_plan_outcome( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + outcome_id: Uuid, + operator_id: Option, + req: UpdateCarePlanOutcomeWithVersion, +) -> HealthResult { + let _plan = find_plan(state, tenant_id, plan_id).await?; + let existing = care_plan_outcome::Entity::find_by_id(outcome_id) + .one(&state.db) + .await? + .ok_or(HealthError::CarePlanOutcomeNotFound)?; + + if existing.tenant_id != tenant_id || existing.plan_id != plan_id { + return Err(HealthError::CarePlanOutcomeNotFound); + } + + let next_ver = check_version(req.version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: care_plan_outcome::ActiveModel = existing.into(); + let now = Utc::now(); + + if req.data.current_value.is_some() { + active.current_value = Set(req.data.current_value); + active.measured_at = Set(Some(now)); + } + if let Some(v) = req.data.target_value { + active.target_value = Set(v); + } + if req.data.measured_at.is_some() { + active.measured_at = Set(req.data.measured_at); + } + if req.data.notes.is_some() { + active.notes = Set(req.data.notes); + } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + Ok(outcome_to_resp(m)) +} + +pub async fn delete_care_plan_outcome( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, + outcome_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let _plan = find_plan(state, tenant_id, plan_id).await?; + let existing = care_plan_outcome::Entity::find_by_id(outcome_id) + .one(&state.db) + .await? + .ok_or(HealthError::CarePlanOutcomeNotFound)?; + + if existing.tenant_id != tenant_id || existing.plan_id != plan_id { + return Err(HealthError::CarePlanOutcomeNotFound); + } + + let next_ver = check_version(version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let now = Utc::now(); + let mut active: care_plan_outcome::ActiveModel = existing.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn find_plan( + state: &HealthState, + tenant_id: Uuid, + plan_id: Uuid, +) -> HealthResult { + care_plan::Entity::find_by_id(plan_id) + .one(&state.db) + .await? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or(HealthError::CarePlanNotFound) +} + +fn plan_to_resp(m: care_plan::Model) -> CarePlanResp { + CarePlanResp { + id: m.id, + patient_id: m.patient_id, + plan_type: m.plan_type, + status: m.status, + title: m.title, + goals: m.goals, + start_date: m.start_date, + end_date: m.end_date, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn item_to_resp(m: care_plan_item::Model) -> CarePlanItemResp { + CarePlanItemResp { + id: m.id, + plan_id: m.plan_id, + item_type: m.item_type, + title: m.title, + description: m.description, + status: m.status, + schedule: m.schedule, + sort_order: m.sort_order, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn outcome_to_resp(m: care_plan_outcome::Model) -> CarePlanOutcomeResp { + CarePlanOutcomeResp { + id: m.id, + plan_id: m.plan_id, + item_id: m.item_id, + metric: m.metric, + baseline_value: m.baseline_value, + target_value: m.target_value, + current_value: m.current_value, + measured_at: m.measured_at, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn validate_plan_type(plan_type: &str) -> HealthResult<()> { + let valid = ["dialysis", "chronic", "preventive", "rehabilitation"]; + if valid.contains(&plan_type) { + Ok(()) + } else { + Err(HealthError::Validation(format!( + "plan_type 必须为以下之一: {}", + valid.join(", ") + ))) + } +} + +fn validate_plan_status(status: &str) -> HealthResult<()> { + let valid = ["draft", "active", "paused", "completed", "cancelled"]; + if valid.contains(&status) { + Ok(()) + } else { + Err(HealthError::Validation(format!( + "status 必须为以下之一: {}", + valid.join(", ") + ))) + } +} + +fn validate_item_type(item_type: &str) -> HealthResult<()> { + let valid = ["intervention", "monitoring", "goal", "education"]; + if valid.contains(&item_type) { + Ok(()) + } else { + Err(HealthError::Validation(format!( + "item_type 必须为以下之一: {}", + valid.join(", ") + ))) + } +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 11cba20..260ae21 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -9,6 +9,7 @@ pub mod appointment_service; pub mod article_category_service; pub mod article_service; pub mod article_tag_service; +pub mod care_plan_service; pub mod consultation_service; pub mod consent_service; pub mod critical_alert_service; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index fc60e21..baef196 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -110,6 +110,7 @@ mod m20260504_000107_alter_article_article_tag_add_tenant_and_soft_delete; mod m20260504_000108_alter_vital_signs_hourly_add_soft_delete; mod m20260504_000109_add_missing_fk_constraints; mod m20260504_000110_alter_critical_alerts_version_i32; +mod m20260505_000111_create_care_plan; pub struct Migrator; @@ -227,6 +228,7 @@ impl MigratorTrait for Migrator { Box::new(m20260504_000108_alter_vital_signs_hourly_add_soft_delete::Migration), Box::new(m20260504_000109_add_missing_fk_constraints::Migration), Box::new(m20260504_000110_alter_critical_alerts_version_i32::Migration), + Box::new(m20260505_000111_create_care_plan::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs b/crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs new file mode 100644 index 0000000..ee89b1e --- /dev/null +++ b/crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs @@ -0,0 +1,258 @@ +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> { + // care_plans — 护理计划主表 + manager + .create_table( + Table::create() + .table(Alias::new("care_plans")) + .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("patient_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("plan_type")) + .string_len(50) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("status")) + .string_len(32) + .not_null() + .default("draft"), + ) + .col(ColumnDef::new(Alias::new("title")).string_len(200).not_null()) + .col( + ColumnDef::new(Alias::new("goals")) + .json_binary() + .not_null() + .default("[]"), + ) + .col(ColumnDef::new(Alias::new("start_date")).date()) + .col(ColumnDef::new(Alias::new("end_date")).date()) + .col(ColumnDef::new(Alias::new("notes")).text()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid()) + .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone()) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_care_plans_tenant_patient") + .table(Alias::new("care_plans")) + .col(Alias::new("tenant_id")) + .col(Alias::new("patient_id")) + .col(Alias::new("deleted_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_care_plans_tenant_status") + .table(Alias::new("care_plans")) + .col(Alias::new("tenant_id")) + .col(Alias::new("status")) + .col(Alias::new("deleted_at")) + .to_owned(), + ) + .await?; + + // care_plan_items — 护理计划条目(干预/监测/目标) + manager + .create_table( + Table::create() + .table(Alias::new("care_plan_items")) + .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("plan_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("item_type")) + .string_len(32) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("title")) + .string_len(200) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("description")).text()) + .col( + ColumnDef::new(Alias::new("status")) + .string_len(32) + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(Alias::new("schedule")).string_len(100)) + .col(ColumnDef::new(Alias::new("sort_order")).integer().default(0)) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid()) + .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone()) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_care_plan_items_plan_id") + .from(Alias::new("care_plan_items"), Alias::new("plan_id")) + .to(Alias::new("care_plans"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_care_plan_items_tenant_plan") + .table(Alias::new("care_plan_items")) + .col(Alias::new("tenant_id")) + .col(Alias::new("plan_id")) + .col(Alias::new("deleted_at")) + .to_owned(), + ) + .await?; + + // care_plan_outcomes — 护理计划预后测量 + manager + .create_table( + Table::create() + .table(Alias::new("care_plan_outcomes")) + .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("plan_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("item_id")).uuid()) + .col( + ColumnDef::new(Alias::new("metric")) + .string_len(100) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("baseline_value")) + .string_len(50) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("target_value")) + .string_len(50) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("current_value")).string_len(50)) + .col( + ColumnDef::new(Alias::new("measured_at")) + .timestamp_with_time_zone(), + ) + .col(ColumnDef::new(Alias::new("notes")).text()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid()) + .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone()) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_care_plan_outcomes_plan_id") + .from(Alias::new("care_plan_outcomes"), Alias::new("plan_id")) + .to(Alias::new("care_plans"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_care_plan_outcomes_tenant_plan") + .table(Alias::new("care_plan_outcomes")) + .col(Alias::new("tenant_id")) + .col(Alias::new("plan_id")) + .col(Alias::new("deleted_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("care_plan_outcomes")).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Alias::new("care_plan_items")).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Alias::new("care_plans")).to_owned()) + .await?; + Ok(()) + } +} diff --git a/crates/erp-server/tests/integration/health_alert_tests.rs b/crates/erp-server/tests/integration/health_alert_tests.rs index aefcc56..6e50331 100644 --- a/crates/erp-server/tests/integration/health_alert_tests.rs +++ b/crates/erp-server/tests/integration/health_alert_tests.rs @@ -46,6 +46,7 @@ async fn seed_hourly( sample_count: Set(1), created_at: Set(chrono::Utc::now()), updated_at: Set(chrono::Utc::now()), + deleted_at: Set(None), version: Set(1), }; model.insert(app.db()).await.expect("插入 hourly 应成功");