From 6fbe7ec530e28b7be83efd869c135ce14dc5cf00 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 24 Apr 2026 01:07:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(health):=20=E4=B8=89=E6=AC=A1=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=89=B9=E6=AC=A1B=E4=BF=AE=E5=A4=8D=20=E2=80=94=2012?= =?UTF-8?q?=E4=B8=AAHIGH=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H-6: appointment_service 状态转换复用 validation.rs 函数 - H-7: 添加 validate_record_type (checkup/outpatient/inpatient) - H-8: 添加 validate_patient_status + validate_verification_status 白名单 - H-9: 添加 validate_online_status + online_status 变更事件 - H-10: create_appointment 添加 doctor_id 存在性检查 - H-12/H-13/H-14: 添加 lab_report GIN/health_trend/follow_up_record 索引 --- crates/erp-health/src/dto/doctor_dto.rs | 1 + .../src/service/appointment_service.rs | 24 ++++---- .../erp-health/src/service/doctor_service.rs | 14 +++++ .../src/service/health_data_service.rs | 20 ++++--- .../erp-health/src/service/patient_service.rs | 4 +- crates/erp-health/src/service/validation.rs | 24 ++++++++ crates/erp-server/migration/src/lib.rs | 2 + .../src/m20260424_000045_health_indexes.rs | 57 +++++++++++++++++++ 8 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260424_000045_health_indexes.rs diff --git a/crates/erp-health/src/dto/doctor_dto.rs b/crates/erp-health/src/dto/doctor_dto.rs index d3fb788..40168eb 100644 --- a/crates/erp-health/src/dto/doctor_dto.rs +++ b/crates/erp-health/src/dto/doctor_dto.rs @@ -30,6 +30,7 @@ pub struct UpdateDoctorReq { pub specialty: Option, pub license_number: Option, pub bio: Option, + pub online_status: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs index 6836b83..6c634ae 100644 --- a/crates/erp-health/src/service/appointment_service.rs +++ b/crates/erp-health/src/service/appointment_service.rs @@ -10,10 +10,10 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::appointment_dto::*; -use crate::entity::{appointment, doctor_schedule, patient}; +use crate::entity::{appointment, doctor_profile, doctor_schedule, patient}; use crate::error::{HealthError, HealthResult}; use crate::service::validation::{ - validate_appointment_type, + validate_appointment_status_transition, validate_appointment_type, validate_period_type, validate_schedule_status, }; use crate::state::HealthState; @@ -83,6 +83,15 @@ pub async fn create_appointment( let doctor_id_val = req.doctor_id.ok_or(HealthError::Validation("doctor_id is required".to_string()))?; + // 校验医护存在 + doctor_profile::Entity::find() + .filter(doctor_profile::Column::Id.eq(doctor_id_val)) + .filter(doctor_profile::Column::TenantId.eq(tenant_id)) + .filter(doctor_profile::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::DoctorNotFound)?; + // 事务包裹 CAS + INSERT,防止 CAS 成功但 INSERT 失败产生幽灵占位 let txn = state.db.begin().await?; @@ -170,16 +179,7 @@ pub async fn update_appointment_status( .ok_or(HealthError::AppointmentNotFound)?; // 状态机校验 - let valid = match (model.status.as_str(), req.status.as_str()) { - ("pending", "confirmed" | "cancelled") => true, - ("confirmed", "completed" | "no_show" | "cancelled") => true, - _ => false, - }; - if !valid { - return Err(HealthError::InvalidStatusTransition(format!( - "{} -> {}", model.status, req.status - ))); - } + validate_appointment_status_transition(&model.status, &req.status)?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; diff --git a/crates/erp-health/src/service/doctor_service.rs b/crates/erp-health/src/service/doctor_service.rs index 31f3187..0e58f3a 100644 --- a/crates/erp-health/src/service/doctor_service.rs +++ b/crates/erp-health/src/service/doctor_service.rs @@ -11,6 +11,7 @@ use erp_core::types::PaginatedResponse; use crate::dto::doctor_dto::*; use crate::entity::doctor_profile; use crate::error::{HealthError, HealthResult}; +use crate::service::validation::validate_online_status; use crate::state::HealthState; pub async fn list_doctors( @@ -118,6 +119,7 @@ pub async fn update_doctor( let model = find_doctor(&state.db, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; + let old_online_status = model.online_status.clone(); let mut active: doctor_profile::ActiveModel = model.into(); if let Some(v) = req.name { active.name = Set(v); } @@ -126,6 +128,18 @@ pub async fn update_doctor( if let Some(v) = req.specialty { active.specialty = Set(Some(v)); } if let Some(v) = req.license_number { active.license_number = Set(Some(v)); } if let Some(v) = req.bio { active.bio = Set(Some(v)); } + if let Some(ref v) = req.online_status { + validate_online_status(v)?; + active.online_status = Set(v.clone()); + if old_online_status != *v { + let event = erp_core::events::DomainEvent::new( + "doctor.online_status_changed", + tenant_id, + serde_json::json!({ "doctor_id": id, "old_status": old_online_status, "new_status": v }), + ); + state.event_bus.publish(event, &state.db).await; + } + } active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_ver); diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index d45438d..048eae1 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -12,6 +12,7 @@ use erp_core::types::PaginatedResponse; use crate::dto::health_data_dto::*; use crate::entity::{health_record, health_trend, lab_report, patient, vital_signs}; use crate::error::{HealthError, HealthResult}; +use crate::service::validation::validate_record_type; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -393,11 +394,14 @@ pub async fn create_health_record( .ok_or(HealthError::PatientNotFound)?; let now = Utc::now(); + let record_type = req.record_type.unwrap_or_else(|| "checkup".to_string()); + validate_record_type(&record_type)?; + let active = health_record::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), patient_id: Set(patient_id), - record_type: Set(req.record_type.unwrap_or_else(|| "checkup".to_string())), + record_type: Set(record_type), record_date: Set(req.record_date), source: Set(req.source), overall_assessment: Set(req.overall_assessment), @@ -425,7 +429,7 @@ pub async fn update_health_record( patient_id: Uuid, record_id: Uuid, operator_id: Option, - req: CreateHealthRecordReq, + req: UpdateHealthRecordReq, expected_version: i32, ) -> HealthResult { let model = health_record::Entity::find() @@ -440,12 +444,12 @@ pub async fn update_health_record( .map_err(|_| HealthError::VersionMismatch)?; let mut active: health_record::ActiveModel = model.into(); - if let Some(v) = req.record_type { active.record_type = Set(v); } - active.record_date = Set(req.record_date); - active.source = Set(req.source); - active.overall_assessment = Set(req.overall_assessment); - active.report_file_url = Set(req.report_file_url); - active.notes = Set(req.notes); + if let Some(ref v) = req.record_type { validate_record_type(v)?; active.record_type = Set(v.clone()); } + if let Some(v) = req.record_date { active.record_date = Set(v); } + if let Some(v) = req.source { active.source = Set(Some(v)); } + if let Some(v) = req.overall_assessment { active.overall_assessment = Set(Some(v)); } + if let Some(v) = req.report_file_url { active.report_file_url = Set(Some(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); diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 9f23b49..ecc8a70 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -15,7 +15,7 @@ use crate::entity::patient_family_member; use crate::entity::patient_tag_relation; use crate::entity::patient_doctor_relation; use crate::error::{HealthError, HealthResult}; -use crate::service::validation::{validate_gender, validate_blood_type}; +use crate::service::validation::{validate_gender, validate_blood_type, validate_patient_status, validate_verification_status}; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -163,6 +163,8 @@ pub async fn update_patient( if let Some(ref g) = req.gender { validate_gender(g)?; } if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; } + if let Some(ref s) = req.status { validate_patient_status(s)?; } + if let Some(ref vs) = req.verification_status { validate_verification_status(vs)?; } // 状态机验证: patient.status if let Some(ref new_status) = req.status { diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index d05f929..845a04e 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -106,3 +106,27 @@ pub fn validate_consultation_type(value: &str) -> HealthResult<()> { ]); Ok(()) } + +/// health_record.record_type +pub fn validate_record_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "record_type", ["checkup", "outpatient", "inpatient"]); + Ok(()) +} + +/// patient.status +pub fn validate_patient_status(value: &str) -> HealthResult<()> { + validate_enum!(value, "patient.status", ["active", "inactive", "deceased"]); + Ok(()) +} + +/// patient.verification_status +pub fn validate_verification_status(value: &str) -> HealthResult<()> { + validate_enum!(value, "verification_status", ["pending", "verified", "rejected"]); + Ok(()) +} + +/// doctor_profile.online_status +pub fn validate_online_status(value: &str) -> HealthResult<()> { + validate_enum!(value, "online_status", ["online", "offline", "busy"]); + Ok(()) +} diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 0e64457..2f1a855 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -44,6 +44,7 @@ mod m20260419_000041_plugin_user_views; mod m20260423_000042_create_health_tables; mod m20260423_000043_create_wechat_users; mod m20260423_000044_create_articles; +mod m20260424_000045_health_indexes; pub struct Migrator; @@ -95,6 +96,7 @@ impl MigratorTrait for Migrator { Box::new(m20260423_000042_create_health_tables::Migration), Box::new(m20260423_000043_create_wechat_users::Migration), Box::new(m20260423_000044_create_articles::Migration), + Box::new(m20260424_000045_health_indexes::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260424_000045_health_indexes.rs b/crates/erp-server/migration/src/m20260424_000045_health_indexes.rs new file mode 100644 index 0000000..02d41d5 --- /dev/null +++ b/crates/erp-server/migration/src/m20260424_000045_health_indexes.rs @@ -0,0 +1,57 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // H-12: lab_report.indicators GIN 索引(JSONB 查询加速) + manager + .get_connection() + .execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_lab_report_indicators_gin ON lab_report USING GIN (indicators)", + ) + .await?; + + // H-13: health_trend (patient_id, period_start) 联合索引(趋势查询加速) + manager + .create_index( + Index::create() + .name("idx_health_trend_patient_period") + .table(Alias::new("health_trend")) + .col(Alias::new("patient_id")) + .col(Alias::new("period_start")) + .to_owned(), + ) + .await?; + + // H-14: follow_up_record (task_id, executed_date) 联合索引(随访记录查询加速) + manager + .create_index( + Index::create() + .name("idx_follow_up_record_task_date") + .table(Alias::new("follow_up_record")) + .col(Alias::new("task_id")) + .col(Alias::new("executed_date")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP INDEX IF EXISTS idx_lab_report_indicators_gin") + .await?; + manager + .drop_index(Index::drop().name("idx_health_trend_patient_period").to_owned()) + .await?; + manager + .drop_index(Index::drop().name("idx_follow_up_record_task_date").to_owned()) + .await?; + Ok(()) + } +}