From ca96310a845fec55fb60f37c4235941abd7a89b6 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 27 Apr 2026 14:26:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E9=80=8F=E6=9E=90=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E7=AE=A1=E7=90=86=20CRUD=20=E2=80=94=20dialysis=5Fpre?= =?UTF-8?q?scription=20=E5=85=A8=E6=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增透析方案实体和完整 CRUD: - Entity: 20 字段含抗凝/血管通路/透析参数 - DTO: f64 类型适配 utoipa ToSchema - Service: 抗凝类型 + 血管通路类型校验 - Handler: 5 端点 + RBAC 权限控制 - 路由: /api/v1/health/dialysis-prescriptions --- .../src/dto/dialysis_prescription_dto.rs | 106 +++++++ crates/erp-health/src/dto/mod.rs | 1 + .../src/entity/dialysis_prescription.rs | 77 +++++ crates/erp-health/src/entity/mod.rs | 1 + crates/erp-health/src/error.rs | 6 +- .../handler/dialysis_prescription_handler.rs | 119 ++++++++ crates/erp-health/src/handler/mod.rs | 1 + crates/erp-health/src/module.rs | 14 +- .../service/dialysis_prescription_service.rs | 273 ++++++++++++++++++ crates/erp-health/src/service/mod.rs | 1 + 10 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 crates/erp-health/src/dto/dialysis_prescription_dto.rs create mode 100644 crates/erp-health/src/entity/dialysis_prescription.rs create mode 100644 crates/erp-health/src/handler/dialysis_prescription_handler.rs create mode 100644 crates/erp-health/src/service/dialysis_prescription_service.rs diff --git a/crates/erp-health/src/dto/dialysis_prescription_dto.rs b/crates/erp-health/src/dto/dialysis_prescription_dto.rs new file mode 100644 index 0000000..47e6f72 --- /dev/null +++ b/crates/erp-health/src/dto/dialysis_prescription_dto.rs @@ -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, + pub page_size: Option, + pub patient_id: Option, + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateDialysisPrescriptionReq { + pub patient_id: Uuid, + pub dialyzer_model: Option, + pub membrane_area: Option, + pub dialysate_potassium: Option, + pub dialysate_calcium: Option, + pub dialysate_bicarbonate: Option, + pub anticoagulation_type: Option, + pub anticoagulation_dose: Option, + pub target_ultrafiltration_ml: Option, + pub target_dry_weight: Option, + pub blood_flow_rate: Option, + pub dialysate_flow_rate: Option, + pub frequency_per_week: Option, + pub duration_minutes: Option, + pub vascular_access_type: Option, + pub vascular_access_location: Option, + pub effective_from: Option, + pub effective_to: Option, + pub notes: Option, +} + +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, + pub membrane_area: Option, + pub dialysate_potassium: Option, + pub dialysate_calcium: Option, + pub dialysate_bicarbonate: Option, + pub anticoagulation_type: Option, + pub anticoagulation_dose: Option, + pub target_ultrafiltration_ml: Option, + pub target_dry_weight: Option, + pub blood_flow_rate: Option, + pub dialysate_flow_rate: Option, + pub frequency_per_week: Option, + pub duration_minutes: Option, + pub vascular_access_type: Option, + pub vascular_access_location: Option, + pub effective_from: Option, + pub effective_to: Option, + pub status: Option, + pub notes: Option, +} + +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, + pub membrane_area: Option, + pub dialysate_potassium: Option, + pub dialysate_calcium: Option, + pub dialysate_bicarbonate: Option, + pub anticoagulation_type: Option, + pub anticoagulation_dose: Option, + pub target_ultrafiltration_ml: Option, + pub target_dry_weight: Option, + pub blood_flow_rate: Option, + pub dialysate_flow_rate: Option, + pub frequency_per_week: Option, + pub duration_minutes: Option, + pub vascular_access_type: Option, + pub vascular_access_location: Option, + pub effective_from: Option, + pub effective_to: Option, + pub status: String, + pub prescribed_by: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 8ac06e3..0bceb30 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -7,6 +7,7 @@ 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 health_data_dto; diff --git a/crates/erp-health/src/entity/dialysis_prescription.rs b/crates/erp-health/src/entity/dialysis_prescription.rs new file mode 100644 index 0000000..97816a9 --- /dev/null +++ b/crates/erp-health/src/entity/dialysis_prescription.rs @@ -0,0 +1,77 @@ +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, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub membrane_area: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub dialysate_potassium: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub dialysate_calcium: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub dialysate_bicarbonate: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub anticoagulation_type: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub anticoagulation_dose: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub target_ultrafiltration_ml: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub target_dry_weight: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub blood_flow_rate: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub dialysate_flow_rate: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub frequency_per_week: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub duration_minutes: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub vascular_access_type: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub vascular_access_location: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub effective_from: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub effective_to: Option, + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub prescribed_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index aa46671..c07e1c1 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -13,6 +13,7 @@ pub mod consultation_session; 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; diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 4a7b0cf..3d9fbd2 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -74,6 +74,9 @@ pub enum HealthError { #[error("告警记录不存在")] AlertNotFound, + #[error("透析方案不存在")] + DialysisPrescriptionNotFound, + #[error("状态转换无效: {0}")] InvalidStatusTransition(String), @@ -109,7 +112,8 @@ impl From for AppError { | HealthError::ThresholdNotFound | HealthError::ConsentNotFound | HealthError::AlertRuleNotFound - | HealthError::AlertNotFound => AppError::NotFound(err.to_string()), + | HealthError::AlertNotFound + | HealthError::DialysisPrescriptionNotFound => AppError::NotFound(err.to_string()), HealthError::ScheduleFull => AppError::Validation(err.to_string()), HealthError::InvalidStatusTransition(s) => AppError::Validation(s), HealthError::VersionMismatch => AppError::VersionMismatch, diff --git a/crates/erp-health/src/handler/dialysis_prescription_handler.rs b/crates/erp-health/src/handler/dialysis_prescription_handler.rs new file mode 100644 index 0000000..b40b72a --- /dev/null +++ b/crates/erp-health/src/handler/dialysis_prescription_handler.rs @@ -0,0 +1,119 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::dto::dialysis_prescription_dto::*; +use crate::dto::DeleteWithVersion; +use crate::service::dialysis_prescription_service; +use crate::state::HealthState; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct DialysisPrescriptionListParams { + pub page: Option, + pub page_size: Option, + pub patient_id: Option, + pub status: Option, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateDialysisPrescriptionWithVersion { + #[serde(flatten)] + pub data: UpdateDialysisPrescriptionReq, + pub version: i32, +} + +pub async fn list_prescriptions( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.dialysis-prescription.manage")?; + dialysis_prescription_service::delete_prescription( + &state, ctx.tenant_id, id, Some(ctx.user_id), req.version, + ) + .await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 3425e49..2195c0f 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -12,6 +12,7 @@ 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 health_data_handler; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 6a45799..f3eda93 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -7,7 +7,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor}; use crate::handler::{ alert_handler, alert_rule_handler, - appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler, + appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, device_reading_handler, diagnosis_handler, dialysis_handler, dialysis_prescription_handler, doctor_handler, follow_up_handler, health_data_handler, medication_record_handler, patient_handler, points_handler, stats_handler, }; @@ -222,6 +222,18 @@ impl HealthModule { "/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/patients/{id}/daily-monitoring", diff --git a/crates/erp-health/src/service/dialysis_prescription_service.rs b/crates/erp-health/src/service/dialysis_prescription_service.rs new file mode 100644 index 0000000..ee9b8d3 --- /dev/null +++ b/crates/erp-health/src/service/dialysis_prescription_service.rs @@ -0,0 +1,273 @@ +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, patient}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +pub async fn list_prescriptions( + state: &HealthState, + tenant_id: Uuid, + page: u64, + page_size: u64, + patient_id: Option, + status: Option, +) -> HealthResult> { + 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: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + 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(model_to_resp(m)) +} + +pub async fn create_prescription( + state: &HealthState, + 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)?; + + 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: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: UpdateDialysisPrescriptionReq, + expected_version: i32, +) -> HealthResult { + 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)?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::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: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + 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)?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::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>) -> HealthResult<()> { + if let Some(t) = val { + let valid = ["heparin", "lmwh", "heparin_free"]; + if !valid.contains(&t) { + return Err(HealthError::Validation(format!( + "anticoagulation_type 必须为: {}", valid.join(", ") + ))); + } + } + Ok(()) +} + +fn validate_vascular_access_type(val: Option<&str>) -> HealthResult<()> { + if let Some(t) = val { + let valid = ["avf", "avg", "cvc"]; + if !valid.contains(&t) { + return Err(HealthError::Validation(format!( + "vascular_access_type 必须为: {}", valid.join(", ") + ))); + } + } + Ok(()) +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index ea26a26..10665f7 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -12,6 +12,7 @@ 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;