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

View File

@@ -0,0 +1,124 @@
use chrono::NaiveDate;
use chrono::NaiveTime;
use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
type Decimal = f64;
// ---------------------------------------------------------------------------
// 透析记录
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateDialysisRecordReq {
pub patient_id: Uuid,
pub dialysis_date: NaiveDate,
pub start_time: Option<NaiveTime>,
pub end_time: Option<NaiveTime>,
pub dry_weight: Option<Decimal>,
pub pre_weight: Option<Decimal>,
pub post_weight: Option<Decimal>,
pub pre_bp_systolic: Option<i32>,
pub pre_bp_diastolic: Option<i32>,
pub post_bp_systolic: Option<i32>,
pub post_bp_diastolic: Option<i32>,
pub pre_heart_rate: Option<i32>,
pub post_heart_rate: Option<i32>,
pub ultrafiltration_volume: Option<i32>,
pub dialysis_duration: Option<i32>,
pub blood_flow_rate: Option<i32>,
/// HD / HDF / HF
#[serde(default = "default_dialysis_type")]
pub dialysis_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub symptoms: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub complication_notes: Option<String>,
}
fn default_dialysis_type() -> String {
"HD".to_string()
}
impl CreateDialysisRecordReq {
pub fn sanitize(&mut self) {
self.complication_notes = sanitize_option(self.complication_notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateDialysisRecordReq {
pub dialysis_date: Option<NaiveDate>,
pub start_time: Option<NaiveTime>,
pub end_time: Option<NaiveTime>,
pub dry_weight: Option<Decimal>,
pub pre_weight: Option<Decimal>,
pub post_weight: Option<Decimal>,
pub pre_bp_systolic: Option<i32>,
pub pre_bp_diastolic: Option<i32>,
pub post_bp_systolic: Option<i32>,
pub post_bp_diastolic: Option<i32>,
pub pre_heart_rate: Option<i32>,
pub post_heart_rate: Option<i32>,
pub ultrafiltration_volume: Option<i32>,
pub dialysis_duration: Option<i32>,
pub blood_flow_rate: Option<i32>,
pub dialysis_type: Option<String>,
pub symptoms: Option<serde_json::Value>,
pub complication_notes: Option<String>,
}
impl UpdateDialysisRecordReq {
pub fn sanitize(&mut self) {
self.complication_notes = sanitize_option(self.complication_notes.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DialysisRecordResp {
pub id: Uuid,
pub patient_id: Uuid,
pub dialysis_date: NaiveDate,
pub start_time: Option<NaiveTime>,
pub end_time: Option<NaiveTime>,
pub dry_weight: Option<Decimal>,
pub pre_weight: Option<Decimal>,
pub post_weight: Option<Decimal>,
pub pre_bp_systolic: Option<i32>,
pub pre_bp_diastolic: Option<i32>,
pub post_bp_systolic: Option<i32>,
pub post_bp_diastolic: Option<i32>,
pub pre_heart_rate: Option<i32>,
pub post_heart_rate: Option<i32>,
pub ultrafiltration_volume: Option<i32>,
pub dialysis_duration: Option<i32>,
pub blood_flow_rate: Option<i32>,
pub dialysis_type: String,
pub symptoms: Option<serde_json::Value>,
pub complication_notes: Option<String>,
pub status: String,
pub reviewed_by: Option<Uuid>,
pub reviewed_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
// ---------------------------------------------------------------------------
// 化验报告审阅
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReviewLabReportReq {
pub doctor_notes: Option<String>,
/// 审阅时可选覆盖 items补充标注 is_abnormal
pub items: Option<serde_json::Value>,
}
impl ReviewLabReportReq {
pub fn sanitize(&mut self) {
self.doctor_notes = sanitize_option(self.doctor_notes.take());
}
}

View File

@@ -0,0 +1,106 @@
use chrono::NaiveDate;
use erp_core::sanitize::sanitize_option;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
#[derive(Debug, Clone, Deserialize, IntoParams)]
pub struct DialysisPrescriptionListQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Option<Uuid>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateDialysisPrescriptionReq {
pub patient_id: Uuid,
pub dialyzer_model: Option<String>,
pub membrane_area: Option<f64>,
pub dialysate_potassium: Option<f64>,
pub dialysate_calcium: Option<f64>,
pub dialysate_bicarbonate: Option<f64>,
pub anticoagulation_type: Option<String>,
pub anticoagulation_dose: Option<String>,
pub target_ultrafiltration_ml: Option<i32>,
pub target_dry_weight: Option<f64>,
pub blood_flow_rate: Option<i32>,
pub dialysate_flow_rate: Option<i32>,
pub frequency_per_week: Option<i32>,
pub duration_minutes: Option<i32>,
pub vascular_access_type: Option<String>,
pub vascular_access_location: Option<String>,
pub effective_from: Option<NaiveDate>,
pub effective_to: Option<NaiveDate>,
pub notes: Option<String>,
}
impl CreateDialysisPrescriptionReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
self.dialyzer_model = sanitize_option(self.dialyzer_model.take());
self.anticoagulation_dose = sanitize_option(self.anticoagulation_dose.take());
self.vascular_access_location = sanitize_option(self.vascular_access_location.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateDialysisPrescriptionReq {
pub dialyzer_model: Option<String>,
pub membrane_area: Option<f64>,
pub dialysate_potassium: Option<f64>,
pub dialysate_calcium: Option<f64>,
pub dialysate_bicarbonate: Option<f64>,
pub anticoagulation_type: Option<String>,
pub anticoagulation_dose: Option<String>,
pub target_ultrafiltration_ml: Option<i32>,
pub target_dry_weight: Option<f64>,
pub blood_flow_rate: Option<i32>,
pub dialysate_flow_rate: Option<i32>,
pub frequency_per_week: Option<i32>,
pub duration_minutes: Option<i32>,
pub vascular_access_type: Option<String>,
pub vascular_access_location: Option<String>,
pub effective_from: Option<NaiveDate>,
pub effective_to: Option<NaiveDate>,
pub status: Option<String>,
pub notes: Option<String>,
}
impl UpdateDialysisPrescriptionReq {
pub fn sanitize(&mut self) {
self.notes = sanitize_option(self.notes.take());
self.dialyzer_model = sanitize_option(self.dialyzer_model.take());
self.anticoagulation_dose = sanitize_option(self.anticoagulation_dose.take());
self.vascular_access_location = sanitize_option(self.vascular_access_location.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DialysisPrescriptionResp {
pub id: Uuid,
pub patient_id: Uuid,
pub dialyzer_model: Option<String>,
pub membrane_area: Option<f64>,
pub dialysate_potassium: Option<f64>,
pub dialysate_calcium: Option<f64>,
pub dialysate_bicarbonate: Option<f64>,
pub anticoagulation_type: Option<String>,
pub anticoagulation_dose: Option<String>,
pub target_ultrafiltration_ml: Option<i32>,
pub target_dry_weight: Option<f64>,
pub blood_flow_rate: Option<i32>,
pub dialysate_flow_rate: Option<i32>,
pub frequency_per_week: Option<i32>,
pub duration_minutes: Option<i32>,
pub vascular_access_type: Option<String>,
pub vascular_access_location: Option<String>,
pub effective_from: Option<NaiveDate>,
pub effective_to: Option<NaiveDate>,
pub status: String,
pub prescribed_by: Option<Uuid>,
pub notes: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}

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

@@ -0,0 +1,64 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "dialysis_prescription")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub dialyzer_model: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub membrane_area: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub dialysate_potassium: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub dialysate_calcium: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub dialysate_bicarbonate: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub anticoagulation_type: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub anticoagulation_dose: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub target_ultrafiltration_ml: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub target_dry_weight: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub blood_flow_rate: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub dialysate_flow_rate: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub frequency_per_week: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub duration_minutes: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub vascular_access_type: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub vascular_access_location: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub effective_from: Option<chrono::NaiveDate>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub effective_to: Option<chrono::NaiveDate>,
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub prescribed_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,68 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "dialysis_record")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub patient_id: Uuid,
pub dialysis_date: chrono::NaiveDate,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub start_time: Option<chrono::NaiveTime>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub end_time: Option<chrono::NaiveTime>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub dry_weight: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub pre_weight: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub post_weight: Option<Decimal>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub pre_bp_systolic: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub pre_bp_diastolic: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub post_bp_systolic: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub post_bp_diastolic: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub pre_heart_rate: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub post_heart_rate: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub ultrafiltration_volume: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub dialysis_duration: Option<i32>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub blood_flow_rate: Option<i32>,
/// HD / HDF / HF
pub dialysis_type: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub symptoms: Option<serde_json::Value>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub complication_notes: Option<String>,
/// draft / completed / reviewed
pub status: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub reviewed_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub reviewed_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub key_version: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
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

@@ -0,0 +1,144 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::dialysis_dto::*;
use crate::dto::DeleteWithVersion;
use crate::service::dialysis_service;
use crate::state::DialysisState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PaginationParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateDialysisWithVersion {
#[serde(flatten)]
pub data: UpdateDialysisRecordReq,
pub version: i32,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct ReviewDialysisWithVersion {
pub version: i32,
}
pub async fn list_dialysis_records<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(patient_id): Path<Uuid>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<DialysisRecordResp>>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = dialysis_service::list_dialysis_records(
&state, ctx.tenant_id, patient_id, page, page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_dialysis_record<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let result = dialysis_service::get_dialysis_record(
&state, ctx.tenant_id, record_id,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_dialysis_record<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateDialysisRecordReq>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let mut req = req;
req.sanitize();
let result = dialysis_service::create_dialysis_record(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_dialysis_record<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
Json(req): Json<UpdateDialysisWithVersion>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let mut data = req.data;
data.sanitize();
let result = dialysis_service::update_dialysis_record(
&state, ctx.tenant_id, record_id, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn review_dialysis_record<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
Json(req): Json<ReviewDialysisWithVersion>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = dialysis_service::review_dialysis_record(
&state, ctx.tenant_id, record_id, ctx.user_id, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn delete_dialysis_record<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
dialysis_service::delete_dialysis_record(
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
)
.await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -0,0 +1,119 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::dialysis_prescription_dto::*;
use crate::dto::DeleteWithVersion;
use crate::service::dialysis_prescription_service;
use crate::state::DialysisState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct DialysisPrescriptionListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub patient_id: Option<Uuid>,
pub status: Option<String>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateDialysisPrescriptionWithVersion {
#[serde(flatten)]
pub data: UpdateDialysisPrescriptionReq,
pub version: i32,
}
pub async fn list_prescriptions<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<DialysisPrescriptionListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<DialysisPrescriptionResp>>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = dialysis_prescription_service::list_prescriptions(
&state, ctx.tenant_id, page, page_size, params.patient_id, params.status,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_prescription<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<DialysisPrescriptionResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.list")?;
let result = dialysis_prescription_service::get_prescription(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_prescription<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateDialysisPrescriptionReq>,
) -> Result<Json<ApiResponse<DialysisPrescriptionResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.manage")?;
let mut req = req;
req.sanitize();
let result = dialysis_prescription_service::create_prescription(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_prescription<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateDialysisPrescriptionWithVersion>,
) -> Result<Json<ApiResponse<DialysisPrescriptionResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.manage")?;
let mut data = req.data;
data.sanitize();
let result = dialysis_prescription_service::update_prescription(
&state, ctx.tenant_id, id, Some(ctx.user_id), data, req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn delete_prescription<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<DeleteWithVersion>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis-prescription.manage")?;
dialysis_prescription_service::delete_prescription(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
)
.await?;
Ok(Json(ApiResponse::ok(())))
}

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

@@ -0,0 +1,272 @@
//! 透析方案 Service — 透析处方 CRUD
use chrono::Utc;
use num_traits::ToPrimitive;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::dialysis_prescription_dto::*;
use crate::entity::dialysis_prescription;
use crate::error::{DialysisError, DialysisResult};
use crate::state::DialysisState;
pub async fn list_prescriptions(
state: &DialysisState,
tenant_id: Uuid,
page: u64,
page_size: u64,
patient_id: Option<Uuid>,
status: Option<String>,
) -> DialysisResult<PaginatedResponse<DialysisPrescriptionResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = dialysis_prescription::Entity::find()
.filter(dialysis_prescription::Column::TenantId.eq(tenant_id))
.filter(dialysis_prescription::Column::DeletedAt.is_null());
if let Some(pid) = patient_id {
query = query.filter(dialysis_prescription::Column::PatientId.eq(pid));
}
if let Some(ref s) = status {
query = query.filter(dialysis_prescription::Column::Status.eq(s));
}
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(dialysis_prescription::Column::CreatedAt)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(model_to_resp).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn get_prescription(
state: &DialysisState,
tenant_id: Uuid,
id: Uuid,
) -> 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(DialysisError::DialysisPrescriptionNotFound)?;
Ok(model_to_resp(m))
}
pub async fn create_prescription(
state: &DialysisState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateDialysisPrescriptionReq,
) -> DialysisResult<DialysisPrescriptionResp> {
// 患者存在性由数据库 FK 约束保证,不再显式查询 patient 表
validate_anticoagulation_type(req.anticoagulation_type.as_deref())?;
validate_vascular_access_type(req.vascular_access_type.as_deref())?;
let now = Utc::now();
let active = dialysis_prescription::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
dialyzer_model: Set(req.dialyzer_model),
membrane_area: Set(req.membrane_area.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
dialysate_potassium: Set(req.dialysate_potassium.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
dialysate_calcium: Set(req.dialysate_calcium.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
dialysate_bicarbonate: Set(req.dialysate_bicarbonate.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
anticoagulation_type: Set(req.anticoagulation_type),
anticoagulation_dose: Set(req.anticoagulation_dose),
target_ultrafiltration_ml: Set(req.target_ultrafiltration_ml),
target_dry_weight: Set(req.target_dry_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
blood_flow_rate: Set(req.blood_flow_rate),
dialysate_flow_rate: Set(req.dialysate_flow_rate),
frequency_per_week: Set(req.frequency_per_week),
duration_minutes: Set(req.duration_minutes),
vascular_access_type: Set(req.vascular_access_type),
vascular_access_location: Set(req.vascular_access_location),
effective_from: Set(req.effective_from),
effective_to: Set(req.effective_to),
status: Set("active".to_string()),
prescribed_by: Set(operator_id),
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, "dialysis_prescription.created", "dialysis_prescription")
.with_resource_id(m.id),
&state.db,
).await;
Ok(model_to_resp(m))
}
pub async fn update_prescription(
state: &DialysisState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateDialysisPrescriptionReq,
expected_version: i32,
) -> 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(DialysisError::DialysisPrescriptionNotFound)?;
let next_ver = check_version(expected_version, model.version)
.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))?; }
let mut active: dialysis_prescription::ActiveModel = model.into();
if let Some(v) = req.dialyzer_model { active.dialyzer_model = Set(Some(v)); }
if let Some(v) = req.membrane_area { active.membrane_area = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.dialysate_potassium { active.dialysate_potassium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.dialysate_calcium { active.dialysate_calcium = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.dialysate_bicarbonate { active.dialysate_bicarbonate = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.anticoagulation_type { active.anticoagulation_type = Set(Some(v)); }
if let Some(v) = req.anticoagulation_dose { active.anticoagulation_dose = Set(Some(v)); }
if let Some(v) = req.target_ultrafiltration_ml { active.target_ultrafiltration_ml = Set(Some(v)); }
if let Some(v) = req.target_dry_weight { active.target_dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.blood_flow_rate { active.blood_flow_rate = Set(Some(v)); }
if let Some(v) = req.dialysate_flow_rate { active.dialysate_flow_rate = Set(Some(v)); }
if let Some(v) = req.frequency_per_week { active.frequency_per_week = Set(Some(v)); }
if let Some(v) = req.duration_minutes { active.duration_minutes = Set(Some(v)); }
if let Some(v) = req.vascular_access_type { active.vascular_access_type = Set(Some(v)); }
if let Some(v) = req.vascular_access_location { active.vascular_access_location = Set(Some(v)); }
if let Some(v) = req.effective_from { active.effective_from = Set(Some(v)); }
if let Some(v) = req.effective_to { active.effective_to = Set(Some(v)); }
if let Some(v) = req.status { active.status = Set(v); }
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.updated", "dialysis_prescription")
.with_resource_id(m.id),
&state.db,
).await;
Ok(model_to_resp(m))
}
pub async fn delete_prescription(
state: &DialysisState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> 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(DialysisError::DialysisPrescriptionNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| DialysisError::VersionMismatch)?;
let mut active: dialysis_prescription::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_prescription.deleted", "dialysis_prescription")
.with_resource_id(id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 私有辅助函数
// ---------------------------------------------------------------------------
fn model_to_resp(m: dialysis_prescription::Model) -> DialysisPrescriptionResp {
DialysisPrescriptionResp {
id: m.id,
patient_id: m.patient_id,
dialyzer_model: m.dialyzer_model,
membrane_area: m.membrane_area.map(|d| d.to_f64().unwrap_or(0.0)),
dialysate_potassium: m.dialysate_potassium.map(|d| d.to_f64().unwrap_or(0.0)),
dialysate_calcium: m.dialysate_calcium.map(|d| d.to_f64().unwrap_or(0.0)),
dialysate_bicarbonate: m.dialysate_bicarbonate.map(|d| d.to_f64().unwrap_or(0.0)),
anticoagulation_type: m.anticoagulation_type,
anticoagulation_dose: m.anticoagulation_dose,
target_ultrafiltration_ml: m.target_ultrafiltration_ml,
target_dry_weight: m.target_dry_weight.map(|d| d.to_f64().unwrap_or(0.0)),
blood_flow_rate: m.blood_flow_rate,
dialysate_flow_rate: m.dialysate_flow_rate,
frequency_per_week: m.frequency_per_week,
duration_minutes: m.duration_minutes,
vascular_access_type: m.vascular_access_type,
vascular_access_location: m.vascular_access_location,
effective_from: m.effective_from,
effective_to: m.effective_to,
status: m.status,
prescribed_by: m.prescribed_by,
notes: m.notes,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
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(DialysisError::Validation(format!(
"anticoagulation_type 必须为: {}", valid.join(", ")
)));
}
}
Ok(())
}
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(DialysisError::Validation(format!(
"vascular_access_type 必须为: {}", valid.join(", ")
)));
}
}
Ok(())
}

View File

@@ -0,0 +1,379 @@
//! 透析记录 Service — 血透专科 CRUD + 审阅
use chrono::Utc;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::crypto as pii;
use num_traits::ToPrimitive;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::dialysis_dto::*;
use crate::entity::dialysis_record;
use crate::error::{DialysisError, DialysisResult};
use crate::state::DialysisState;
pub async fn list_dialysis_records(
state: &DialysisState,
tenant_id: Uuid,
patient_id: Uuid,
page: u64,
page_size: u64,
) -> DialysisResult<PaginatedResponse<DialysisRecordResp>> {
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = dialysis_record::Entity::find()
.filter(dialysis_record::Column::TenantId.eq(tenant_id))
.filter(dialysis_record::Column::PatientId.eq(patient_id))
.filter(dialysis_record::Column::DeletedAt.is_null());
let total = query.clone().count(&state.db).await?;
let models = query
.order_by_desc(dialysis_record::Column::DialysisDate)
.offset(offset)
.limit(limit)
.all(&state.db)
.await?;
let total_pages = total.div_ceil(limit.max(1));
let crypto = &state.crypto;
let data: Vec<DialysisRecordResp> = models.into_iter().map(|m| to_resp(crypto, m)).collect();
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
pub async fn get_dialysis_record(
state: &DialysisState,
tenant_id: Uuid,
record_id: Uuid,
) -> 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(DialysisError::DialysisRecordNotFound)?;
Ok(to_resp(&state.crypto, m))
}
pub async fn create_dialysis_record(
state: &DialysisState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateDialysisRecordReq,
) -> DialysisResult<DialysisRecordResp> {
// 患者存在性由数据库 FK 约束保证,不再显式查询 patient 表
validate_dialysis_type(&req.dialysis_type)?;
let kek = state.crypto.kek();
// PII 加密
let encrypted_symptoms = req.symptoms.as_ref()
.map(|v| -> DialysisResult<serde_json::Value> {
let json_str = serde_json::to_string(v)
.map_err(|e| DialysisError::Validation(e.to_string()))?;
Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?))
})
.transpose()?;
let encrypted_complication = req.complication_notes.as_ref()
.map(|c| pii::encrypt(kek, c))
.transpose()?;
let now = Utc::now();
let active = dialysis_record::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
dialysis_date: Set(req.dialysis_date),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
dry_weight: Set(req.dry_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
pre_weight: Set(req.pre_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
post_weight: Set(req.post_weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())),
pre_bp_systolic: Set(req.pre_bp_systolic),
pre_bp_diastolic: Set(req.pre_bp_diastolic),
post_bp_systolic: Set(req.post_bp_systolic),
post_bp_diastolic: Set(req.post_bp_diastolic),
pre_heart_rate: Set(req.pre_heart_rate),
post_heart_rate: Set(req.post_heart_rate),
ultrafiltration_volume: Set(req.ultrafiltration_volume),
dialysis_duration: Set(req.dialysis_duration),
blood_flow_rate: Set(req.blood_flow_rate),
dialysis_type: Set(req.dialysis_type),
symptoms: Set(encrypted_symptoms),
complication_notes: Set(encrypted_complication),
status: Set("draft".to_string()),
reviewed_by: Set(None),
reviewed_at: Set(None),
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),
key_version: Set(Some(1)),
};
let m = active.insert(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.created", "dialysis_record")
.with_resource_id(m.id),
&state.db,
).await;
Ok(to_resp(&state.crypto, m))
}
pub async fn update_dialysis_record(
state: &DialysisState,
tenant_id: Uuid,
record_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateDialysisRecordReq,
expected_version: i32,
) -> 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(DialysisError::DialysisRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| DialysisError::VersionMismatch)?;
let mut active: dialysis_record::ActiveModel = model.into();
if let Some(v) = req.dialysis_date { active.dialysis_date = Set(v); }
if let Some(v) = req.start_time { active.start_time = Set(Some(v)); }
if let Some(v) = req.end_time { active.end_time = Set(Some(v)); }
if let Some(v) = req.dry_weight { active.dry_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.pre_weight { active.pre_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.post_weight { active.post_weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); }
if let Some(v) = req.pre_bp_systolic { active.pre_bp_systolic = Set(Some(v)); }
if let Some(v) = req.pre_bp_diastolic { active.pre_bp_diastolic = Set(Some(v)); }
if let Some(v) = req.post_bp_systolic { active.post_bp_systolic = Set(Some(v)); }
if let Some(v) = req.post_bp_diastolic { active.post_bp_diastolic = Set(Some(v)); }
if let Some(v) = req.pre_heart_rate { active.pre_heart_rate = Set(Some(v)); }
if let Some(v) = req.post_heart_rate { active.post_heart_rate = Set(Some(v)); }
if let Some(v) = req.ultrafiltration_volume { active.ultrafiltration_volume = Set(Some(v)); }
if let Some(v) = req.dialysis_duration { active.dialysis_duration = Set(Some(v)); }
if let Some(v) = req.blood_flow_rate { active.blood_flow_rate = Set(Some(v)); }
if let Some(ref v) = req.dialysis_type { validate_dialysis_type(v)?; active.dialysis_type = Set(v.clone()); }
if let Some(v) = req.symptoms {
let kek = state.crypto.kek();
let encrypted = Some(serde_json::Value::String(
pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())?
));
active.symptoms = Set(encrypted);
}
if let Some(v) = req.complication_notes {
let kek = state.crypto.kek();
let encrypted = pii::encrypt(kek, &v)?;
active.complication_notes = Set(Some(encrypted));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.key_version = Set(Some(1));
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.updated", "dialysis_record")
.with_resource_id(m.id),
&state.db,
).await;
Ok(to_resp(&state.crypto, m))
}
pub async fn review_dialysis_record(
state: &DialysisState,
tenant_id: Uuid,
record_id: Uuid,
reviewer_id: Uuid,
expected_version: i32,
) -> 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(DialysisError::DialysisRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| DialysisError::VersionMismatch)?;
validate_dialysis_status_transition(&model.status, "reviewed")?;
let mut active: dialysis_record::ActiveModel = model.into();
active.status = Set("reviewed".to_string());
active.reviewed_by = Set(Some(reviewer_id));
active.reviewed_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(reviewer_id));
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(reviewer_id), "dialysis_record.reviewed", "dialysis_record")
.with_resource_id(m.id),
&state.db,
).await;
Ok(to_resp(&state.crypto, m))
}
pub async fn delete_dialysis_record(
state: &DialysisState,
tenant_id: Uuid,
record_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> 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(DialysisError::DialysisRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| DialysisError::VersionMismatch)?;
let mut active: dialysis_record::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.deleted", "dialysis_record")
.with_resource_id(record_id),
&state.db,
).await;
Ok(())
}
// ---------------------------------------------------------------------------
// 私有辅助函数
// ---------------------------------------------------------------------------
/// 校验透析类型枚举
fn validate_dialysis_type(dialysis_type: &str) -> DialysisResult<()> {
match dialysis_type {
"HD" | "HDF" | "HF" => Ok(()),
_ => 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();
// 解密症状 JSON加密时存储为 Value::String(ciphertext)
let symptoms = m.symptoms.as_ref()
.and_then(|v| v.as_str())
.and_then(|s| pii::decrypt(kek, s).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.or(m.symptoms);
// 解密并发症备注
let complication_notes = m.complication_notes.as_ref()
.map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone()))
.or(m.complication_notes);
DialysisRecordResp {
id: m.id,
patient_id: m.patient_id,
dialysis_date: m.dialysis_date,
start_time: m.start_time,
end_time: m.end_time,
dry_weight: m.dry_weight.map(|d| d.to_f64().unwrap_or(0.0)),
pre_weight: m.pre_weight.map(|d| d.to_f64().unwrap_or(0.0)),
post_weight: m.post_weight.map(|d| d.to_f64().unwrap_or(0.0)),
pre_bp_systolic: m.pre_bp_systolic,
pre_bp_diastolic: m.pre_bp_diastolic,
post_bp_systolic: m.post_bp_systolic,
post_bp_diastolic: m.post_bp_diastolic,
pre_heart_rate: m.pre_heart_rate,
post_heart_rate: m.post_heart_rate,
ultrafiltration_volume: m.ultrafiltration_volume,
dialysis_duration: m.dialysis_duration,
blood_flow_rate: m.blood_flow_rate,
dialysis_type: m.dialysis_type,
symptoms,
complication_notes,
status: m.status,
reviewed_by: m.reviewed_by,
reviewed_at: m.reviewed_at,
created_at: m.created_at,
updated_at: m.updated_at,
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,
}