diff --git a/Cargo.lock b/Cargo.lock index b0eb1a9..711203c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1281,6 +1281,7 @@ dependencies = [ "axum", "chrono", "erp-core", + "num-traits", "sea-orm", "serde", "serde_json", diff --git a/crates/erp-health/Cargo.toml b/crates/erp-health/Cargo.toml index 418e08f..bbc411f 100644 --- a/crates/erp-health/Cargo.toml +++ b/crates/erp-health/Cargo.toml @@ -17,3 +17,4 @@ thiserror.workspace = true validator.workspace = true utoipa.workspace = true async-trait.workspace = true +num-traits = "0.2.19" diff --git a/crates/erp-health/src/handler/consultation_handler.rs b/crates/erp-health/src/handler/consultation_handler.rs index 66db058..c897010 100644 --- a/crates/erp-health/src/handler/consultation_handler.rs +++ b/crates/erp-health/src/handler/consultation_handler.rs @@ -35,7 +35,6 @@ pub struct CloseSessionReq { #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct CreateConsultationMessageReq { pub session_id: Uuid, - pub sender_role: String, pub content_type: Option, pub content: String, } @@ -135,7 +134,7 @@ where let msg_req = CreateMessageReq { session_id: req.session_id, sender_id: ctx.user_id, - sender_role: req.sender_role, + sender_role: "doctor".to_string(), content_type: req.content_type, content: req.content, }; diff --git a/crates/erp-health/src/handler/doctor_handler.rs b/crates/erp-health/src/handler/doctor_handler.rs index 46221d8..9550297 100644 --- a/crates/erp-health/src/handler/doctor_handler.rs +++ b/crates/erp-health/src/handler/doctor_handler.rs @@ -28,6 +28,11 @@ pub struct UpdateDoctorWithVersion { pub version: i32, } +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteWithVersion { + pub version: i32, +} + pub async fn list_doctors( State(state): State, Extension(ctx): Extension, @@ -100,12 +105,13 @@ pub async fn delete_doctor( 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.doctor.manage")?; - doctor_service::delete_doctor(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?; + doctor_service::delete_doctor(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?; Ok(Json(ApiResponse::ok(()))) } diff --git a/crates/erp-health/src/handler/follow_up_handler.rs b/crates/erp-health/src/handler/follow_up_handler.rs index 18e14c0..410a013 100644 --- a/crates/erp-health/src/handler/follow_up_handler.rs +++ b/crates/erp-health/src/handler/follow_up_handler.rs @@ -36,6 +36,11 @@ pub struct UpdateFollowUpTaskWithVersion { pub version: i32, } +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteWithVersion { + pub version: i32, +} + pub async fn list_tasks( State(state): State, Extension(ctx): Extension, @@ -95,13 +100,14 @@ pub async fn delete_task( 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.follow-up.manage")?; - follow_up_service::delete_task(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?; + follow_up_service::delete_task(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?; Ok(Json(ApiResponse::ok(()))) } diff --git a/crates/erp-health/src/handler/health_data_handler.rs b/crates/erp-health/src/handler/health_data_handler.rs index fc18215..a10acdb 100644 --- a/crates/erp-health/src/handler/health_data_handler.rs +++ b/crates/erp-health/src/handler/health_data_handler.rs @@ -34,6 +34,11 @@ pub struct GenerateTrendReq { pub period_end: chrono::NaiveDate, } +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteWithVersion { + pub version: i32, +} + #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct UpdateWithVersion { pub data: T, @@ -104,13 +109,14 @@ pub async fn delete_vital_signs( State(state): State, Extension(ctx): Extension, Path((_patient_id, vid)): Path<(Uuid, Uuid)>, + Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; - health_data_service::delete_vital_signs(&state, ctx.tenant_id, vid, Some(ctx.user_id)).await?; + health_data_service::delete_vital_signs(&state, ctx.tenant_id, vid, Some(ctx.user_id), req.version).await?; Ok(Json(ApiResponse::ok(()))) } @@ -178,13 +184,14 @@ pub async fn delete_lab_report( State(state): State, Extension(ctx): Extension, Path((_patient_id, rid)): Path<(Uuid, Uuid)>, + Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; - health_data_service::delete_lab_report(&state, ctx.tenant_id, rid, Some(ctx.user_id)).await?; + health_data_service::delete_lab_report(&state, ctx.tenant_id, rid, Some(ctx.user_id), req.version).await?; Ok(Json(ApiResponse::ok(()))) } @@ -252,13 +259,14 @@ pub async fn delete_health_record( State(state): State, Extension(ctx): Extension, Path((_patient_id, rid)): Path<(Uuid, Uuid)>, + Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.health-data.manage")?; - health_data_service::delete_health_record(&state, ctx.tenant_id, rid, Some(ctx.user_id)).await?; + health_data_service::delete_health_record(&state, ctx.tenant_id, rid, Some(ctx.user_id), req.version).await?; Ok(Json(ApiResponse::ok(()))) } diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index 6584a4f..d3bd3af 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -30,6 +30,11 @@ pub struct AssignDoctorReq { pub relationship_type: Option, } +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteWithVersion { + pub version: i32, +} + pub async fn list_patients( State(state): State, Extension(ctx): Extension, @@ -118,13 +123,14 @@ pub async fn delete_patient( 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.patient.manage")?; - patient_service::delete_patient(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?; + patient_service::delete_patient(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?; Ok(Json(ApiResponse::ok(()))) } @@ -219,6 +225,7 @@ pub async fn delete_family_member( State(state): State, Extension(ctx): Extension, Path((patient_id, member_id)): Path<(Uuid, Uuid)>, + Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, @@ -226,7 +233,7 @@ where { require_permission(&ctx, "health.patient.manage")?; patient_service::delete_family_member( - &state, ctx.tenant_id, patient_id, member_id, Some(ctx.user_id), + &state, ctx.tenant_id, patient_id, member_id, Some(ctx.user_id), req.version, ) .await?; Ok(Json(ApiResponse::ok(()))) diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs index 9109ae4..c132ab9 100644 --- a/crates/erp-health/src/service/appointment_service.rs +++ b/crates/erp-health/src/service/appointment_service.rs @@ -184,6 +184,8 @@ pub async fn update_appointment_status( let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; + let txn = state.db.begin().await?; + // 取消时释放排班名额(带下限保护) if req.status == "cancelled" { if let Some(did) = model.doctor_id { @@ -198,7 +200,7 @@ pub async fn update_appointment_status( .filter(doctor_schedule::Column::ScheduleDate.eq(model.appointment_date)) .filter(doctor_schedule::Column::DeletedAt.is_null()) .filter(Expr::col(doctor_schedule::Column::CurrentAppointments).gt(0)) - .exec(&state.db) + .exec(&txn) .await; if let Err(e) = release_result { tracing::error!(error = %e, "取消预约时释放排班名额失败"); @@ -213,7 +215,9 @@ pub async fn update_appointment_status( active.updated_by = Set(operator_id); active.version = Set(next_ver); - let m = active.update(&state.db).await?; + let m = active.update(&txn).await?; + + txn.commit().await?; let event_type = format!("appointment.{}", m.status); let event = DomainEvent::new( diff --git a/crates/erp-health/src/service/doctor_service.rs b/crates/erp-health/src/service/doctor_service.rs index 7bbd923..64a4adc 100644 --- a/crates/erp-health/src/service/doctor_service.rs +++ b/crates/erp-health/src/service/doctor_service.rs @@ -152,14 +152,17 @@ pub async fn delete_doctor( tenant_id: Uuid, id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = find_doctor(&state.db, tenant_id, id).await?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; let mut active: doctor_profile::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(active.version.unwrap() + 1); + active.version = Set(next_ver); active.update(&state.db).await?; Ok(()) diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index 7223d97..f540eee 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -164,6 +164,7 @@ pub async fn delete_task( tenant_id: Uuid, task_id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = follow_up_task::Entity::find() .filter(follow_up_task::Column::Id.eq(task_id)) @@ -173,11 +174,14 @@ pub async fn delete_task( .await? .ok_or(HealthError::FollowUpTaskNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + let mut active: follow_up_task::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(active.version.unwrap() + 1); + active.version = Set(next_ver); active.update(&state.db).await?; Ok(()) } diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index 048eae1..b4ea7e6 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -2,6 +2,7 @@ use chrono::Utc; use erp_core::events::DomainEvent; +use num_traits::cast::ToPrimitive; use sea_orm::entity::prelude::*; use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; use uuid::Uuid; @@ -52,8 +53,8 @@ pub async fn list_vital_signs( systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening, heart_rate: m.heart_rate, - weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), - blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml, notes: m.notes, @@ -110,8 +111,8 @@ pub async fn create_vital_signs( systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning, systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening, heart_rate: m.heart_rate, - weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), - blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml, notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, }) @@ -159,8 +160,8 @@ pub async fn update_vital_signs( systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning, systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening, heart_rate: m.heart_rate, - weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), - blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + weight: m.weight.map(|d| d.to_f64().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml, notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, }) @@ -171,6 +172,7 @@ pub async fn delete_vital_signs( tenant_id: Uuid, vital_signs_id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = vital_signs::Entity::find() .filter(vital_signs::Column::Id.eq(vital_signs_id)) @@ -180,11 +182,14 @@ pub async fn delete_vital_signs( .await? .ok_or(HealthError::VitalSignsNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + let mut active: vital_signs::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(active.version.unwrap() + 1); + active.version = Set(next_ver); active.update(&state.db).await?; Ok(()) } @@ -321,6 +326,7 @@ pub async fn delete_lab_report( tenant_id: Uuid, report_id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = lab_report::Entity::find() .filter(lab_report::Column::Id.eq(report_id)) @@ -330,11 +336,14 @@ pub async fn delete_lab_report( .await? .ok_or(HealthError::LabReportNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + let mut active: lab_report::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(active.version.unwrap() + 1); + active.version = Set(next_ver); active.update(&state.db).await?; Ok(()) } @@ -468,6 +477,7 @@ pub async fn delete_health_record( tenant_id: Uuid, record_id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = health_record::Entity::find() .filter(health_record::Column::Id.eq(record_id)) @@ -477,11 +487,14 @@ pub async fn delete_health_record( .await? .ok_or(HealthError::HealthRecordNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + let mut active: health_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(active.version.unwrap() + 1); + active.version = Set(next_ver); active.update(&state.db).await?; Ok(()) } @@ -605,8 +618,8 @@ pub async fn get_indicator_timeseries( let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| { let val = match indicator.as_str() { "heart_rate" => v.heart_rate.map(|x| x as f64), - "weight" => v.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), - "blood_sugar" => v.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + "weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)), + "blood_sugar" => v.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), "systolic_bp_morning" => v.systolic_bp_morning.map(|x| x as f64), "diastolic_bp_morning" => v.diastolic_bp_morning.map(|x| x as f64), "systolic_bp_evening" => v.systolic_bp_evening.map(|x| x as f64), diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 095f5ef..2c99df7 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -229,14 +229,17 @@ pub async fn delete_patient( tenant_id: Uuid, id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = find_patient(&state.db, tenant_id, id).await?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; let mut active: patient::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(active.version.unwrap() + 1); + active.version = Set(next_ver); active.update(&state.db).await?; Ok(()) @@ -498,6 +501,7 @@ pub async fn delete_family_member( patient_id: Uuid, family_member_id: Uuid, operator_id: Option, + expected_version: i32, ) -> HealthResult<()> { let model = patient_family_member::Entity::find() .filter(patient_family_member::Column::Id.eq(family_member_id)) @@ -508,11 +512,14 @@ pub async fn delete_family_member( .await? .ok_or(HealthError::FamilyMemberNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + let mut active: patient_family_member::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(active.version.unwrap() + 1); + active.version = Set(next_ver); active.update(&state.db).await?; Ok(()) diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 767b291..1a77337 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -46,6 +46,7 @@ mod m20260423_000043_create_wechat_users; mod m20260423_000044_create_articles; mod m20260424_000045_health_indexes; mod m20260424_000046_health_constraints_fix; +mod m20260424_000047_health_index_fix; pub struct Migrator; @@ -99,6 +100,7 @@ impl MigratorTrait for Migrator { Box::new(m20260423_000044_create_articles::Migration), Box::new(m20260424_000045_health_indexes::Migration), Box::new(m20260424_000046_health_constraints_fix::Migration), + Box::new(m20260424_000047_health_index_fix::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260424_000047_health_index_fix.rs b/crates/erp-server/migration/src/m20260424_000047_health_index_fix.rs new file mode 100644 index 0000000..765e31a --- /dev/null +++ b/crates/erp-server/migration/src/m20260424_000047_health_index_fix.rs @@ -0,0 +1,49 @@ +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> { + let db = manager.get_connection(); + + // 删除旧索引(缺少 tenant_id 前导列) + db.execute_unprepared( + "DROP INDEX IF EXISTS idx_health_trend_patient_period" + ).await?; + + // 重建为包含 tenant_id 的正确索引 + db.execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_health_trend_tenant_patient_period \ + ON health_trend (tenant_id, patient_id, period_start DESC)" + ).await?; + + // 添加 follow_up_record 缺失的 (tenant_id, executed_date) 索引 + db.execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_follow_up_record_tenant_executed_date \ + ON follow_up_record (tenant_id, executed_date DESC)" + ).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + db.execute_unprepared( + "DROP INDEX IF EXISTS idx_health_trend_tenant_patient_period" + ).await?; + + db.execute_unprepared( + "DROP INDEX IF EXISTS idx_follow_up_record_tenant_executed_date" + ).await?; + + db.execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_health_trend_patient_period \ + ON health_trend (patient_id, period_start)" + ).await?; + + Ok(()) + } +}