diff --git a/Cargo.lock b/Cargo.lock index a8bf9e5..0db747b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,6 +1465,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "erp-dialysis" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "chrono", + "erp-core", + "num-traits", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", + "validator", +] + [[package]] name = "erp-health" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3a934de..c157830 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/erp-health", "crates/erp-ai", "crates/erp-plugin-assessment", + "crates/erp-dialysis", ] [workspace.package] diff --git a/crates/erp-dialysis/Cargo.toml b/crates/erp-dialysis/Cargo.toml new file mode 100644 index 0000000..55c4594 --- /dev/null +++ b/crates/erp-dialysis/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "erp-dialysis" +version.workspace = true +edition.workspace = true + +[dependencies] +erp-core.workspace = true +sea-orm.workspace = true +tokio.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +axum.workspace = true +utoipa.workspace = true +validator.workspace = true +async-trait.workspace = true +tracing.workspace = true +thiserror.workspace = true +num-traits = "0.2" diff --git a/crates/erp-health/src/dto/dialysis_dto.rs b/crates/erp-dialysis/src/dto/dialysis_dto.rs similarity index 100% rename from crates/erp-health/src/dto/dialysis_dto.rs rename to crates/erp-dialysis/src/dto/dialysis_dto.rs diff --git a/crates/erp-health/src/dto/dialysis_prescription_dto.rs b/crates/erp-dialysis/src/dto/dialysis_prescription_dto.rs similarity index 100% rename from crates/erp-health/src/dto/dialysis_prescription_dto.rs rename to crates/erp-dialysis/src/dto/dialysis_prescription_dto.rs diff --git a/crates/erp-dialysis/src/dto/mod.rs b/crates/erp-dialysis/src/dto/mod.rs new file mode 100644 index 0000000..dbaa8bd --- /dev/null +++ b/crates/erp-dialysis/src/dto/mod.rs @@ -0,0 +1,7 @@ +pub mod dialysis_dto; +pub mod dialysis_prescription_dto; + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteWithVersion { + pub version: i32, +} diff --git a/crates/erp-health/src/entity/dialysis_prescription.rs b/crates/erp-dialysis/src/entity/dialysis_prescription.rs similarity index 90% rename from crates/erp-health/src/entity/dialysis_prescription.rs rename to crates/erp-dialysis/src/entity/dialysis_prescription.rs index 97816a9..2031b4f 100644 --- a/crates/erp-health/src/entity/dialysis_prescription.rs +++ b/crates/erp-dialysis/src/entity/dialysis_prescription.rs @@ -59,19 +59,6 @@ pub struct Model { } #[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, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Patient.def() - } -} +pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/dialysis_record.rs b/crates/erp-dialysis/src/entity/dialysis_record.rs similarity index 90% rename from crates/erp-health/src/entity/dialysis_record.rs rename to crates/erp-dialysis/src/entity/dialysis_record.rs index 6d90d1f..c15176d 100644 --- a/crates/erp-health/src/entity/dialysis_record.rs +++ b/crates/erp-dialysis/src/entity/dialysis_record.rs @@ -63,19 +63,6 @@ pub struct Model { } #[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, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Patient.def() - } -} +pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-dialysis/src/entity/mod.rs b/crates/erp-dialysis/src/entity/mod.rs new file mode 100644 index 0000000..9ab3303 --- /dev/null +++ b/crates/erp-dialysis/src/entity/mod.rs @@ -0,0 +1,2 @@ +pub mod dialysis_prescription; +pub mod dialysis_record; diff --git a/crates/erp-dialysis/src/error.rs b/crates/erp-dialysis/src/error.rs new file mode 100644 index 0000000..7021add --- /dev/null +++ b/crates/erp-dialysis/src/error.rs @@ -0,0 +1,60 @@ +use erp_core::error::AppError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DialysisError { + #[error("{0}")] + Validation(String), + + #[error("患者不存在")] + PatientNotFound, + + #[error("透析记录不存在")] + DialysisRecordNotFound, + + #[error("透析方案不存在")] + DialysisPrescriptionNotFound, + + #[error("状态转换无效: {0}")] + InvalidStatusTransition(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, + + #[error("数据库操作失败: {0}")] + DbError(String), +} + +impl From for AppError { + fn from(err: DialysisError) -> Self { + match err { + DialysisError::Validation(s) => AppError::Validation(s), + DialysisError::PatientNotFound + | DialysisError::DialysisRecordNotFound + | DialysisError::DialysisPrescriptionNotFound => AppError::NotFound(err.to_string()), + DialysisError::InvalidStatusTransition(s) => AppError::Validation(s), + DialysisError::VersionMismatch => AppError::VersionMismatch, + DialysisError::DbError(_) => AppError::Internal(err.to_string()), + } + } +} + +impl From for DialysisError { + fn from(err: sea_orm::DbErr) -> Self { + DialysisError::DbError(err.to_string()) + } +} + +impl From for DialysisError { + fn from(err: AppError) -> Self { + DialysisError::Validation(err.to_string()) + } +} + +impl From for DialysisError { + fn from(err: String) -> Self { + DialysisError::Validation(err) + } +} + +pub type DialysisResult = Result; diff --git a/crates/erp-dialysis/src/event.rs b/crates/erp-dialysis/src/event.rs new file mode 100644 index 0000000..0faa924 --- /dev/null +++ b/crates/erp-dialysis/src/event.rs @@ -0,0 +1,6 @@ +use erp_core::events::EventBus; + +/// 预留事件处理器注册 +pub fn register_handlers_with_state(_state: crate::state::DialysisState) { + // 透析模块事件消费者待后续迭代 +} diff --git a/crates/erp-health/src/handler/dialysis_handler.rs b/crates/erp-dialysis/src/handler/dialysis_handler.rs similarity index 89% rename from crates/erp-health/src/handler/dialysis_handler.rs rename to crates/erp-dialysis/src/handler/dialysis_handler.rs index 6ab12ed..3f30a12 100644 --- a/crates/erp-health/src/handler/dialysis_handler.rs +++ b/crates/erp-dialysis/src/handler/dialysis_handler.rs @@ -11,7 +11,7 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::dto::dialysis_dto::*; use crate::dto::DeleteWithVersion; use crate::service::dialysis_service; -use crate::state::HealthState; +use crate::state::DialysisState; #[derive(Debug, Deserialize, IntoParams)] pub struct PaginationParams { @@ -32,13 +32,13 @@ pub struct ReviewDialysisWithVersion { } pub async fn list_dialysis_records( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(patient_id): Path, Query(params): Query, ) -> Result>>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.list")?; @@ -52,12 +52,12 @@ where } pub async fn get_dialysis_record( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(record_id): Path, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.list")?; @@ -69,12 +69,12 @@ where } pub async fn create_dialysis_record( - State(state): State, + State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; @@ -88,13 +88,13 @@ where } pub async fn update_dialysis_record( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(record_id): Path, Json(req): Json, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; @@ -108,13 +108,13 @@ where } pub async fn review_dialysis_record( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(record_id): Path, Json(req): Json, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; @@ -126,13 +126,13 @@ where } pub async fn delete_dialysis_record( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(record_id): Path, Json(req): Json, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; diff --git a/crates/erp-health/src/handler/dialysis_prescription_handler.rs b/crates/erp-dialysis/src/handler/dialysis_prescription_handler.rs similarity index 90% rename from crates/erp-health/src/handler/dialysis_prescription_handler.rs rename to crates/erp-dialysis/src/handler/dialysis_prescription_handler.rs index b40b72a..172fd25 100644 --- a/crates/erp-health/src/handler/dialysis_prescription_handler.rs +++ b/crates/erp-dialysis/src/handler/dialysis_prescription_handler.rs @@ -11,7 +11,7 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::dto::dialysis_prescription_dto::*; use crate::dto::DeleteWithVersion; use crate::service::dialysis_prescription_service; -use crate::state::HealthState; +use crate::state::DialysisState; #[derive(Debug, Deserialize, IntoParams)] pub struct DialysisPrescriptionListParams { @@ -29,12 +29,12 @@ pub struct UpdateDialysisPrescriptionWithVersion { } pub async fn list_prescriptions( - State(state): State, + State(state): State, Extension(ctx): Extension, Query(params): Query, ) -> Result>>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.dialysis-prescription.list")?; @@ -48,12 +48,12 @@ where } pub async fn get_prescription( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.dialysis-prescription.list")?; @@ -62,12 +62,12 @@ where } pub async fn create_prescription( - State(state): State, + State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.dialysis-prescription.manage")?; @@ -81,13 +81,13 @@ where } pub async fn update_prescription( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.dialysis-prescription.manage")?; @@ -101,13 +101,13 @@ where } pub async fn delete_prescription( - State(state): State, + State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> Result>, AppError> where - HealthState: FromRef, + DialysisState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.dialysis-prescription.manage")?; diff --git a/crates/erp-dialysis/src/handler/mod.rs b/crates/erp-dialysis/src/handler/mod.rs new file mode 100644 index 0000000..784162d --- /dev/null +++ b/crates/erp-dialysis/src/handler/mod.rs @@ -0,0 +1,2 @@ +pub mod dialysis_handler; +pub mod dialysis_prescription_handler; diff --git a/crates/erp-dialysis/src/lib.rs b/crates/erp-dialysis/src/lib.rs new file mode 100644 index 0000000..3614589 --- /dev/null +++ b/crates/erp-dialysis/src/lib.rs @@ -0,0 +1,11 @@ +pub mod dto; +pub mod entity; +pub mod error; +pub mod event; +pub mod handler; +pub mod module; +pub mod service; +pub mod state; + +pub use module::DialysisModule; +pub use state::DialysisState; diff --git a/crates/erp-dialysis/src/module.rs b/crates/erp-dialysis/src/module.rs new file mode 100644 index 0000000..c18e774 --- /dev/null +++ b/crates/erp-dialysis/src/module.rs @@ -0,0 +1,114 @@ +use async_trait::async_trait; +use axum::Router; +use erp_core::error::AppResult; +use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor}; + +use crate::handler::{dialysis_handler, dialysis_prescription_handler}; +use crate::state::DialysisState; + +pub struct DialysisModule; + +impl DialysisModule { + pub fn public_routes() -> Router + where + DialysisState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + } + + pub fn protected_routes() -> Router + where + DialysisState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + // 透析记录 + .route( + "/health/patients/{id}/dialysis-records", + axum::routing::get(dialysis_handler::list_dialysis_records), + ) + .route( + "/health/dialysis-records", + axum::routing::post(dialysis_handler::create_dialysis_record), + ) + .route( + "/health/dialysis-records/{id}", + axum::routing::get(dialysis_handler::get_dialysis_record) + .put(dialysis_handler::update_dialysis_record) + .delete(dialysis_handler::delete_dialysis_record), + ) + .route( + "/health/dialysis-records/{id}/review", + axum::routing::put(dialysis_handler::review_dialysis_record), + ) + // 透析方案 + .route( + "/health/dialysis-prescriptions", + axum::routing::get(dialysis_prescription_handler::list_prescriptions) + .post(dialysis_prescription_handler::create_prescription), + ) + .route( + "/health/dialysis-prescriptions/{id}", + axum::routing::get(dialysis_prescription_handler::get_prescription) + .put(dialysis_prescription_handler::update_prescription) + .delete(dialysis_prescription_handler::delete_prescription), + ) + } +} + +#[async_trait] +impl ErpModule for DialysisModule { + fn name(&self) -> &str { + "透析管理" + } + + fn id(&self) -> &str { + "erp-dialysis" + } + + fn version(&self) -> &str { + "0.1.0" + } + + fn module_type(&self) -> ModuleType { + ModuleType::Builtin + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "health.health-data.list".into(), + name: "查看透析记录".into(), + description: "查看透析记录列表和详情".into(), + module: "erp-dialysis".into(), + }, + PermissionDescriptor { + code: "health.health-data.manage".into(), + name: "管理透析记录".into(), + description: "创建、编辑、审阅、删除透析记录".into(), + module: "erp-dialysis".into(), + }, + PermissionDescriptor { + code: "health.dialysis-prescription.list".into(), + name: "查看透析处方".into(), + description: "查看透析处方列表和详情".into(), + module: "erp-dialysis".into(), + }, + PermissionDescriptor { + code: "health.dialysis-prescription.manage".into(), + name: "管理透析处方".into(), + description: "创建、编辑、删除透析处方".into(), + module: "erp-dialysis".into(), + }, + ] + } + + async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { + Ok(()) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/erp-health/src/service/dialysis_prescription_service.rs b/crates/erp-dialysis/src/service/dialysis_prescription_service.rs similarity index 87% rename from crates/erp-health/src/service/dialysis_prescription_service.rs rename to crates/erp-dialysis/src/service/dialysis_prescription_service.rs index ee9b8d3..45c174c 100644 --- a/crates/erp-health/src/service/dialysis_prescription_service.rs +++ b/crates/erp-dialysis/src/service/dialysis_prescription_service.rs @@ -1,3 +1,5 @@ +//! 透析方案 Service — 透析处方 CRUD + use chrono::Utc; use num_traits::ToPrimitive; use sea_orm::entity::prelude::*; @@ -10,18 +12,18 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::dialysis_prescription_dto::*; -use crate::entity::{dialysis_prescription, patient}; -use crate::error::{HealthError, HealthResult}; -use crate::state::HealthState; +use crate::entity::dialysis_prescription; +use crate::error::{DialysisError, DialysisResult}; +use crate::state::DialysisState; pub async fn list_prescriptions( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, page: u64, page_size: u64, patient_id: Option, status: Option, -) -> HealthResult> { +) -> DialysisResult> { let limit = page_size.min(100); let offset = page.saturating_sub(1) * limit; @@ -51,35 +53,28 @@ pub async fn list_prescriptions( } pub async fn get_prescription( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, id: Uuid, -) -> HealthResult { +) -> DialysisResult { let m = dialysis_prescription::Entity::find() .filter(dialysis_prescription::Column::Id.eq(id)) .filter(dialysis_prescription::Column::TenantId.eq(tenant_id)) .filter(dialysis_prescription::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DialysisPrescriptionNotFound)?; + .ok_or(DialysisError::DialysisPrescriptionNotFound)?; Ok(model_to_resp(m)) } pub async fn create_prescription( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, operator_id: Option, req: CreateDialysisPrescriptionReq, -) -> 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)?; +) -> DialysisResult { + // 患者存在性由数据库 FK 约束保证,不再显式查询 patient 表 validate_anticoagulation_type(req.anticoagulation_type.as_deref())?; validate_vascular_access_type(req.vascular_access_type.as_deref())?; @@ -128,23 +123,23 @@ pub async fn create_prescription( } pub async fn update_prescription( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, id: Uuid, operator_id: Option, req: UpdateDialysisPrescriptionReq, expected_version: i32, -) -> HealthResult { +) -> DialysisResult { let model = dialysis_prescription::Entity::find() .filter(dialysis_prescription::Column::Id.eq(id)) .filter(dialysis_prescription::Column::TenantId.eq(tenant_id)) .filter(dialysis_prescription::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DialysisPrescriptionNotFound)?; + .ok_or(DialysisError::DialysisPrescriptionNotFound)?; let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; + .map_err(|_| DialysisError::VersionMismatch)?; if let Some(ref t) = req.anticoagulation_type { validate_anticoagulation_type(Some(t))?; } if let Some(ref t) = req.vascular_access_type { validate_vascular_access_type(Some(t))?; } @@ -185,22 +180,22 @@ pub async fn update_prescription( } pub async fn delete_prescription( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, id: Uuid, operator_id: Option, expected_version: i32, -) -> HealthResult<()> { +) -> DialysisResult<()> { let model = dialysis_prescription::Entity::find() .filter(dialysis_prescription::Column::Id.eq(id)) .filter(dialysis_prescription::Column::TenantId.eq(tenant_id)) .filter(dialysis_prescription::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DialysisPrescriptionNotFound)?; + .ok_or(DialysisError::DialysisPrescriptionNotFound)?; let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; + .map_err(|_| DialysisError::VersionMismatch)?; let mut active: dialysis_prescription::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); @@ -218,6 +213,10 @@ pub async fn delete_prescription( Ok(()) } +// --------------------------------------------------------------------------- +// 私有辅助函数 +// --------------------------------------------------------------------------- + fn model_to_resp(m: dialysis_prescription::Model) -> DialysisPrescriptionResp { DialysisPrescriptionResp { id: m.id, @@ -248,11 +247,11 @@ fn model_to_resp(m: dialysis_prescription::Model) -> DialysisPrescriptionResp { } } -fn validate_anticoagulation_type(val: Option<&str>) -> HealthResult<()> { +fn validate_anticoagulation_type(val: Option<&str>) -> DialysisResult<()> { if let Some(t) = val { let valid = ["heparin", "lmwh", "heparin_free"]; if !valid.contains(&t) { - return Err(HealthError::Validation(format!( + return Err(DialysisError::Validation(format!( "anticoagulation_type 必须为: {}", valid.join(", ") ))); } @@ -260,11 +259,11 @@ fn validate_anticoagulation_type(val: Option<&str>) -> HealthResult<()> { Ok(()) } -fn validate_vascular_access_type(val: Option<&str>) -> HealthResult<()> { +fn validate_vascular_access_type(val: Option<&str>) -> DialysisResult<()> { if let Some(t) = val { let valid = ["avf", "avg", "cvc"]; if !valid.contains(&t) { - return Err(HealthError::Validation(format!( + return Err(DialysisError::Validation(format!( "vascular_access_type 必须为: {}", valid.join(", ") ))); } diff --git a/crates/erp-health/src/service/dialysis_service.rs b/crates/erp-dialysis/src/service/dialysis_service.rs similarity index 76% rename from crates/erp-health/src/service/dialysis_service.rs rename to crates/erp-dialysis/src/service/dialysis_service.rs index 4bda989..b80aa3e 100644 --- a/crates/erp-health/src/service/dialysis_service.rs +++ b/crates/erp-dialysis/src/service/dialysis_service.rs @@ -13,18 +13,17 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::dialysis_dto::*; -use crate::entity::{dialysis_record, patient}; -use crate::error::{HealthError, HealthResult}; -use crate::service::validation; -use crate::state::HealthState; +use crate::entity::dialysis_record; +use crate::error::{DialysisError, DialysisResult}; +use crate::state::DialysisState; pub async fn list_dialysis_records( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, patient_id: Uuid, page: u64, page_size: u64, -) -> HealthResult> { +) -> DialysisResult> { let limit = page_size.min(100); let offset = page.saturating_sub(1) * limit; @@ -49,34 +48,28 @@ pub async fn list_dialysis_records( } pub async fn get_dialysis_record( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, record_id: Uuid, -) -> HealthResult { +) -> DialysisResult { let m = dialysis_record::Entity::find() .filter(dialysis_record::Column::Id.eq(record_id)) .filter(dialysis_record::Column::TenantId.eq(tenant_id)) .filter(dialysis_record::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DialysisRecordNotFound)?; + .ok_or(DialysisError::DialysisRecordNotFound)?; Ok(to_resp(&state.crypto, m)) } pub async fn create_dialysis_record( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, operator_id: Option, req: CreateDialysisRecordReq, -) -> 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)?; +) -> DialysisResult { + // 患者存在性由数据库 FK 约束保证,不再显式查询 patient 表 validate_dialysis_type(&req.dialysis_type)?; @@ -84,9 +77,9 @@ pub async fn create_dialysis_record( // PII 加密 let encrypted_symptoms = req.symptoms.as_ref() - .map(|v| -> HealthResult { + .map(|v| -> DialysisResult { let json_str = serde_json::to_string(v) - .map_err(|e| HealthError::Validation(e.to_string()))?; + .map_err(|e| DialysisError::Validation(e.to_string()))?; Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?)) }) .transpose()?; @@ -141,23 +134,23 @@ pub async fn create_dialysis_record( } pub async fn update_dialysis_record( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, record_id: Uuid, operator_id: Option, req: UpdateDialysisRecordReq, expected_version: i32, -) -> HealthResult { +) -> DialysisResult { let model = dialysis_record::Entity::find() .filter(dialysis_record::Column::Id.eq(record_id)) .filter(dialysis_record::Column::TenantId.eq(tenant_id)) .filter(dialysis_record::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DialysisRecordNotFound)?; + .ok_or(DialysisError::DialysisRecordNotFound)?; let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; + .map_err(|_| DialysisError::VersionMismatch)?; let mut active: dialysis_record::ActiveModel = model.into(); if let Some(v) = req.dialysis_date { active.dialysis_date = Set(v); } @@ -205,24 +198,24 @@ pub async fn update_dialysis_record( } pub async fn review_dialysis_record( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, record_id: Uuid, reviewer_id: Uuid, expected_version: i32, -) -> HealthResult { +) -> DialysisResult { let model = dialysis_record::Entity::find() .filter(dialysis_record::Column::Id.eq(record_id)) .filter(dialysis_record::Column::TenantId.eq(tenant_id)) .filter(dialysis_record::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DialysisRecordNotFound)?; + .ok_or(DialysisError::DialysisRecordNotFound)?; let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; + .map_err(|_| DialysisError::VersionMismatch)?; - validation::validate_dialysis_status_transition(&model.status, "reviewed")?; + validate_dialysis_status_transition(&model.status, "reviewed")?; let mut active: dialysis_record::ActiveModel = model.into(); active.status = Set("reviewed".to_string()); @@ -244,22 +237,22 @@ pub async fn review_dialysis_record( } pub async fn delete_dialysis_record( - state: &HealthState, + state: &DialysisState, tenant_id: Uuid, record_id: Uuid, operator_id: Option, expected_version: i32, -) -> HealthResult<()> { +) -> DialysisResult<()> { let model = dialysis_record::Entity::find() .filter(dialysis_record::Column::Id.eq(record_id)) .filter(dialysis_record::Column::TenantId.eq(tenant_id)) .filter(dialysis_record::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DialysisRecordNotFound)?; + .ok_or(DialysisError::DialysisRecordNotFound)?; let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; + .map_err(|_| DialysisError::VersionMismatch)?; let mut active: dialysis_record::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); @@ -277,15 +270,40 @@ pub async fn delete_dialysis_record( Ok(()) } -fn validate_dialysis_type(dialysis_type: &str) -> HealthResult<()> { +// --------------------------------------------------------------------------- +// 私有辅助函数 +// --------------------------------------------------------------------------- + +/// 校验透析类型枚举 +fn validate_dialysis_type(dialysis_type: &str) -> DialysisResult<()> { match dialysis_type { "HD" | "HDF" | "HF" => Ok(()), - _ => Err(HealthError::Validation(format!( + _ => Err(DialysisError::Validation(format!( "无效的透析类型: {},允许值: HD, HDF, HF", dialysis_type ))), } } +/// 校验透析记录状态转换 +/// draft -> completed -> reviewed +fn validate_dialysis_status_transition(current: &str, new: &str) -> DialysisResult<()> { + if current == new { + return Ok(()); + } + let allowed = match current { + "draft" => matches!(new, "completed"), + "completed" => matches!(new, "reviewed"), + _ => false, + }; + if allowed { + Ok(()) + } else { + Err(DialysisError::InvalidStatusTransition(format!( + "dialysis_record.status: 不允许从 '{}' 转换到 '{}'", current, new + ))) + } +} + fn to_resp(crypto: &erp_core::crypto::PiiCrypto, m: dialysis_record::Model) -> DialysisRecordResp { let kek = crypto.kek(); @@ -330,3 +348,32 @@ fn to_resp(crypto: &erp_core::crypto::PiiCrypto, m: dialysis_record::Model) -> D version: m.version, } } + +#[cfg(test)] +mod tests { + use super::*; + + // --- validate_dialysis_type --- + #[test] + fn dialysis_type_hd() { assert!(validate_dialysis_type("HD").is_ok()); } + #[test] + fn dialysis_type_hdf() { assert!(validate_dialysis_type("HDF").is_ok()); } + #[test] + fn dialysis_type_hf() { assert!(validate_dialysis_type("HF").is_ok()); } + #[test] + fn dialysis_type_invalid() { assert!(validate_dialysis_type("PD").is_err()); } + + // --- validate_dialysis_status_transition --- + #[test] + fn dial_draft_to_completed() { assert!(validate_dialysis_status_transition("draft", "completed").is_ok()); } + #[test] + fn dial_draft_to_reviewed_fails() { assert!(validate_dialysis_status_transition("draft", "reviewed").is_err()); } + #[test] + fn dial_completed_to_reviewed() { assert!(validate_dialysis_status_transition("completed", "reviewed").is_ok()); } + #[test] + fn dial_completed_to_draft_fails() { assert!(validate_dialysis_status_transition("completed", "draft").is_err()); } + #[test] + fn dial_reviewed_to_any_fails() { assert!(validate_dialysis_status_transition("reviewed", "draft").is_err()); } + #[test] + fn dial_same_status_ok() { assert!(validate_dialysis_status_transition("draft", "draft").is_ok()); } +} diff --git a/crates/erp-dialysis/src/service/mod.rs b/crates/erp-dialysis/src/service/mod.rs new file mode 100644 index 0000000..a0a12eb --- /dev/null +++ b/crates/erp-dialysis/src/service/mod.rs @@ -0,0 +1,2 @@ +pub mod dialysis_service; +pub mod dialysis_prescription_service; diff --git a/crates/erp-dialysis/src/state.rs b/crates/erp-dialysis/src/state.rs new file mode 100644 index 0000000..41e8834 --- /dev/null +++ b/crates/erp-dialysis/src/state.rs @@ -0,0 +1,10 @@ +use erp_core::crypto::PiiCrypto; +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; + +#[derive(Clone)] +pub struct DialysisState { + pub db: DatabaseConnection, + pub event_bus: EventBus, + pub crypto: PiiCrypto, +} diff --git a/crates/erp-health/src/dto/health_data_dto.rs b/crates/erp-health/src/dto/health_data_dto.rs index 09d9981..dfafa49 100644 --- a/crates/erp-health/src/dto/health_data_dto.rs +++ b/crates/erp-health/src/dto/health_data_dto.rs @@ -263,3 +263,15 @@ pub struct MiniTodayResp { pub blood_sugar: Option, pub weight: Option, } + +#[derive(Debug, Clone, serde::Deserialize, ToSchema)] +pub struct ReviewLabReportReq { + pub doctor_notes: Option, + pub items: Option, +} + +impl ReviewLabReportReq { + pub fn sanitize(&mut self) { + self.doctor_notes = sanitize_option(self.doctor_notes.take()); + } +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 9b1fc3a..c7f25fe 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -6,8 +6,6 @@ pub mod consultation_dto; pub mod daily_monitoring_dto; pub mod diagnosis_dto; pub mod medication_record_dto; -pub mod dialysis_dto; -pub mod dialysis_prescription_dto; pub mod doctor_dto; pub mod follow_up_dto; pub mod follow_up_template_dto; diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index 93def26..c9466f5 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -16,8 +16,6 @@ pub mod critical_alert_response; pub mod daily_monitoring; pub mod device_readings; pub mod diagnosis; -pub mod dialysis_prescription; -pub mod dialysis_record; pub mod doctor_profile; pub mod doctor_schedule; pub mod follow_up_record; diff --git a/crates/erp-health/src/handler/health_data_handler.rs b/crates/erp-health/src/handler/health_data_handler.rs index beb7884..411b0fc 100644 --- a/crates/erp-health/src/handler/health_data_handler.rs +++ b/crates/erp-health/src/handler/health_data_handler.rs @@ -9,7 +9,6 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::dto::health_data_dto::*; -use crate::dto::dialysis_dto::ReviewLabReportReq; use crate::dto::DeleteWithVersion; use crate::service::health_data_service; use crate::service::trend_service; diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index f30949c..26f2b8e 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -12,8 +12,6 @@ pub mod daily_monitoring_handler; pub mod device_reading_handler; pub mod diagnosis_handler; pub mod medication_record_handler; -pub mod dialysis_handler; -pub mod dialysis_prescription_handler; pub mod doctor_handler; pub mod follow_up_handler; pub mod follow_up_template_handler; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index dc5ec51..576a6e5 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_alert_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, + 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_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, health_data_handler, medication_record_handler, patient_handler, points_handler, stats_handler, }; @@ -225,37 +225,6 @@ impl HealthModule { "/health/vital-signs/today", axum::routing::get(health_data_handler::get_mini_today), ) - // 透析记录(血透专科) - .route( - "/health/patients/{id}/dialysis-records", - axum::routing::get(dialysis_handler::list_dialysis_records), - ) - .route( - "/health/dialysis-records", - axum::routing::post(dialysis_handler::create_dialysis_record), - ) - .route( - "/health/dialysis-records/{id}", - axum::routing::get(dialysis_handler::get_dialysis_record) - .put(dialysis_handler::update_dialysis_record) - .delete(dialysis_handler::delete_dialysis_record), - ) - .route( - "/health/dialysis-records/{id}/review", - axum::routing::put(dialysis_handler::review_dialysis_record), - ) - // 透析方案 - .route( - "/health/dialysis-prescriptions", - axum::routing::get(dialysis_prescription_handler::list_prescriptions) - .post(dialysis_prescription_handler::create_prescription), - ) - .route( - "/health/dialysis-prescriptions/{id}", - axum::routing::get(dialysis_prescription_handler::get_prescription) - .put(dialysis_prescription_handler::update_prescription) - .delete(dialysis_prescription_handler::delete_prescription), - ) // 随访模板 .route( "/health/follow-up-templates", diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index c484b5d..4c3f579 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -581,16 +581,18 @@ pub async fn enrich_doctor_dashboard_health( doctor_user_id: Uuid, dashboard: &mut DoctorDashboard, ) -> HealthResult<()> { - use crate::entity::{dialysis_record, lab_report, appointment}; + use crate::entity::{lab_report, appointment}; + use sea_orm::{FromQueryResult, Statement, DatabaseBackend}; - // 待审核透析记录(doctor_id 通过患者关联,这里取全租户待审核) - let pending_dialysis = dialysis_record::Entity::find() - .filter(dialysis_record::Column::TenantId.eq(tenant_id)) - .filter(dialysis_record::Column::DeletedAt.is_null()) - .filter(dialysis_record::Column::Status.eq("draft")) - .count(&state.db) - .await?; - dashboard.pending_dialysis_review = pending_dialysis as i64; + // 待审核透析记录(raw SQL — entity 已拆分到 erp-dialysis crate) + #[derive(FromQueryResult)] + struct DialysisCount { count: i64 } + let pending_dialysis = DialysisCount::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'draft'", + [tenant_id.into()], + )).one(&state.db).await?.map(|r| r.count).unwrap_or(0); + dashboard.pending_dialysis_review = pending_dialysis; // 待审核化验报告 let pending_lab = lab_report::Entity::find() diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index 853e0fc..5255d63 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -563,7 +563,7 @@ pub async fn review_lab_report( patient_id: Uuid, report_id: Uuid, reviewer_id: Uuid, - req: crate::dto::dialysis_dto::ReviewLabReportReq, + req: crate::dto::health_data_dto::ReviewLabReportReq, expected_version: i32, ) -> HealthResult { let model = lab_report::Entity::find() diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index b2d8da6..318c102 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -13,8 +13,6 @@ pub mod daily_monitoring_service; pub mod device_reading_service; pub mod diagnosis_service; pub mod medication_record_service; -pub mod dialysis_prescription_service; -pub mod dialysis_service; pub mod doctor_service; pub mod follow_up_service; pub mod follow_up_template_service; diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs index 1e199ba..f1444a4 100644 --- a/crates/erp-health/src/service/stats_service.rs +++ b/crates/erp-health/src/service/stats_service.rs @@ -1,11 +1,11 @@ -use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult}; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, Statement, DatabaseBackend}; use erp_core::error::AppResult; use crate::dto::stats_dto::*; use crate::entity::{ patient, consultation_session, follow_up_task, - points_transaction, dialysis_record, lab_report, + points_transaction, lab_report, appointment, vital_signs, patient_doctor_relation, doctor_profile, }; use crate::state::HealthState; @@ -190,25 +190,27 @@ pub async fn get_dialysis_statistics( ) -> AppResult { let db = &state.db; - let total_records = dialysis_record::Entity::find() - .filter(dialysis_record::Column::TenantId.eq(tenant_id)) - .filter(dialysis_record::Column::DeletedAt.is_null()) - .count(db) - .await?; + // 使用 raw SQL 替代 dialysis_record entity(已拆分到 erp-dialysis crate) + #[derive(FromQueryResult)] + struct CountRow { count: i64 } - let this_month = dialysis_record::Entity::find() - .filter(dialysis_record::Column::TenantId.eq(tenant_id)) - .filter(dialysis_record::Column::DeletedAt.is_null()) - .filter(Expr::col(dialysis_record::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await?; + let total_records = CountRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL", + [tenant_id.into()], + )).one(db).await?.map(|r| r.count).unwrap_or(0); - let pending_review = dialysis_record::Entity::find() - .filter(dialysis_record::Column::TenantId.eq(tenant_id)) - .filter(dialysis_record::Column::DeletedAt.is_null()) - .filter(dialysis_record::Column::Status.eq("draft")) - .count(db) - .await?; + let this_month = CountRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND created_at >= date_trunc('month', NOW())", + [tenant_id.into()], + )).one(db).await?.map(|r| r.count).unwrap_or(0); + + let pending_review = CountRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*)::int8 AS count FROM dialysis_record WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'draft'", + [tenant_id.into()], + )).one(db).await?.map(|r| r.count).unwrap_or(0); let type_distribution = count_by_field( db, tenant_id, @@ -223,13 +225,13 @@ pub async fn get_dialysis_statistics( let avg_duration = compute_avg_field(db, tenant_id, "dialysis_duration").await?; Ok(DialysisStatisticsResp { - total_records: total_records as i64, - this_month: this_month as i64, + total_records, + this_month, type_distribution, complication_rate, avg_ultrafiltration, avg_duration, - pending_review: pending_review as i64, + pending_review, }) }