refactor(dialysis): 透析模块拆分为独立 erp-dialysis crate
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 创建 erp-dialysis crate(DialysisState + DialysisError + DialysisModule)
- 迁移 2 Entity + 2 Service + 2 Handler + 2 DTO 共 8 个文件
- Entity 移除跨 crate patient Relation(FK 列保留)
- Service 内联 validation 逻辑,移除 patient 存在性检查(FK 约束保证)
- erp-health 的 stats/consultation 中 dialysis 查询改为 raw SQL
- ReviewLabReportReq 从 dialysis_dto 移至 health_data_dto(正确归属)
- workspace 全量编译通过
This commit is contained in:
iven
2026-04-28 12:37:23 +08:00
parent e00c2abdcd
commit fa9278590d
30 changed files with 441 additions and 190 deletions

20
Cargo.lock generated
View File

@@ -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"

View File

@@ -18,6 +18,7 @@ members = [
"crates/erp-health",
"crates/erp-ai",
"crates/erp-plugin-assessment",
"crates/erp-dialysis",
]
[workspace.package]

View File

@@ -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"

View File

@@ -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,
}

View File

@@ -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<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<super::patient::Entity> for Entity {
fn to() -> RelationDef {
Relation::Patient.def()
}
}
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,2 @@
pub mod dialysis_prescription;
pub mod dialysis_record;

View File

@@ -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<DialysisError> 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<sea_orm::DbErr> for DialysisError {
fn from(err: sea_orm::DbErr) -> Self {
DialysisError::DbError(err.to_string())
}
}
impl From<AppError> for DialysisError {
fn from(err: AppError) -> Self {
DialysisError::Validation(err.to_string())
}
}
impl From<String> for DialysisError {
fn from(err: String) -> Self {
DialysisError::Validation(err)
}
}
pub type DialysisResult<T> = Result<T, DialysisError>;

View File

@@ -0,0 +1,6 @@
use erp_core::events::EventBus;
/// 预留事件处理器注册
pub fn register_handlers_with_state(_state: crate::state::DialysisState) {
// 透析模块事件消费者待后续迭代
}

View File

@@ -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<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(patient_id): Path<Uuid>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<DialysisRecordResp>>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
@@ -52,12 +52,12 @@ where
}
pub async fn get_dialysis_record<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
@@ -69,12 +69,12 @@ where
}
pub async fn create_dialysis_record<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateDialysisRecordReq>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
@@ -88,13 +88,13 @@ where
}
pub async fn update_dialysis_record<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
Json(req): Json<UpdateDialysisWithVersion>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
@@ -108,13 +108,13 @@ where
}
pub async fn review_dialysis_record<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
Json(req): Json<ReviewDialysisWithVersion>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
@@ -126,13 +126,13 @@ where
}
pub async fn delete_dialysis_record<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;

View File

@@ -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<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<DialysisPrescriptionListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<DialysisPrescriptionResp>>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.list")?;
@@ -48,12 +48,12 @@ where
}
pub async fn get_prescription<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<DialysisPrescriptionResp>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.list")?;
@@ -62,12 +62,12 @@ where
}
pub async fn create_prescription<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateDialysisPrescriptionReq>,
) -> Result<Json<ApiResponse<DialysisPrescriptionResp>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.manage")?;
@@ -81,13 +81,13 @@ where
}
pub async fn update_prescription<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateDialysisPrescriptionWithVersion>,
) -> Result<Json<ApiResponse<DialysisPrescriptionResp>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.manage")?;
@@ -101,13 +101,13 @@ where
}
pub async fn delete_prescription<S>(
State(state): State<HealthState>,
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.manage")?;

View File

@@ -0,0 +1,2 @@
pub mod dialysis_handler;
pub mod dialysis_prescription_handler;

View File

@@ -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;

View File

@@ -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<S>() -> Router<S>
where
DialysisState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
}
pub fn protected_routes<S>() -> Router<S>
where
DialysisState: axum::extract::FromRef<S>,
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<PermissionDescriptor> {
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
}
}

View File

@@ -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<Uuid>,
status: Option<String>,
) -> HealthResult<PaginatedResponse<DialysisPrescriptionResp>> {
) -> DialysisResult<PaginatedResponse<DialysisPrescriptionResp>> {
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<DialysisPrescriptionResp> {
) -> DialysisResult<DialysisPrescriptionResp> {
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<Uuid>,
req: CreateDialysisPrescriptionReq,
) -> HealthResult<DialysisPrescriptionResp> {
// 校验患者存在
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<DialysisPrescriptionResp> {
// 患者存在性由数据库 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<Uuid>,
req: UpdateDialysisPrescriptionReq,
expected_version: i32,
) -> HealthResult<DialysisPrescriptionResp> {
) -> DialysisResult<DialysisPrescriptionResp> {
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<Uuid>,
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(", ")
)));
}

View File

@@ -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<PaginatedResponse<DialysisRecordResp>> {
) -> DialysisResult<PaginatedResponse<DialysisRecordResp>> {
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<DialysisRecordResp> {
) -> DialysisResult<DialysisRecordResp> {
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<Uuid>,
req: CreateDialysisRecordReq,
) -> HealthResult<DialysisRecordResp> {
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<DialysisRecordResp> {
// 患者存在性由数据库 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<serde_json::Value> {
.map(|v| -> DialysisResult<serde_json::Value> {
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<Uuid>,
req: UpdateDialysisRecordReq,
expected_version: i32,
) -> HealthResult<DialysisRecordResp> {
) -> DialysisResult<DialysisRecordResp> {
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<DialysisRecordResp> {
) -> DialysisResult<DialysisRecordResp> {
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<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)?;
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()); }
}

View File

@@ -0,0 +1,2 @@
pub mod dialysis_service;
pub mod dialysis_prescription_service;

View File

@@ -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,
}

View File

@@ -263,3 +263,15 @@ pub struct MiniTodayResp {
pub blood_sugar: Option<IndicatorSummary>,
pub weight: Option<IndicatorSummary>,
}
#[derive(Debug, Clone, serde::Deserialize, ToSchema)]
pub struct ReviewLabReportReq {
pub doctor_notes: Option<String>,
pub items: Option<serde_json::Value>,
}
impl ReviewLabReportReq {
pub fn sanitize(&mut self) {
self.doctor_notes = sanitize_option(self.doctor_notes.take());
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -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()

View File

@@ -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<LabReportResp> {
let model = lab_report::Entity::find()

View File

@@ -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;

View File

@@ -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<DialysisStatisticsResp> {
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,
})
}