diff --git a/Cargo.lock b/Cargo.lock index bcfaa00..b0eb1a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -714,6 +714,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1202,6 +1222,7 @@ dependencies = [ "chrono", "erp-core", "jsonwebtoken", + "reqwest", "sea-orm", "serde", "serde_json", @@ -1531,6 +1552,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1736,6 +1772,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1901,6 +1956,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1909,6 +1965,38 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -1917,13 +2005,23 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.3", + "system-configuration", "tokio", "tower-service", + "tracing", + "windows-registry", ] [[package]] @@ -2124,6 +2222,16 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2190,6 +2298,8 @@ version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2461,6 +2571,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.29.0" @@ -2579,6 +2706,50 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -3135,6 +3306,46 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -3349,6 +3560,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3526,6 +3746,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -4061,6 +4304,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -4073,6 +4319,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-interface" version = "0.27.3" @@ -4271,6 +4538,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4422,11 +4709,14 @@ dependencies = [ "bitflags", "bytes", "futures-core", + "futures-util", "http", "http-body", + "iri-string", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -4519,6 +4809,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -4697,6 +4993,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4741,6 +5046,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.117" @@ -5227,6 +5542,16 @@ dependencies = [ "wast 246.0.2", ] +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5386,6 +5711,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/crates/erp-health/src/dto/patient_dto.rs b/crates/erp-health/src/dto/patient_dto.rs index ab164a3..081d5b9 100644 --- a/crates/erp-health/src/dto/patient_dto.rs +++ b/crates/erp-health/src/dto/patient_dto.rs @@ -31,6 +31,8 @@ pub struct UpdatePatientReq { pub emergency_contact_phone: Option, pub source: Option, pub notes: Option, + pub status: Option, + pub verification_status: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 558a40d..c37fd1b 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -17,6 +17,21 @@ pub enum HealthError { #[error("排班不存在")] ScheduleNotFound, + #[error("体征记录不存在")] + VitalSignsNotFound, + + #[error("化验报告不存在")] + LabReportNotFound, + + #[error("健康档案不存在")] + HealthRecordNotFound, + + #[error("家庭成员不存在")] + FamilyMemberNotFound, + + #[error("标签不存在")] + TagNotFound, + #[error("排班已满,无法预约")] ScheduleFull, @@ -44,6 +59,11 @@ impl From for AppError { | HealthError::DoctorNotFound | HealthError::AppointmentNotFound | HealthError::ScheduleNotFound + | HealthError::VitalSignsNotFound + | HealthError::LabReportNotFound + | HealthError::HealthRecordNotFound + | HealthError::FamilyMemberNotFound + | HealthError::TagNotFound | HealthError::FollowUpTaskNotFound | HealthError::ConsultationNotFound => AppError::NotFound(err.to_string()), HealthError::ScheduleFull => AppError::Validation(err.to_string()), diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index a3f4196..6584a4f 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -104,6 +104,8 @@ where emergency_contact_phone: req.emergency_contact_phone, source: req.source, notes: req.notes, + status: req.status, + verification_status: req.verification_status, }; let result = patient_service::update_patient( &state, ctx.tenant_id, id, Some(ctx.user_id), update, version, @@ -281,6 +283,8 @@ pub struct UpdatePatientWithVersion { pub emergency_contact_phone: Option, pub source: Option, pub notes: Option, + pub status: Option, + pub verification_status: Option, pub version: i32, } diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs index 5a20eca..14f19df 100644 --- a/crates/erp-health/src/service/appointment_service.rs +++ b/crates/erp-health/src/service/appointment_service.rs @@ -10,8 +10,12 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::appointment_dto::*; -use crate::entity::{appointment, doctor_schedule}; +use crate::entity::{appointment, doctor_schedule, patient}; use crate::error::{HealthError, HealthResult}; +use crate::service::validation::{ + validate_appointment_type, + validate_period_type, validate_schedule_status, +}; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -66,6 +70,17 @@ pub async fn create_appointment( operator_id: Option, req: CreateAppointmentReq, ) -> 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)?; + + if let Some(ref at) = req.appointment_type { validate_appointment_type(at)?; } + // 原子 CAS: 排班名额 +1 // 使用 raw SQL 实现 CAS 防止超额预约 let cas_result = doctor_schedule::Entity::update_many() @@ -99,7 +114,7 @@ pub async fn create_appointment( tenant_id: Set(tenant_id), patient_id: Set(req.patient_id), doctor_id: Set(req.doctor_id), - appointment_type: Set(req.appointment_type.unwrap_or_else(|| "regular".to_string())), + appointment_type: Set(req.appointment_type.unwrap_or_else(|| "outpatient".to_string())), appointment_date: Set(req.appointment_date), start_time: Set(req.start_time), end_time: Set(req.end_time), @@ -254,12 +269,14 @@ pub async fn create_schedule( req: CreateScheduleReq, ) -> HealthResult { let now = Utc::now(); + let period_type = req.period_type.unwrap_or_else(|| "am".to_string()); + validate_period_type(&period_type)?; let active = doctor_schedule::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), doctor_id: Set(req.doctor_id), schedule_date: Set(req.schedule_date), - period_type: Set(req.period_type.unwrap_or_else(|| "morning".to_string())), + period_type: Set(period_type), start_time: Set(req.start_time), end_time: Set(req.end_time), max_appointments: Set(req.max_appointments), @@ -300,6 +317,8 @@ pub async fn update_schedule( let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; + if let Some(ref s) = req.status { validate_schedule_status(s)?; } + let mut active: doctor_schedule::ActiveModel = model.into(); if let Some(v) = req.start_time { active.start_time = Set(v); } if let Some(v) = req.end_time { active.end_time = Set(v); } diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index 9e58411..b488d7f 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -10,8 +10,9 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::consultation_dto::*; -use crate::entity::{consultation_message, consultation_session}; +use crate::entity::{consultation_message, consultation_session, patient}; use crate::error::{HealthError, HealthResult}; +use crate::service::validation::{validate_sender_role, validate_content_type, validate_consultation_type}; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -25,12 +26,25 @@ pub async fn create_session( req: CreateSessionReq, ) -> HealthResult { let now = Utc::now(); + + // 校验患者存在 + 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)?; + + let consultation_type = req.consultation_type.unwrap_or_else(|| "customer_service".to_string()); + validate_consultation_type(&consultation_type)?; + let active = consultation_session::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), patient_id: Set(req.patient_id), doctor_id: Set(req.doctor_id), - consultation_type: Set(req.consultation_type.unwrap_or_else(|| "text".to_string())), + consultation_type: Set(consultation_type), status: Set("waiting".to_string()), last_message_at: Set(None), unread_count_patient: Set(0), @@ -113,7 +127,11 @@ pub async fn close_session( .filter(consultation_session::Column::Id.eq(session_id)) .filter(consultation_session::Column::TenantId.eq(tenant_id)) .filter(consultation_session::Column::DeletedAt.is_null()) - .filter(consultation_session::Column::Status.eq("active")) + .filter( + Condition::any() + .add(consultation_session::Column::Status.eq("active")) + .add(consultation_session::Column::Status.eq("waiting")), + ) .one(&state.db) .await? .ok_or(HealthError::ConsultationNotFound)?; @@ -234,6 +252,9 @@ pub async fn create_message( .ok_or(HealthError::ConsultationNotFound)?; let now = Utc::now(); + validate_sender_role(&req.sender_role)?; + let content_type = req.content_type.unwrap_or_else(|| "text".to_string()); + validate_content_type(&content_type)?; let is_patient = req.sender_role == "patient"; let should_activate = session.status == "waiting"; @@ -244,7 +265,7 @@ pub async fn create_message( session_id: Set(req.session_id), sender_id: Set(req.sender_id), sender_role: Set(req.sender_role), - content_type: Set(req.content_type.unwrap_or_else(|| "text".to_string())), + content_type: Set(content_type), content: Set(req.content), is_read: Set(false), created_at: Set(now), diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index fc4fa46..c64d75d 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -10,8 +10,9 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::follow_up_dto::*; -use crate::entity::{follow_up_record, follow_up_task}; +use crate::entity::{follow_up_record, follow_up_task, patient}; use crate::error::{HealthError, HealthResult}; +use crate::service::validation::validate_follow_up_type; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -65,6 +66,18 @@ pub async fn create_task( req: CreateFollowUpTaskReq, ) -> HealthResult { let now = Utc::now(); + + validate_follow_up_type(&req.follow_up_type)?; + + // 校验患者存在 + 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)?; + let active = follow_up_task::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), @@ -119,6 +132,13 @@ pub async fn update_task( let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; + if let Some(ref ft) = req.follow_up_type { validate_follow_up_type(ft)?; } + + // 状态机验证: follow_up_task.status + if let Some(ref new_status) = req.status { + validate_follow_up_status_transition(&model.status, new_status)?; + } + let mut active: follow_up_task::ActiveModel = model.into(); if let Some(v) = req.assigned_to { active.assigned_to = Set(Some(v)); } if let Some(v) = req.follow_up_type { active.follow_up_type = Set(v); } @@ -301,3 +321,22 @@ pub async fn list_records( Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } + +/// 随访任务状态机: pending → in_progress/cancelled, in_progress → completed/cancelled +fn validate_follow_up_status_transition(current: &str, new_status: &str) -> HealthResult<()> { + if current == new_status { + return Ok(()); + } + let allowed = match current { + "pending" => matches!(new_status, "in_progress" | "cancelled"), + "in_progress" => matches!(new_status, "completed" | "cancelled"), + _ => false, + }; + if allowed { + Ok(()) + } else { + Err(HealthError::InvalidStatusTransition(format!( + "follow_up_task.status: 不允许从 '{}' 转换到 '{}'", current, new_status + ))) + } +} diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index b5eb65d..0861061 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -10,7 +10,7 @@ use erp_core::error::check_version; use erp_core::types::PaginatedResponse; use crate::dto::health_data_dto::*; -use crate::entity::{health_record, health_trend, lab_report, vital_signs}; +use crate::entity::{health_record, health_trend, lab_report, patient, vital_signs}; use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; @@ -71,6 +71,15 @@ pub async fn create_vital_signs( operator_id: Option, req: CreateVitalSignsReq, ) -> HealthResult { + // 校验患者存在 + patient::Entity::find() + .filter(patient::Column::Id.eq(patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + let now = Utc::now(); let active = vital_signs::ActiveModel { id: Set(Uuid::now_v7()), @@ -122,7 +131,7 @@ pub async fn update_vital_signs( .filter(vital_signs::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::VitalSignsNotFound)?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; @@ -167,7 +176,7 @@ pub async fn delete_vital_signs( .filter(vital_signs::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::VitalSignsNotFound)?; let mut active: vital_signs::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); @@ -271,7 +280,7 @@ pub async fn update_lab_report( .filter(lab_report::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::LabReportNotFound)?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; @@ -306,7 +315,7 @@ pub async fn delete_lab_report( .filter(lab_report::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::LabReportNotFound)?; let mut active: lab_report::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); @@ -403,7 +412,7 @@ pub async fn update_health_record( .filter(health_record::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::HealthRecordNotFound)?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; @@ -439,7 +448,7 @@ pub async fn delete_health_record( .filter(health_record::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::HealthRecordNotFound)?; let mut active: health_record::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 8c47f90..f6e0fe2 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -5,3 +5,4 @@ pub mod follow_up_service; pub mod health_data_service; pub mod patient_service; pub mod seed; +pub mod validation; diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 88364c2..9f23b49 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -15,6 +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::state::HealthState; // --------------------------------------------------------------------------- @@ -97,6 +98,9 @@ pub async fn create_patient( let now = Utc::now(); let id = Uuid::now_v7(); + if let Some(ref g) = req.gender { validate_gender(g)?; } + if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; } + let active = patient::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), @@ -157,6 +161,26 @@ pub async fn update_patient( let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; + if let Some(ref g) = req.gender { validate_gender(g)?; } + if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; } + + // 状态机验证: patient.status + if let Some(ref new_status) = req.status { + validate_status_transition("patient.status", &model.status, new_status, &[ + ("active", "inactive"), + ("active", "deceased"), + ("inactive", "active"), + ])?; + } + // 状态机验证: patient.verification_status + if let Some(ref new_vs) = req.verification_status { + validate_status_transition("patient.verification_status", &model.verification_status, new_vs, &[ + ("pending", "verified"), + ("pending", "rejected"), + ("rejected", "pending"), + ])?; + } + let mut active: patient::ActiveModel = model.into(); if let Some(v) = req.name { active.name = Set(v); } @@ -170,6 +194,8 @@ pub async fn update_patient( if let Some(v) = req.emergency_contact_phone { active.emergency_contact_phone = Set(Some(v)); } if let Some(v) = req.source { active.source = Set(Some(v)); } if let Some(v) = req.notes { active.notes = Set(Some(v)); } + if let Some(ref v) = req.status { active.status = Set(v.clone()); } + if let Some(ref v) = req.verification_status { active.verification_status = Set(v.clone()); } active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); @@ -177,8 +203,16 @@ pub async fn update_patient( let updated = active.update(&state.db).await?; + // 根据状态变更发布不同事件 + let event_type = if req.status.as_deref() == Some("deceased") { + "patient.deceased" + } else if req.verification_status.as_deref() == Some("verified") { + "patient.verified" + } else { + "patient.updated" + }; let event = DomainEvent::new( - "patient.updated", + event_type, tenant_id, serde_json::json!({ "patient_id": updated.id }), ); @@ -398,7 +432,7 @@ pub async fn create_family_member( pub async fn update_family_member( state: &HealthState, tenant_id: Uuid, - _patient_id: Uuid, + patient_id: Uuid, family_member_id: Uuid, operator_id: Option, req: FamilyMemberReq, @@ -406,11 +440,12 @@ pub async fn update_family_member( ) -> HealthResult { let model = patient_family_member::Entity::find() .filter(patient_family_member::Column::Id.eq(family_member_id)) + .filter(patient_family_member::Column::PatientId.eq(patient_id)) .filter(patient_family_member::Column::TenantId.eq(tenant_id)) .filter(patient_family_member::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::FamilyMemberNotFound)?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; @@ -444,17 +479,18 @@ pub async fn update_family_member( pub async fn delete_family_member( state: &HealthState, tenant_id: Uuid, - _patient_id: Uuid, + patient_id: Uuid, family_member_id: Uuid, operator_id: Option, ) -> HealthResult<()> { let model = patient_family_member::Entity::find() .filter(patient_family_member::Column::Id.eq(family_member_id)) + .filter(patient_family_member::Column::PatientId.eq(patient_id)) .filter(patient_family_member::Column::TenantId.eq(tenant_id)) .filter(patient_family_member::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::PatientNotFound)?; + .ok_or(HealthError::FamilyMemberNotFound)?; let mut active: patient_family_member::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); @@ -563,3 +599,23 @@ fn model_to_resp(m: patient::Model) -> PatientResp { version: m.version, } } + +/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中 +fn validate_status_transition( + field_name: &str, + current: &str, + new_status: &str, + allowed_transitions: &[(&str, &str)], +) -> HealthResult<()> { + if current == new_status { + return Ok(()); + } + if allowed_transitions.iter().any(|(from, to)| *from == current && *to == new_status) { + Ok(()) + } else { + Err(HealthError::InvalidStatusTransition(format!( + "{}: 不允许从 '{}' 转换到 '{}'", + field_name, current, new_status + ))) + } +} diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs new file mode 100644 index 0000000..406fa19 --- /dev/null +++ b/crates/erp-health/src/service/validation.rs @@ -0,0 +1,108 @@ +//! 通用字段校验 — 枚举白名单、输入格式校验 + +use crate::error::{HealthError, HealthResult}; + +macro_rules! validate_enum { + ($value:expr, $field:expr, [$($allowed:expr),* $(,)?]) => { + { + let v: &str = $value; + let allowed: &[&str] = &[$($allowed),*]; + let mut found = false; + let mut _i = 0; + while _i < allowed.len() { + if allowed[_i] == v { + found = true; + break; + } + _i += 1; + } + if !found { + return Err(HealthError::Validation(format!( + "{}: '{}' 不是有效值,允许值: [{}]", + $field, v, allowed.join(", ") + ))); + } + } + }; +} + +/// patient.gender +pub fn validate_gender(value: &str) -> HealthResult<()> { + validate_enum!(value, "gender", ["male", "female", "other"]); + Ok(()) +} + +/// patient.blood_type +pub fn validate_blood_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "blood_type", [ + "A", "B", "AB", "O", "A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-", + ]); + Ok(()) +} + +/// appointment.appointment_type +pub fn validate_appointment_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "appointment_type", [ + "dialysis", "recheck", "outpatient", "health_checkup", "consultation", + ]); + Ok(()) +} + +/// appointment.status transitions +pub fn validate_appointment_status_transition(current: &str, new: &str) -> HealthResult<()> { + if current == new { + return Ok(()); + } + let allowed = match current { + "pending" => matches!(new, "confirmed" | "cancelled"), + "confirmed" => matches!(new, "completed" | "cancelled" | "no_show"), + _ => false, + }; + if allowed { + Ok(()) + } else { + Err(HealthError::InvalidStatusTransition(format!( + "appointment.status: 不允许从 '{}' 转换到 '{}'", current, new + ))) + } +} + +/// doctor_schedule.period_type +pub fn validate_period_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "period_type", ["am", "pm", "night", "full_day"]); + Ok(()) +} + +/// doctor_schedule.status +pub fn validate_schedule_status(value: &str) -> HealthResult<()> { + validate_enum!(value, "schedule.status", ["enabled", "disabled"]); + Ok(()) +} + +/// follow_up_task.follow_up_type +pub fn validate_follow_up_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "follow_up_type", [ + "phone", "wechat", "visit", "sms", "other", + ]); + Ok(()) +} + +/// consultation.sender_role +pub fn validate_sender_role(value: &str) -> HealthResult<()> { + validate_enum!(value, "sender_role", ["patient", "doctor", "system"]); + Ok(()) +} + +/// consultation.content_type +pub fn validate_content_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "content_type", ["text", "image", "voice", "file"]); + Ok(()) +} + +/// consultation.consultation_type +pub fn validate_consultation_type(value: &str) -> HealthResult<()> { + validate_enum!(value, "consultation_type", [ + "customer_service", "doctor", "nutritionist", "psychologist", + ]); + Ok(()) +}