From b235f67c318c204764d6f5e2268b90b6c99f762b Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 14:09:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor(health):=20=E6=8B=86=E5=88=86=204=20?= =?UTF-8?q?=E4=B8=AA=E5=8D=83=E8=A1=8C=20service=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=BA=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit points_service.rs (1863行) → points_service/ (mod + account + checkin + product + event) patient_service.rs (1118行) → patient_service/ (mod + helper + crud + relation + tag) health_data_service.rs (1056行) → health_data_service/ (mod + vital_signs + lab_report + health_record + alert) stats_service.rs (1117行) → stats_service/ (mod + operations + health + personal + dashboard) 所有公开 API 通过 pub use 保持不变,handler 层无需修改。 cargo check: 0 error, 0 warning cargo test: 232 passed, 0 failed --- .../src/service/health_data_service.rs | 1056 ---------- .../src/service/health_data_service/alert.rs | 157 ++ .../health_data_service/health_record.rs | 227 ++ .../service/health_data_service/lab_report.rs | 407 ++++ .../src/service/health_data_service/mod.rs | 21 + .../health_data_service/vital_signs.rs | 310 +++ .../erp-health/src/service/patient_service.rs | 1118 ---------- .../src/service/patient_service/crud.rs | 395 ++++ .../src/service/patient_service/helper.rs | 91 + .../src/service/patient_service/mod.rs | 22 + .../src/service/patient_service/relation.rs | 488 +++++ .../src/service/patient_service/tag.rs | 195 ++ .../erp-health/src/service/points_service.rs | 1863 ----------------- .../src/service/points_service/account.rs | 250 +++ .../src/service/points_service/checkin.rs | 315 +++ .../src/service/points_service/event.rs | 745 +++++++ .../src/service/points_service/mod.rs | 27 + .../src/service/points_service/product.rs | 616 ++++++ .../erp-health/src/service/stats_service.rs | 1117 ---------- .../src/service/stats_service/dashboard.rs | 331 +++ .../src/service/stats_service/health.rs | 320 +++ .../src/service/stats_service/mod.rs | 33 + .../src/service/stats_service/operations.rs | 186 ++ .../src/service/stats_service/personal.rs | 308 +++ 24 files changed, 5444 insertions(+), 5154 deletions(-) delete mode 100644 crates/erp-health/src/service/health_data_service.rs create mode 100644 crates/erp-health/src/service/health_data_service/alert.rs create mode 100644 crates/erp-health/src/service/health_data_service/health_record.rs create mode 100644 crates/erp-health/src/service/health_data_service/lab_report.rs create mode 100644 crates/erp-health/src/service/health_data_service/mod.rs create mode 100644 crates/erp-health/src/service/health_data_service/vital_signs.rs delete mode 100644 crates/erp-health/src/service/patient_service.rs create mode 100644 crates/erp-health/src/service/patient_service/crud.rs create mode 100644 crates/erp-health/src/service/patient_service/helper.rs create mode 100644 crates/erp-health/src/service/patient_service/mod.rs create mode 100644 crates/erp-health/src/service/patient_service/relation.rs create mode 100644 crates/erp-health/src/service/patient_service/tag.rs delete mode 100644 crates/erp-health/src/service/points_service.rs create mode 100644 crates/erp-health/src/service/points_service/account.rs create mode 100644 crates/erp-health/src/service/points_service/checkin.rs create mode 100644 crates/erp-health/src/service/points_service/event.rs create mode 100644 crates/erp-health/src/service/points_service/mod.rs create mode 100644 crates/erp-health/src/service/points_service/product.rs delete mode 100644 crates/erp-health/src/service/stats_service.rs create mode 100644 crates/erp-health/src/service/stats_service/dashboard.rs create mode 100644 crates/erp-health/src/service/stats_service/health.rs create mode 100644 crates/erp-health/src/service/stats_service/mod.rs create mode 100644 crates/erp-health/src/service/stats_service/operations.rs create mode 100644 crates/erp-health/src/service/stats_service/personal.rs diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs deleted file mode 100644 index 07499de..0000000 --- a/crates/erp-health/src/service/health_data_service.rs +++ /dev/null @@ -1,1056 +0,0 @@ -//! 健康数据 Service — 体征记录、化验报告、体检记录 - -use chrono::Utc; -use erp_core::audit::AuditLog; -use erp_core::audit_service; -use erp_core::crypto as pii; -use erp_core::events::DomainEvent; -use num_traits::ToPrimitive; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; -use uuid::Uuid; - -use erp_core::error::check_version; -use erp_core::types::PaginatedResponse; - -use crate::dto::health_data_dto::*; -use crate::entity::{doctor_profile, health_record, lab_report, patient, patient_doctor_relation, vital_signs}; -use crate::error::{HealthError, HealthResult}; -use crate::service::validation::{validate_record_type, validate_lab_report_status_transition}; -use crate::state::HealthState; - -// --------------------------------------------------------------------------- -// 体征记录 (Vital Signs) -// --------------------------------------------------------------------------- - -pub async fn list_vital_signs( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, page, page_size, "查询体征记录列表"); - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = vital_signs::Entity::find() - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::PatientId.eq(patient_id)) - .filter(vital_signs::Column::DeletedAt.is_null()); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(vital_signs::Column::RecordDate) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - tracing::debug!(total, "体征记录查询结果数量"); - let total_pages = total.div_ceil(limit.max(1)); - let data: Vec = models.into_iter().map(|m| VitalSignsResp { - id: m.id, - patient_id: m.patient_id, - record_date: m.record_date, - source: m.source, - 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_f64().unwrap_or(0.0)), - blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), - body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), - spo2: m.spo2, - blood_sugar_type: m.blood_sugar_type, - 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, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -pub async fn create_vital_signs( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - operator_id: Option, - req: CreateVitalSignsReq, -) -> HealthResult { - tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, "创建体征记录"); - // 校验患者存在 - 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_else(|| { - tracing::error!(patient_id = %patient_id, tenant_id = %tenant_id, "创建体征记录失败:患者不存在"); - HealthError::PatientNotFound - })?; - - let now = Utc::now(); - let alert_req = req.clone(); - let active = vital_signs::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - record_date: Set(req.record_date), - systolic_bp_morning: Set(req.systolic_bp_morning), - diastolic_bp_morning: Set(req.diastolic_bp_morning), - systolic_bp_evening: Set(req.systolic_bp_evening), - diastolic_bp_evening: Set(req.diastolic_bp_evening), - heart_rate: Set(req.heart_rate), - weight: Set(req.weight.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), - blood_sugar: Set(req.blood_sugar.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), - body_temperature: Set(req.body_temperature.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), - spo2: Set(req.spo2), - blood_sugar_type: Set(req.blood_sugar_type), - water_intake_ml: Set(req.water_intake_ml), - urine_output_ml: Set(req.urine_output_ml), - notes: Set(req.notes), - source: Set(req.source.unwrap_or_else(|| "manual".to_string())), - 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?; - tracing::info!(id = %m.id, tenant_id = %tenant_id, patient_id = %patient_id, "体征记录创建成功"); - - // 数据持久化成功后再触发危急值检测 - check_vital_signs_alert(state, tenant_id, patient_id, operator_id, alert_req).await; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "vital_signs.created", "vital_signs") - .with_resource_id(m.id), - &state.db, - ) - .await; - - Ok(VitalSignsResp { - id: m.id, patient_id: m.patient_id, record_date: m.record_date, - source: m.source, - 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_f64().unwrap_or(0.0)), - blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), - body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), - spo2: m.spo2, - blood_sugar_type: m.blood_sugar_type, - 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, - }) -} - -pub async fn update_vital_signs( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - vital_signs_id: Uuid, - operator_id: Option, - req: UpdateVitalSignsReq, - expected_version: i32, -) -> HealthResult { - tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, vital_signs_id = %vital_signs_id, expected_version, "更新体征记录"); - let model = vital_signs::Entity::find() - .filter(vital_signs::Column::Id.eq(vital_signs_id)) - .filter(vital_signs::Column::PatientId.eq(patient_id)) - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| { - tracing::error!(vital_signs_id = %vital_signs_id, tenant_id = %tenant_id, "更新体征记录失败:记录不存在"); - HealthError::VitalSignsNotFound - })?; - let next_ver = check_version(expected_version, model.version).map_err(|e| { - tracing::error!(vital_signs_id = %vital_signs_id, expected_version, db_version = model.version, "更新体征记录失败:版本冲突"); - e - })?; - - // 记录变更前的关键体征值 - let old_values = serde_json::json!({ - "record_date": model.record_date, - "systolic_bp_morning": model.systolic_bp_morning, - "diastolic_bp_morning": model.diastolic_bp_morning, - "systolic_bp_evening": model.systolic_bp_evening, - "diastolic_bp_evening": model.diastolic_bp_evening, - "heart_rate": model.heart_rate, - "weight": model.weight, - "blood_sugar": model.blood_sugar, - "notes": model.notes, - }); - - let mut active: vital_signs::ActiveModel = model.into(); - if let Some(v) = req.record_date { active.record_date = Set(v); } - if let Some(v) = req.systolic_bp_morning { active.systolic_bp_morning = Set(Some(v)); } - if let Some(v) = req.diastolic_bp_morning { active.diastolic_bp_morning = Set(Some(v)); } - if let Some(v) = req.systolic_bp_evening { active.systolic_bp_evening = Set(Some(v)); } - if let Some(v) = req.diastolic_bp_evening { active.diastolic_bp_evening = Set(Some(v)); } - if let Some(v) = req.heart_rate { active.heart_rate = Set(Some(v)); } - if let Some(v) = req.weight { active.weight = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } - if let Some(v) = req.blood_sugar { active.blood_sugar = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } - if let Some(v) = req.body_temperature { active.body_temperature = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } - if let Some(v) = req.spo2 { active.spo2 = Set(Some(v)); } - if let Some(v) = req.blood_sugar_type { active.blood_sugar_type = Set(Some(v)); } - if let Some(v) = req.water_intake_ml { active.water_intake_ml = Set(Some(v)); } - if let Some(v) = req.urine_output_ml { active.urine_output_ml = 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); - - let m = active.update(&state.db).await?; - tracing::info!(id = %m.id, tenant_id = %tenant_id, version = m.version, "体征记录更新成功"); - - // 变更后快照 - let new_values = serde_json::json!({ - "record_date": m.record_date, - "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, - "blood_sugar": m.blood_sugar, - "notes": m.notes, - }); - - // 更新后也触发危急值检测(修改后的值可能触发告警) - let check_req = CreateVitalSignsReq { - record_date: m.record_date, - 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_f64().unwrap_or(0.0)), - blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), - body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), - spo2: m.spo2, - blood_sugar_type: m.blood_sugar_type.clone(), - water_intake_ml: m.water_intake_ml, - urine_output_ml: m.urine_output_ml, - notes: m.notes.clone(), - source: Some(m.source.clone()), - }; - check_vital_signs_alert(state, tenant_id, patient_id, operator_id, check_req).await; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "vital_signs.updated", "vital_signs") - .with_resource_id(m.id) - .with_changes(Some(old_values), Some(new_values)), - &state.db, - ).await; - - Ok(VitalSignsResp { - id: m.id, patient_id: m.patient_id, record_date: m.record_date, - source: m.source, - 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_f64().unwrap_or(0.0)), - blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), - body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), - spo2: m.spo2, - blood_sugar_type: m.blood_sugar_type, - 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, - }) -} - -pub async fn delete_vital_signs( - state: &HealthState, - tenant_id: Uuid, - vital_signs_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - tracing::info!(tenant_id = %tenant_id, vital_signs_id = %vital_signs_id, expected_version, "删除体征记录"); - let model = vital_signs::Entity::find() - .filter(vital_signs::Column::Id.eq(vital_signs_id)) - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| { - tracing::error!(vital_signs_id = %vital_signs_id, tenant_id = %tenant_id, "删除体征记录失败:记录不存在"); - HealthError::VitalSignsNotFound - })?; - - let next_ver = check_version(expected_version, model.version).map_err(|e| { - tracing::error!(vital_signs_id = %vital_signs_id, expected_version, db_version = model.version, "删除体征记录失败:版本冲突"); - e - })?; - - 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(next_ver); - active.update(&state.db).await?; - tracing::info!(vital_signs_id = %vital_signs_id, tenant_id = %tenant_id, "体征记录删除成功"); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "vital_signs.deleted", "vital_signs") - .with_resource_id(vital_signs_id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 化验报告 (Lab Reports) -// --------------------------------------------------------------------------- - -pub async fn list_lab_reports( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, page, page_size, "查询化验报告列表"); - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = lab_report::Entity::find() - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::PatientId.eq(patient_id)) - .filter(lab_report::Column::DeletedAt.is_null()); - - let total = query.clone().count(&state.db).await?; - tracing::debug!(total, "化验报告查询结果数量"); - let models = query - .order_by_desc(lab_report::Column::ReportDate) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - let total_pages = total.div_ceil(limit.max(1)); - let kek = state.crypto.kek(); - let data = models.into_iter().map(|m| { - // 解密 items JSON(加密时存储为 Value::String(ciphertext)) - let items = m.items.as_ref() - .and_then(|v| v.as_str()) - .and_then(|s| pii::decrypt(kek, s).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .or(m.items.clone()); - - // 解密医生备注 - let doctor_notes = m.doctor_notes.as_ref() - .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) - .or(m.doctor_notes.clone()); - - LabReportResp { - id: m.id, patient_id: m.patient_id, report_date: m.report_date, - report_type: m.report_type, source: m.source, - items, image_urls: m.image_urls, doctor_notes, - status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - } - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -pub async fn create_lab_report( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - operator_id: Option, - req: CreateLabReportReq, -) -> HealthResult { - tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, "创建化验报告"); - // 校验患者存在 - 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_else(|| { - tracing::error!(patient_id = %patient_id, tenant_id = %tenant_id, "创建化验报告失败:患者不存在"); - HealthError::PatientNotFound - })?; - - let kek = state.crypto.kek(); - - // PII 加密 - let encrypted_items = req.items.as_ref() - .map(|v| -> HealthResult { - let json_str = serde_json::to_string(v) - .map_err(|e| HealthError::Validation(e.to_string()))?; - Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?)) - }) - .transpose()?; - - let encrypted_doctor_notes = req.doctor_notes.as_ref() - .map(|c| pii::encrypt(kek, c)) - .transpose()?; - - let now = Utc::now(); - let active = lab_report::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - report_date: Set(req.report_date), - report_type: Set(req.report_type), - source: Set(req.source), - items: Set(encrypted_items), - image_urls: Set(req.image_urls), - doctor_notes: Set(encrypted_doctor_notes), - status: Set("pending".to_string()), - reviewed_by: Set(None), - reviewed_at: Set(None), - created_at: Set(now), - updated_at: Set(now), - created_by: Set(operator_id), - updated_by: Set(operator_id), - deleted_at: Set(None), - version: Set(1), - key_version: Set(Some(1)), - }; - let m = active.insert(&state.db).await?; - tracing::info!(id = %m.id, tenant_id = %tenant_id, patient_id = %patient_id, report_type = %m.report_type, "化验报告创建成功"); - - let event = DomainEvent::new( - crate::event::LAB_REPORT_UPLOADED, - tenant_id, - erp_core::events::build_event_payload(serde_json::json!({ "report_id": m.id, "patient_id": m.patient_id, "report_type": m.report_type })), - ); - state.event_bus.publish(event, &state.db).await; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "lab_report.created", "lab_report") - .with_resource_id(m.id), - &state.db, - ).await; - - // 解密返回 - let decrypted_items = m.items.as_ref() - .and_then(|v| v.as_str()) - .and_then(|s| pii::decrypt(kek, s).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .or(m.items); - - let decrypted_doctor_notes = m.doctor_notes.as_ref() - .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) - .or(m.doctor_notes); - - Ok(LabReportResp { - id: m.id, patient_id: m.patient_id, report_date: m.report_date, - report_type: m.report_type, source: m.source, - items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes, - status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn update_lab_report( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - report_id: Uuid, - operator_id: Option, - req: UpdateLabReportReq, - expected_version: i32, -) -> HealthResult { - tracing::info!(tenant_id = %tenant_id, report_id = %report_id, patient_id = %patient_id, expected_version, "更新化验报告"); - let model = lab_report::Entity::find() - .filter(lab_report::Column::Id.eq(report_id)) - .filter(lab_report::Column::PatientId.eq(patient_id)) - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| { - tracing::error!(report_id = %report_id, tenant_id = %tenant_id, "更新化验报告失败:报告不存在"); - HealthError::LabReportNotFound - })?; - let next_ver = check_version(expected_version, model.version).map_err(|e| { - tracing::error!(report_id = %report_id, expected_version, db_version = model.version, "更新化验报告失败:版本冲突"); - e - })?; - - // 记录变更前的关键字段(items 为加密值,记录 meta 信息) - let old_values = serde_json::json!({ - "report_date": model.report_date, - "report_type": model.report_type, - "status": model.status, - "has_items": model.items.is_some(), - "has_image_urls": model.image_urls.is_some(), - "has_doctor_notes": model.doctor_notes.is_some(), - }); - - let mut active: lab_report::ActiveModel = model.into(); - if let Some(v) = req.report_date { active.report_date = Set(v); } - if let Some(v) = req.report_type { active.report_type = Set(v); } - if let Some(v) = req.source { active.source = Set(Some(v)); } - if let Some(v) = req.items { - let kek = state.crypto.kek(); - let encrypted = Some(serde_json::Value::String( - pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())? - )); - active.items = Set(encrypted); - } - if let Some(v) = req.image_urls { active.image_urls = Set(Some(v)); } - if let Some(v) = req.doctor_notes { - let kek = state.crypto.kek(); - let encrypted = pii::encrypt(kek, &v)?; - active.doctor_notes = Set(Some(encrypted)); - } - active.updated_at = Set(Utc::now()); - active.updated_by = Set(operator_id); - active.version = Set(next_ver); - active.key_version = Set(Some(1)); - - let m = active.update(&state.db).await?; - tracing::info!(id = %m.id, tenant_id = %tenant_id, version = m.version, "化验报告更新成功"); - - // 变更后快照 - let new_values = serde_json::json!({ - "report_date": m.report_date, - "report_type": m.report_type, - "status": m.status, - "has_items": m.items.is_some(), - "has_image_urls": m.image_urls.is_some(), - "has_doctor_notes": m.doctor_notes.is_some(), - }); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "lab_report.updated", "lab_report") - .with_resource_id(m.id) - .with_changes(Some(old_values), Some(new_values)), - &state.db, - ).await; - - // 解密返回 - let kek = state.crypto.kek(); - let decrypted_items = m.items.as_ref() - .and_then(|v| v.as_str()) - .and_then(|s| pii::decrypt(kek, s).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .or(m.items); - - let decrypted_doctor_notes = m.doctor_notes.as_ref() - .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) - .or(m.doctor_notes); - - Ok(LabReportResp { - id: m.id, patient_id: m.patient_id, report_date: m.report_date, - report_type: m.report_type, source: m.source, - items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes, - status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn delete_lab_report( - state: &HealthState, - tenant_id: Uuid, - report_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - tracing::info!(tenant_id = %tenant_id, report_id = %report_id, expected_version, "删除化验报告"); - let model = lab_report::Entity::find() - .filter(lab_report::Column::Id.eq(report_id)) - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| { - tracing::error!(report_id = %report_id, tenant_id = %tenant_id, "删除化验报告失败:报告不存在"); - HealthError::LabReportNotFound - })?; - - let next_ver = check_version(expected_version, model.version).map_err(|e| { - tracing::error!(report_id = %report_id, expected_version, db_version = model.version, "删除化验报告失败:版本冲突"); - e - })?; - - 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(next_ver); - active.update(&state.db).await?; - tracing::info!(report_id = %report_id, tenant_id = %tenant_id, "化验报告删除成功"); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "lab_report.deleted", "lab_report") - .with_resource_id(report_id), - &state.db, - ).await; - - Ok(()) -} - -pub async fn review_lab_report( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - report_id: Uuid, - reviewer_id: Uuid, - req: crate::dto::health_data_dto::ReviewLabReportReq, - expected_version: i32, -) -> HealthResult { - tracing::info!(tenant_id = %tenant_id, report_id = %report_id, reviewer_id = %reviewer_id, "审核化验报告"); - let model = lab_report::Entity::find() - .filter(lab_report::Column::Id.eq(report_id)) - .filter(lab_report::Column::PatientId.eq(patient_id)) - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| { - tracing::error!(report_id = %report_id, tenant_id = %tenant_id, "审核化验报告失败:报告不存在"); - HealthError::LabReportNotFound - })?; - - let next_ver = check_version(expected_version, model.version).map_err(|e| { - tracing::error!(report_id = %report_id, expected_version, db_version = model.version, "审核化验报告失败:版本冲突"); - e - })?; - - validate_lab_report_status_transition(&model.status, "reviewed")?; - - let old_status = model.status.clone(); - let mut active: lab_report::ActiveModel = model.into(); - active.status = Set("reviewed".to_string()); - active.reviewed_by = Set(Some(reviewer_id)); - active.reviewed_at = Set(Some(Utc::now())); - if let Some(v) = req.doctor_notes { - let kek = state.crypto.kek(); - let encrypted = pii::encrypt(kek, &v)?; - active.doctor_notes = Set(Some(encrypted)); - } - if let Some(v) = req.items { - let kek = state.crypto.kek(); - let encrypted = Some(serde_json::Value::String( - pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())? - )); - active.items = Set(encrypted); - } - active.updated_at = Set(Utc::now()); - active.updated_by = Set(Some(reviewer_id)); - active.version = Set(next_ver); - active.key_version = Set(Some(1)); - - let m = active.update(&state.db).await?; - tracing::info!(id = %m.id, tenant_id = %tenant_id, old_status = %old_status, new_status = %m.status, "化验报告审核成功"); - - audit_service::record( - AuditLog::new(tenant_id, Some(reviewer_id), "lab_report.reviewed", "lab_report") - .with_resource_id(m.id) - .with_changes( - Some(serde_json::json!({ "status": old_status })), - Some(serde_json::json!({ "status": m.status, "reviewed_by": m.reviewed_by })), - ), - &state.db, - ).await; - - // 发布化验报告审核事件,触发患者通知 - state.event_bus.publish( - DomainEvent::new( - crate::event::LAB_REPORT_REVIEWED, - tenant_id, - erp_core::events::build_event_payload(serde_json::json!({ - "patient_id": patient_id.to_string(), - "report_id": m.id.to_string(), - "report_type": m.report_type, - })), - ), - &state.db, - ).await; - - // 解密返回 - let kek = state.crypto.kek(); - let decrypted_items = m.items.as_ref() - .and_then(|v| v.as_str()) - .and_then(|s| pii::decrypt(kek, s).ok()) - .and_then(|s| serde_json::from_str(&s).ok()) - .or(m.items); - - let decrypted_doctor_notes = m.doctor_notes.as_ref() - .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) - .or(m.doctor_notes); - - Ok(LabReportResp { - id: m.id, patient_id: m.patient_id, report_date: m.report_date, - report_type: m.report_type, source: m.source, - items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes, - status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -// --------------------------------------------------------------------------- -// 体检记录 (Health Records) -// --------------------------------------------------------------------------- - -pub async fn list_health_records( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, page, page_size, "查询体检记录列表"); - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = health_record::Entity::find() - .filter(health_record::Column::TenantId.eq(tenant_id)) - .filter(health_record::Column::PatientId.eq(patient_id)) - .filter(health_record::Column::DeletedAt.is_null()); - - let total = query.clone().count(&state.db).await?; - tracing::debug!(total, "体检记录查询结果数量"); - let models = query - .order_by_desc(health_record::Column::RecordDate) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - let total_pages = total.div_ceil(limit.max(1)); - let data = models.into_iter().map(|m| HealthRecordResp { - id: m.id, patient_id: m.patient_id, record_type: m.record_type, - record_date: m.record_date, source: m.source, - overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, - notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -pub async fn create_health_record( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - operator_id: Option, - req: CreateHealthRecordReq, -) -> HealthResult { - tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, "创建体检记录"); - // 校验患者存在 - 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_else(|| { - tracing::error!(patient_id = %patient_id, tenant_id = %tenant_id, "创建体检记录失败:患者不存在"); - 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(record_type), - record_date: Set(req.record_date), - source: Set(req.source), - overall_assessment: Set(req.overall_assessment), - report_file_url: Set(req.report_file_url), - 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?; - tracing::info!(id = %m.id, tenant_id = %tenant_id, patient_id = %patient_id, record_type = %m.record_type, "体检记录创建成功"); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "health_record.created", "health_record") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(HealthRecordResp { - id: m.id, patient_id: m.patient_id, record_type: m.record_type, - record_date: m.record_date, source: m.source, - overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, - notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn update_health_record( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - record_id: Uuid, - operator_id: Option, - req: UpdateHealthRecordReq, - expected_version: i32, -) -> HealthResult { - tracing::info!(tenant_id = %tenant_id, record_id = %record_id, patient_id = %patient_id, expected_version, "更新体检记录"); - let model = health_record::Entity::find() - .filter(health_record::Column::Id.eq(record_id)) - .filter(health_record::Column::PatientId.eq(patient_id)) - .filter(health_record::Column::TenantId.eq(tenant_id)) - .filter(health_record::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| { - tracing::error!(record_id = %record_id, tenant_id = %tenant_id, "更新体检记录失败:记录不存在"); - HealthError::HealthRecordNotFound - })?; - let next_ver = check_version(expected_version, model.version).map_err(|e| { - tracing::error!(record_id = %record_id, expected_version, db_version = model.version, "更新体检记录失败:版本冲突"); - e - })?; - - // 记录变更前的关键字段 - let old_values = serde_json::json!({ - "record_type": model.record_type, - "record_date": model.record_date, - "overall_assessment": model.overall_assessment, - "notes": model.notes, - }); - - let mut active: health_record::ActiveModel = model.into(); - 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); - - let m = active.update(&state.db).await?; - tracing::info!(id = %m.id, tenant_id = %tenant_id, version = m.version, "体检记录更新成功"); - - // 变更后快照 - let new_values = serde_json::json!({ - "record_type": m.record_type, - "record_date": m.record_date, - "overall_assessment": m.overall_assessment, - "notes": m.notes, - }); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "health_record.updated", "health_record") - .with_resource_id(m.id) - .with_changes(Some(old_values), Some(new_values)), - &state.db, - ).await; - - Ok(HealthRecordResp { - id: m.id, patient_id: m.patient_id, record_type: m.record_type, - record_date: m.record_date, source: m.source, - overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, - notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn delete_health_record( - state: &HealthState, - tenant_id: Uuid, - record_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - tracing::info!(tenant_id = %tenant_id, record_id = %record_id, expected_version, "删除体检记录"); - let model = health_record::Entity::find() - .filter(health_record::Column::Id.eq(record_id)) - .filter(health_record::Column::TenantId.eq(tenant_id)) - .filter(health_record::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| { - tracing::error!(record_id = %record_id, tenant_id = %tenant_id, "删除体检记录失败:记录不存在"); - HealthError::HealthRecordNotFound - })?; - - let next_ver = check_version(expected_version, model.version).map_err(|e| { - tracing::error!(record_id = %record_id, expected_version, db_version = model.version, "删除体检记录失败:版本冲突"); - e - })?; - - 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(next_ver); - active.update(&state.db).await?; - tracing::info!(record_id = %record_id, tenant_id = %tenant_id, "体检记录删除成功"); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "health_record.deleted", "health_record") - .with_resource_id(record_id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 危急值预警检测 -// --------------------------------------------------------------------------- - -/// 检查体征数据中的危急值,发布 `health_data.critical_alert` 事件。 -/// -/// 阈值从 `critical_value_threshold` 表加载,支持按科室/年龄差异化配置。 -/// 事件 payload 包含:患者信息、责任医生、操作人信息、告警详情。 -async fn check_vital_signs_alert( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - operator_id: Option, - req: CreateVitalSignsReq, -) { - // 从数据库加载阈值配置 - let thresholds = match crate::service::critical_value_threshold_service::find_thresholds( - &state.db, tenant_id, - ) - .await - { - Ok(t) if !t.is_empty() => t, - Ok(_) => { - tracing::warn!(tenant_id = %tenant_id, "无危急值阈值配置,跳过告警检测"); - return; - } - Err(e) => { - tracing::warn!(error = %e, "加载危急值阈值失败,跳过告警检测"); - return; - } - }; - - let mut alerts: Vec = Vec::new(); - - // 收缩压危急值 - if let Some(sbp) = req.systolic_bp_morning.or(req.systolic_bp_evening) { - check_indicator(&thresholds, "systolic_bp", sbp as f64, &mut alerts); - } - - // 舒张压危急值 - if let Some(dbp) = req.diastolic_bp_morning.or(req.diastolic_bp_evening) { - check_indicator(&thresholds, "diastolic_bp", dbp as f64, &mut alerts); - } - - // 心率危急值 - if let Some(hr) = req.heart_rate { - check_indicator(&thresholds, "heart_rate", hr as f64, &mut alerts); - } - - // 血糖危急值 - if let Some(bs) = req.blood_sugar { - check_indicator(&thresholds, "blood_sugar", bs.to_f64().unwrap_or(0.0), &mut alerts); - } - - if alerts.is_empty() { - return; - } - - // 查询患者信息 - let patient_model = patient::Entity::find_by_id(patient_id) - .one(&state.db) - .await - .ok() - .flatten(); - let patient_name = patient_model - .as_ref() - .map(|p| p.name.as_str()) - .unwrap_or("未知患者"); - - // 查询责任医生(通过 patient_doctor_relation 的 attending 类型) - let attending_relation = patient_doctor_relation::Entity::find() - .filter(patient_doctor_relation::Column::PatientId.eq(patient_id)) - .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) - .filter(patient_doctor_relation::Column::DeletedAt.is_null()) - .filter(patient_doctor_relation::Column::RelationshipType.eq("attending")) - .one(&state.db) - .await - .ok() - .flatten(); - - let doctor_user_id: Option = if let Some(rel) = attending_relation { - doctor_profile::Entity::find_by_id(rel.doctor_id) - .one(&state.db) - .await - .ok() - .flatten() - .and_then(|d| d.user_id) - } else { - None - }; - - for alert in &alerts { - let mut payload = serde_json::json!({ - "patient_id": patient_id, - "patient_name": patient_name, - "operator_id": operator_id, - "alert": alert, - }); - if let Some(did) = doctor_user_id { - payload["doctor_user_id"] = serde_json::json!(did); - } - - let event = DomainEvent::new( - crate::event::HEALTH_DATA_CRITICAL_ALERT, - tenant_id, - erp_core::events::build_event_payload(payload), - ); - state.event_bus.publish(event, &state.db).await; - tracing::warn!( - patient_id = %patient_id, - tenant_id = %tenant_id, - indicator = %alert["indicator"], - value = %alert["value"], - "体征危急值预警已发布" - ); - } -} - -/// 根据阈值配置检查单个指标值,匹配则添加到 alerts。 -fn check_indicator( - thresholds: &[crate::entity::critical_value_threshold::Model], - indicator: &str, - value: f64, - alerts: &mut Vec, -) { - for t in thresholds { - if t.indicator != indicator { - continue; - } - let triggered = match t.direction.as_str() { - "high" => value >= t.threshold_value, - "low" => value <= t.threshold_value, - _ => false, - }; - if triggered { - alerts.push(serde_json::json!({ - "indicator": indicator, - "value": value, - "threshold": t.threshold_value, - "level": t.level, - "direction": t.direction, - })); - return; - } - } -} diff --git a/crates/erp-health/src/service/health_data_service/alert.rs b/crates/erp-health/src/service/health_data_service/alert.rs new file mode 100644 index 0000000..d19f844 --- /dev/null +++ b/crates/erp-health/src/service/health_data_service/alert.rs @@ -0,0 +1,157 @@ +//! 健康数据 Service — 危急值预警检测 + +use erp_core::events::DomainEvent; +use num_traits::ToPrimitive; +use sea_orm::entity::prelude::*; +use uuid::Uuid; + +use crate::dto::health_data_dto::CreateVitalSignsReq; +use crate::entity::{doctor_profile, patient, patient_doctor_relation}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 危急值预警检测 +// --------------------------------------------------------------------------- + +/// 检查体征数据中的危急值,发布 `health_data.critical_alert` 事件。 +/// +/// 阈值从 `critical_value_threshold` 表加载,支持按科室/年龄差异化配置。 +/// 事件 payload 包含:患者信息、责任医生、操作人信息、告警详情。 +pub(crate) async fn check_vital_signs_alert( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, + req: CreateVitalSignsReq, +) { + // 从数据库加载阈值配置 + let thresholds = match crate::service::critical_value_threshold_service::find_thresholds( + &state.db, tenant_id, + ) + .await + { + Ok(t) if !t.is_empty() => t, + Ok(_) => { + tracing::warn!(tenant_id = %tenant_id, "无危急值阈值配置,跳过告警检测"); + return; + } + Err(e) => { + tracing::warn!(error = %e, "加载危急值阈值失败,跳过告警检测"); + return; + } + }; + + let mut alerts: Vec = Vec::new(); + + // 收缩压危急值 + if let Some(sbp) = req.systolic_bp_morning.or(req.systolic_bp_evening) { + check_indicator(&thresholds, "systolic_bp", sbp as f64, &mut alerts); + } + + // 舒张压危急值 + if let Some(dbp) = req.diastolic_bp_morning.or(req.diastolic_bp_evening) { + check_indicator(&thresholds, "diastolic_bp", dbp as f64, &mut alerts); + } + + // 心率危急值 + if let Some(hr) = req.heart_rate { + check_indicator(&thresholds, "heart_rate", hr as f64, &mut alerts); + } + + // 血糖危急值 + if let Some(bs) = req.blood_sugar { + check_indicator(&thresholds, "blood_sugar", bs.to_f64().unwrap_or(0.0), &mut alerts); + } + + if alerts.is_empty() { + return; + } + + // 查询患者信息 + let patient_model = patient::Entity::find_by_id(patient_id) + .one(&state.db) + .await + .ok() + .flatten(); + let patient_name = patient_model + .as_ref() + .map(|p| p.name.as_str()) + .unwrap_or("未知患者"); + + // 查询责任医生(通过 patient_doctor_relation 的 attending 类型) + let attending_relation = patient_doctor_relation::Entity::find() + .filter(patient_doctor_relation::Column::PatientId.eq(patient_id)) + .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_doctor_relation::Column::DeletedAt.is_null()) + .filter(patient_doctor_relation::Column::RelationshipType.eq("attending")) + .one(&state.db) + .await + .ok() + .flatten(); + + let doctor_user_id: Option = if let Some(rel) = attending_relation { + doctor_profile::Entity::find_by_id(rel.doctor_id) + .one(&state.db) + .await + .ok() + .flatten() + .and_then(|d| d.user_id) + } else { + None + }; + + for alert in &alerts { + let mut payload = serde_json::json!({ + "patient_id": patient_id, + "patient_name": patient_name, + "operator_id": operator_id, + "alert": alert, + }); + if let Some(did) = doctor_user_id { + payload["doctor_user_id"] = serde_json::json!(did); + } + + let event = DomainEvent::new( + crate::event::HEALTH_DATA_CRITICAL_ALERT, + tenant_id, + erp_core::events::build_event_payload(payload), + ); + state.event_bus.publish(event, &state.db).await; + tracing::warn!( + patient_id = %patient_id, + tenant_id = %tenant_id, + indicator = %alert["indicator"], + value = %alert["value"], + "体征危急值预警已发布" + ); + } +} + +/// 根据阈值配置检查单个指标值,匹配则添加到 alerts。 +fn check_indicator( + thresholds: &[crate::entity::critical_value_threshold::Model], + indicator: &str, + value: f64, + alerts: &mut Vec, +) { + for t in thresholds { + if t.indicator != indicator { + continue; + } + let triggered = match t.direction.as_str() { + "high" => value >= t.threshold_value, + "low" => value <= t.threshold_value, + _ => false, + }; + if triggered { + alerts.push(serde_json::json!({ + "indicator": indicator, + "value": value, + "threshold": t.threshold_value, + "level": t.level, + "direction": t.direction, + })); + return; + } + } +} diff --git a/crates/erp-health/src/service/health_data_service/health_record.rs b/crates/erp-health/src/service/health_data_service/health_record.rs new file mode 100644 index 0000000..7b9cc6a --- /dev/null +++ b/crates/erp-health/src/service/health_data_service/health_record.rs @@ -0,0 +1,227 @@ +//! 健康数据 Service — 体检记录 CRUD + +use chrono::Utc; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::health_data_dto::*; +use crate::entity::{patient, health_record}; +use crate::error::{HealthError, HealthResult}; +use crate::service::validation::validate_record_type; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 体检记录 (Health Records) +// --------------------------------------------------------------------------- + +pub async fn list_health_records( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, page, page_size, "查询体检记录列表"); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = health_record::Entity::find() + .filter(health_record::Column::TenantId.eq(tenant_id)) + .filter(health_record::Column::PatientId.eq(patient_id)) + .filter(health_record::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + tracing::debug!(total, "体检记录查询结果数量"); + let models = query + .order_by_desc(health_record::Column::RecordDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| HealthRecordResp { + id: m.id, patient_id: m.patient_id, record_type: m.record_type, + record_date: m.record_date, source: m.source, + overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, + notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn create_health_record( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, + req: CreateHealthRecordReq, +) -> HealthResult { + tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, "创建体检记录"); + // 校验患者存在 + 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_else(|| { + tracing::error!(patient_id = %patient_id, tenant_id = %tenant_id, "创建体检记录失败:患者不存在"); + 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(record_type), + record_date: Set(req.record_date), + source: Set(req.source), + overall_assessment: Set(req.overall_assessment), + report_file_url: Set(req.report_file_url), + 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?; + tracing::info!(id = %m.id, tenant_id = %tenant_id, patient_id = %patient_id, record_type = %m.record_type, "体检记录创建成功"); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "health_record.created", "health_record") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(HealthRecordResp { + id: m.id, patient_id: m.patient_id, record_type: m.record_type, + record_date: m.record_date, source: m.source, + overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, + notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn update_health_record( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + record_id: Uuid, + operator_id: Option, + req: UpdateHealthRecordReq, + expected_version: i32, +) -> HealthResult { + tracing::info!(tenant_id = %tenant_id, record_id = %record_id, patient_id = %patient_id, expected_version, "更新体检记录"); + let model = health_record::Entity::find() + .filter(health_record::Column::Id.eq(record_id)) + .filter(health_record::Column::PatientId.eq(patient_id)) + .filter(health_record::Column::TenantId.eq(tenant_id)) + .filter(health_record::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| { + tracing::error!(record_id = %record_id, tenant_id = %tenant_id, "更新体检记录失败:记录不存在"); + HealthError::HealthRecordNotFound + })?; + let next_ver = check_version(expected_version, model.version).map_err(|e| { + tracing::error!(record_id = %record_id, expected_version, db_version = model.version, "更新体检记录失败:版本冲突"); + e + })?; + + // 记录变更前的关键字段 + let old_values = serde_json::json!({ + "record_type": model.record_type, + "record_date": model.record_date, + "overall_assessment": model.overall_assessment, + "notes": model.notes, + }); + + let mut active: health_record::ActiveModel = model.into(); + 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); + + let m = active.update(&state.db).await?; + tracing::info!(id = %m.id, tenant_id = %tenant_id, version = m.version, "体检记录更新成功"); + + // 变更后快照 + let new_values = serde_json::json!({ + "record_type": m.record_type, + "record_date": m.record_date, + "overall_assessment": m.overall_assessment, + "notes": m.notes, + }); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "health_record.updated", "health_record") + .with_resource_id(m.id) + .with_changes(Some(old_values), Some(new_values)), + &state.db, + ).await; + + Ok(HealthRecordResp { + id: m.id, patient_id: m.patient_id, record_type: m.record_type, + record_date: m.record_date, source: m.source, + overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, + notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn delete_health_record( + state: &HealthState, + tenant_id: Uuid, + record_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + tracing::info!(tenant_id = %tenant_id, record_id = %record_id, expected_version, "删除体检记录"); + let model = health_record::Entity::find() + .filter(health_record::Column::Id.eq(record_id)) + .filter(health_record::Column::TenantId.eq(tenant_id)) + .filter(health_record::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| { + tracing::error!(record_id = %record_id, tenant_id = %tenant_id, "删除体检记录失败:记录不存在"); + HealthError::HealthRecordNotFound + })?; + + let next_ver = check_version(expected_version, model.version).map_err(|e| { + tracing::error!(record_id = %record_id, expected_version, db_version = model.version, "删除体检记录失败:版本冲突"); + e + })?; + + 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(next_ver); + active.update(&state.db).await?; + tracing::info!(record_id = %record_id, tenant_id = %tenant_id, "体检记录删除成功"); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "health_record.deleted", "health_record") + .with_resource_id(record_id), + &state.db, + ).await; + + Ok(()) +} diff --git a/crates/erp-health/src/service/health_data_service/lab_report.rs b/crates/erp-health/src/service/health_data_service/lab_report.rs new file mode 100644 index 0000000..995402b --- /dev/null +++ b/crates/erp-health/src/service/health_data_service/lab_report.rs @@ -0,0 +1,407 @@ +//! 健康数据 Service — 化验报告 CRUD + +use chrono::Utc; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::crypto as pii; +use erp_core::events::DomainEvent; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::health_data_dto::*; +use crate::entity::{patient, lab_report}; +use crate::error::{HealthError, HealthResult}; +use crate::service::validation::validate_lab_report_status_transition; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 化验报告 (Lab Reports) +// --------------------------------------------------------------------------- + +pub async fn list_lab_reports( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, page, page_size, "查询化验报告列表"); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::PatientId.eq(patient_id)) + .filter(lab_report::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + tracing::debug!(total, "化验报告查询结果数量"); + let models = query + .order_by_desc(lab_report::Column::ReportDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let kek = state.crypto.kek(); + let data = models.into_iter().map(|m| { + // 解密 items JSON(加密时存储为 Value::String(ciphertext)) + let items = m.items.as_ref() + .and_then(|v| v.as_str()) + .and_then(|s| pii::decrypt(kek, s).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .or(m.items.clone()); + + // 解密医生备注 + let doctor_notes = m.doctor_notes.as_ref() + .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) + .or(m.doctor_notes.clone()); + + LabReportResp { + id: m.id, patient_id: m.patient_id, report_date: m.report_date, + report_type: m.report_type, source: m.source, + items, image_urls: m.image_urls, doctor_notes, + status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + } + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn create_lab_report( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, + req: CreateLabReportReq, +) -> HealthResult { + tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, "创建化验报告"); + // 校验患者存在 + 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_else(|| { + tracing::error!(patient_id = %patient_id, tenant_id = %tenant_id, "创建化验报告失败:患者不存在"); + HealthError::PatientNotFound + })?; + + let kek = state.crypto.kek(); + + // PII 加密 + let encrypted_items = req.items.as_ref() + .map(|v| -> HealthResult { + let json_str = serde_json::to_string(v) + .map_err(|e| HealthError::Validation(e.to_string()))?; + Ok(serde_json::Value::String(pii::encrypt(kek, &json_str)?)) + }) + .transpose()?; + + let encrypted_doctor_notes = req.doctor_notes.as_ref() + .map(|c| pii::encrypt(kek, c)) + .transpose()?; + + let now = Utc::now(); + let active = lab_report::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + report_date: Set(req.report_date), + report_type: Set(req.report_type), + source: Set(req.source), + items: Set(encrypted_items), + image_urls: Set(req.image_urls), + doctor_notes: Set(encrypted_doctor_notes), + status: Set("pending".to_string()), + reviewed_by: Set(None), + reviewed_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + key_version: Set(Some(1)), + }; + let m = active.insert(&state.db).await?; + tracing::info!(id = %m.id, tenant_id = %tenant_id, patient_id = %patient_id, report_type = %m.report_type, "化验报告创建成功"); + + let event = DomainEvent::new( + crate::event::LAB_REPORT_UPLOADED, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ "report_id": m.id, "patient_id": m.patient_id, "report_type": m.report_type })), + ); + state.event_bus.publish(event, &state.db).await; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "lab_report.created", "lab_report") + .with_resource_id(m.id), + &state.db, + ).await; + + // 解密返回 + let decrypted_items = m.items.as_ref() + .and_then(|v| v.as_str()) + .and_then(|s| pii::decrypt(kek, s).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .or(m.items); + + let decrypted_doctor_notes = m.doctor_notes.as_ref() + .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) + .or(m.doctor_notes); + + Ok(LabReportResp { + id: m.id, patient_id: m.patient_id, report_date: m.report_date, + report_type: m.report_type, source: m.source, + items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes, + status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn update_lab_report( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + report_id: Uuid, + operator_id: Option, + req: UpdateLabReportReq, + expected_version: i32, +) -> HealthResult { + tracing::info!(tenant_id = %tenant_id, report_id = %report_id, patient_id = %patient_id, expected_version, "更新化验报告"); + let model = lab_report::Entity::find() + .filter(lab_report::Column::Id.eq(report_id)) + .filter(lab_report::Column::PatientId.eq(patient_id)) + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| { + tracing::error!(report_id = %report_id, tenant_id = %tenant_id, "更新化验报告失败:报告不存在"); + HealthError::LabReportNotFound + })?; + let next_ver = check_version(expected_version, model.version).map_err(|e| { + tracing::error!(report_id = %report_id, expected_version, db_version = model.version, "更新化验报告失败:版本冲突"); + e + })?; + + // 记录变更前的关键字段(items 为加密值,记录 meta 信息) + let old_values = serde_json::json!({ + "report_date": model.report_date, + "report_type": model.report_type, + "status": model.status, + "has_items": model.items.is_some(), + "has_image_urls": model.image_urls.is_some(), + "has_doctor_notes": model.doctor_notes.is_some(), + }); + + let mut active: lab_report::ActiveModel = model.into(); + if let Some(v) = req.report_date { active.report_date = Set(v); } + if let Some(v) = req.report_type { active.report_type = Set(v); } + if let Some(v) = req.source { active.source = Set(Some(v)); } + if let Some(v) = req.items { + let kek = state.crypto.kek(); + let encrypted = Some(serde_json::Value::String( + pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())? + )); + active.items = Set(encrypted); + } + if let Some(v) = req.image_urls { active.image_urls = Set(Some(v)); } + if let Some(v) = req.doctor_notes { + let kek = state.crypto.kek(); + let encrypted = pii::encrypt(kek, &v)?; + active.doctor_notes = Set(Some(encrypted)); + } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.key_version = Set(Some(1)); + + let m = active.update(&state.db).await?; + tracing::info!(id = %m.id, tenant_id = %tenant_id, version = m.version, "化验报告更新成功"); + + // 变更后快照 + let new_values = serde_json::json!({ + "report_date": m.report_date, + "report_type": m.report_type, + "status": m.status, + "has_items": m.items.is_some(), + "has_image_urls": m.image_urls.is_some(), + "has_doctor_notes": m.doctor_notes.is_some(), + }); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "lab_report.updated", "lab_report") + .with_resource_id(m.id) + .with_changes(Some(old_values), Some(new_values)), + &state.db, + ).await; + + // 解密返回 + let kek = state.crypto.kek(); + let decrypted_items = m.items.as_ref() + .and_then(|v| v.as_str()) + .and_then(|s| pii::decrypt(kek, s).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .or(m.items); + + let decrypted_doctor_notes = m.doctor_notes.as_ref() + .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) + .or(m.doctor_notes); + + Ok(LabReportResp { + id: m.id, patient_id: m.patient_id, report_date: m.report_date, + report_type: m.report_type, source: m.source, + items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes, + status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn delete_lab_report( + state: &HealthState, + tenant_id: Uuid, + report_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + tracing::info!(tenant_id = %tenant_id, report_id = %report_id, expected_version, "删除化验报告"); + let model = lab_report::Entity::find() + .filter(lab_report::Column::Id.eq(report_id)) + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| { + tracing::error!(report_id = %report_id, tenant_id = %tenant_id, "删除化验报告失败:报告不存在"); + HealthError::LabReportNotFound + })?; + + let next_ver = check_version(expected_version, model.version).map_err(|e| { + tracing::error!(report_id = %report_id, expected_version, db_version = model.version, "删除化验报告失败:版本冲突"); + e + })?; + + 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(next_ver); + active.update(&state.db).await?; + tracing::info!(report_id = %report_id, tenant_id = %tenant_id, "化验报告删除成功"); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "lab_report.deleted", "lab_report") + .with_resource_id(report_id), + &state.db, + ).await; + + Ok(()) +} + +pub async fn review_lab_report( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + report_id: Uuid, + reviewer_id: Uuid, + req: crate::dto::health_data_dto::ReviewLabReportReq, + expected_version: i32, +) -> HealthResult { + tracing::info!(tenant_id = %tenant_id, report_id = %report_id, reviewer_id = %reviewer_id, "审核化验报告"); + let model = lab_report::Entity::find() + .filter(lab_report::Column::Id.eq(report_id)) + .filter(lab_report::Column::PatientId.eq(patient_id)) + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| { + tracing::error!(report_id = %report_id, tenant_id = %tenant_id, "审核化验报告失败:报告不存在"); + HealthError::LabReportNotFound + })?; + + let next_ver = check_version(expected_version, model.version).map_err(|e| { + tracing::error!(report_id = %report_id, expected_version, db_version = model.version, "审核化验报告失败:版本冲突"); + e + })?; + + validate_lab_report_status_transition(&model.status, "reviewed")?; + + let old_status = model.status.clone(); + let mut active: lab_report::ActiveModel = model.into(); + active.status = Set("reviewed".to_string()); + active.reviewed_by = Set(Some(reviewer_id)); + active.reviewed_at = Set(Some(Utc::now())); + if let Some(v) = req.doctor_notes { + let kek = state.crypto.kek(); + let encrypted = pii::encrypt(kek, &v)?; + active.doctor_notes = Set(Some(encrypted)); + } + if let Some(v) = req.items { + let kek = state.crypto.kek(); + let encrypted = Some(serde_json::Value::String( + pii::encrypt(kek, &serde_json::to_string(&v).unwrap_or_default())? + )); + active.items = Set(encrypted); + } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(Some(reviewer_id)); + active.version = Set(next_ver); + active.key_version = Set(Some(1)); + + let m = active.update(&state.db).await?; + tracing::info!(id = %m.id, tenant_id = %tenant_id, old_status = %old_status, new_status = %m.status, "化验报告审核成功"); + + audit_service::record( + AuditLog::new(tenant_id, Some(reviewer_id), "lab_report.reviewed", "lab_report") + .with_resource_id(m.id) + .with_changes( + Some(serde_json::json!({ "status": old_status })), + Some(serde_json::json!({ "status": m.status, "reviewed_by": m.reviewed_by })), + ), + &state.db, + ).await; + + // 发布化验报告审核事件,触发患者通知 + state.event_bus.publish( + DomainEvent::new( + crate::event::LAB_REPORT_REVIEWED, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "patient_id": patient_id.to_string(), + "report_id": m.id.to_string(), + "report_type": m.report_type, + })), + ), + &state.db, + ).await; + + // 解密返回 + let kek = state.crypto.kek(); + let decrypted_items = m.items.as_ref() + .and_then(|v| v.as_str()) + .and_then(|s| pii::decrypt(kek, s).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .or(m.items); + + let decrypted_doctor_notes = m.doctor_notes.as_ref() + .map(|c| pii::decrypt(kek, c).unwrap_or_else(|_| c.clone())) + .or(m.doctor_notes); + + Ok(LabReportResp { + id: m.id, patient_id: m.patient_id, report_date: m.report_date, + report_type: m.report_type, source: m.source, + items: decrypted_items, image_urls: m.image_urls, doctor_notes: decrypted_doctor_notes, + status: m.status, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} diff --git a/crates/erp-health/src/service/health_data_service/mod.rs b/crates/erp-health/src/service/health_data_service/mod.rs new file mode 100644 index 0000000..9991762 --- /dev/null +++ b/crates/erp-health/src/service/health_data_service/mod.rs @@ -0,0 +1,21 @@ +//! 健康数据 Service — 体征记录、化验报告、体检记录 +//! +//! 按 4 个功能域组织: +//! - `vital_signs` — 体征记录 CRUD +//! - `lab_report` — 化验报告 CRUD + 审核 +//! - `health_record` — 体检记录 CRUD +//! - `alert` — 危急值预警检测 + +mod alert; +mod health_record; +mod lab_report; +mod vital_signs; + +// 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变 +pub use vital_signs::{list_vital_signs, create_vital_signs, update_vital_signs, delete_vital_signs}; +pub use lab_report::{ + list_lab_reports, create_lab_report, update_lab_report, delete_lab_report, review_lab_report, +}; +pub use health_record::{ + list_health_records, create_health_record, update_health_record, delete_health_record, +}; diff --git a/crates/erp-health/src/service/health_data_service/vital_signs.rs b/crates/erp-health/src/service/health_data_service/vital_signs.rs new file mode 100644 index 0000000..4551b45 --- /dev/null +++ b/crates/erp-health/src/service/health_data_service/vital_signs.rs @@ -0,0 +1,310 @@ +//! 健康数据 Service — 体征记录 CRUD + +use chrono::Utc; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use num_traits::ToPrimitive; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::health_data_dto::*; +use crate::entity::{patient, vital_signs}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +use super::alert::check_vital_signs_alert; + +// --------------------------------------------------------------------------- +// 体征记录 (Vital Signs) +// --------------------------------------------------------------------------- + +pub async fn list_vital_signs( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, page, page_size, "查询体征记录列表"); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(vital_signs::Column::RecordDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + tracing::debug!(total, "体征记录查询结果数量"); + let total_pages = total.div_ceil(limit.max(1)); + let data: Vec = models.into_iter().map(|m| VitalSignsResp { + id: m.id, + patient_id: m.patient_id, + record_date: m.record_date, + source: m.source, + 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_f64().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), + body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), + spo2: m.spo2, + blood_sugar_type: m.blood_sugar_type, + 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, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn create_vital_signs( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, + req: CreateVitalSignsReq, +) -> HealthResult { + tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, "创建体征记录"); + // 校验患者存在 + 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_else(|| { + tracing::error!(patient_id = %patient_id, tenant_id = %tenant_id, "创建体征记录失败:患者不存在"); + HealthError::PatientNotFound + })?; + + let now = Utc::now(); + let alert_req = req.clone(); + let active = vital_signs::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + record_date: Set(req.record_date), + systolic_bp_morning: Set(req.systolic_bp_morning), + diastolic_bp_morning: Set(req.diastolic_bp_morning), + systolic_bp_evening: Set(req.systolic_bp_evening), + diastolic_bp_evening: Set(req.diastolic_bp_evening), + heart_rate: Set(req.heart_rate), + weight: Set(req.weight.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), + blood_sugar: Set(req.blood_sugar.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), + body_temperature: Set(req.body_temperature.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), + spo2: Set(req.spo2), + blood_sugar_type: Set(req.blood_sugar_type), + water_intake_ml: Set(req.water_intake_ml), + urine_output_ml: Set(req.urine_output_ml), + notes: Set(req.notes), + source: Set(req.source.unwrap_or_else(|| "manual".to_string())), + 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?; + tracing::info!(id = %m.id, tenant_id = %tenant_id, patient_id = %patient_id, "体征记录创建成功"); + + // 数据持久化成功后再触发危急值检测 + check_vital_signs_alert(state, tenant_id, patient_id, operator_id, alert_req).await; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "vital_signs.created", "vital_signs") + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(VitalSignsResp { + id: m.id, patient_id: m.patient_id, record_date: m.record_date, + source: m.source, + 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_f64().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), + body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), + spo2: m.spo2, + blood_sugar_type: m.blood_sugar_type, + 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, + }) +} + +pub async fn update_vital_signs( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + vital_signs_id: Uuid, + operator_id: Option, + req: UpdateVitalSignsReq, + expected_version: i32, +) -> HealthResult { + tracing::info!(tenant_id = %tenant_id, patient_id = %patient_id, vital_signs_id = %vital_signs_id, expected_version, "更新体征记录"); + let model = vital_signs::Entity::find() + .filter(vital_signs::Column::Id.eq(vital_signs_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| { + tracing::error!(vital_signs_id = %vital_signs_id, tenant_id = %tenant_id, "更新体征记录失败:记录不存在"); + HealthError::VitalSignsNotFound + })?; + let next_ver = check_version(expected_version, model.version).map_err(|e| { + tracing::error!(vital_signs_id = %vital_signs_id, expected_version, db_version = model.version, "更新体征记录失败:版本冲突"); + e + })?; + + // 记录变更前的关键体征值 + let old_values = serde_json::json!({ + "record_date": model.record_date, + "systolic_bp_morning": model.systolic_bp_morning, + "diastolic_bp_morning": model.diastolic_bp_morning, + "systolic_bp_evening": model.systolic_bp_evening, + "diastolic_bp_evening": model.diastolic_bp_evening, + "heart_rate": model.heart_rate, + "weight": model.weight, + "blood_sugar": model.blood_sugar, + "notes": model.notes, + }); + + let mut active: vital_signs::ActiveModel = model.into(); + if let Some(v) = req.record_date { active.record_date = Set(v); } + if let Some(v) = req.systolic_bp_morning { active.systolic_bp_morning = Set(Some(v)); } + if let Some(v) = req.diastolic_bp_morning { active.diastolic_bp_morning = Set(Some(v)); } + if let Some(v) = req.systolic_bp_evening { active.systolic_bp_evening = Set(Some(v)); } + if let Some(v) = req.diastolic_bp_evening { active.diastolic_bp_evening = Set(Some(v)); } + if let Some(v) = req.heart_rate { active.heart_rate = Set(Some(v)); } + if let Some(v) = req.weight { active.weight = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } + if let Some(v) = req.blood_sugar { active.blood_sugar = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } + if let Some(v) = req.body_temperature { active.body_temperature = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } + if let Some(v) = req.spo2 { active.spo2 = Set(Some(v)); } + if let Some(v) = req.blood_sugar_type { active.blood_sugar_type = Set(Some(v)); } + if let Some(v) = req.water_intake_ml { active.water_intake_ml = Set(Some(v)); } + if let Some(v) = req.urine_output_ml { active.urine_output_ml = 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); + + let m = active.update(&state.db).await?; + tracing::info!(id = %m.id, tenant_id = %tenant_id, version = m.version, "体征记录更新成功"); + + // 变更后快照 + let new_values = serde_json::json!({ + "record_date": m.record_date, + "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, + "blood_sugar": m.blood_sugar, + "notes": m.notes, + }); + + // 更新后也触发危急值检测(修改后的值可能触发告警) + let check_req = CreateVitalSignsReq { + record_date: m.record_date, + 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_f64().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), + body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), + spo2: m.spo2, + blood_sugar_type: m.blood_sugar_type.clone(), + water_intake_ml: m.water_intake_ml, + urine_output_ml: m.urine_output_ml, + notes: m.notes.clone(), + source: Some(m.source.clone()), + }; + check_vital_signs_alert(state, tenant_id, patient_id, operator_id, check_req).await; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "vital_signs.updated", "vital_signs") + .with_resource_id(m.id) + .with_changes(Some(old_values), Some(new_values)), + &state.db, + ).await; + + Ok(VitalSignsResp { + id: m.id, patient_id: m.patient_id, record_date: m.record_date, + source: m.source, + 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_f64().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_f64().unwrap_or(0.0)), + body_temperature: m.body_temperature.map(|d| d.to_f64().unwrap_or(0.0)), + spo2: m.spo2, + blood_sugar_type: m.blood_sugar_type, + 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, + }) +} + +pub async fn delete_vital_signs( + state: &HealthState, + tenant_id: Uuid, + vital_signs_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + tracing::info!(tenant_id = %tenant_id, vital_signs_id = %vital_signs_id, expected_version, "删除体征记录"); + let model = vital_signs::Entity::find() + .filter(vital_signs::Column::Id.eq(vital_signs_id)) + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| { + tracing::error!(vital_signs_id = %vital_signs_id, tenant_id = %tenant_id, "删除体征记录失败:记录不存在"); + HealthError::VitalSignsNotFound + })?; + + let next_ver = check_version(expected_version, model.version).map_err(|e| { + tracing::error!(vital_signs_id = %vital_signs_id, expected_version, db_version = model.version, "删除体征记录失败:版本冲突"); + e + })?; + + 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(next_ver); + active.update(&state.db).await?; + tracing::info!(vital_signs_id = %vital_signs_id, tenant_id = %tenant_id, "体征记录删除成功"); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "vital_signs.deleted", "vital_signs") + .with_resource_id(vital_signs_id), + &state.db, + ).await; + + Ok(()) +} diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs deleted file mode 100644 index 4c840c5..0000000 --- a/crates/erp-health/src/service/patient_service.rs +++ /dev/null @@ -1,1118 +0,0 @@ -//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要 - -use chrono::Utc; -use erp_core::audit::AuditLog; -use erp_core::audit_service; -use erp_core::events::DomainEvent; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect, TransactionTrait}; -use uuid::Uuid; - -use erp_core::error::check_version; -use erp_core::types::PaginatedResponse; - -use crate::dto::patient_dto::*; -use crate::entity::patient; -use crate::entity::patient_family_member; -use crate::entity::patient_tag; -use crate::entity::patient_tag_relation; -use crate::entity::patient_doctor_relation; -use crate::entity::doctor_profile; -use crate::error::{HealthError, HealthResult}; -use crate::service::validation::{validate_gender, validate_blood_type, validate_patient_status, validate_verification_status}; -use crate::service::masking::{mask_id_number, mask_phone, validate_status_transition}; -use crate::state::HealthState; -use erp_core::crypto::{self as pii, PiiCrypto}; - -// --------------------------------------------------------------------------- -// 患者 CRUD -// --------------------------------------------------------------------------- - -/// 患者列表(分页 + 搜索 + 标签筛选) -pub async fn list_patients( - state: &HealthState, - tenant_id: Uuid, - page: u64, - page_size: u64, - search: Option, - tag_id: Option, -) -> HealthResult> { - tracing::info!(action = "list_patients", tenant_id = %tenant_id, page, page_size, "Listing patients"); - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - // 如果按标签筛选,先查出关联的 patient_id 列表 - let tagged_patient_ids: Option> = if let Some(tid) = tag_id { - let rows: Vec = patient_tag_relation::Entity::find() - .filter(patient_tag_relation::Column::TenantId.eq(tenant_id)) - .filter(patient_tag_relation::Column::TagId.eq(tid)) - .filter(patient_tag_relation::Column::DeletedAt.is_null()) - .all(&state.db) - .await?; - Some(rows.into_iter().map(|r| r.patient_id).collect()) - } else { - None - }; - - let mut query = patient::Entity::find() - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()); - - if let Some(ref search) = search { - let search_hash = pii::hmac_hash(state.crypto.hmac_key(), search); - query = query.filter( - Condition::any() - .add(patient::Column::Name.contains(search)) - .add(patient::Column::IdNumberHash.eq(&search_hash)) - .add(patient::Column::EmergencyContactPhoneHash.eq(search_hash)), - ); - } - - if let Some(ref ids) = tagged_patient_ids { - query = query.filter(patient::Column::Id.is_in(ids.clone())); - } - - let total = query - .clone() - .count(&state.db) - .await?; - - let models = query - .order_by_desc(patient::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 create_patient( - state: &HealthState, - tenant_id: Uuid, - operator_id: Option, - req: CreatePatientReq, -) -> HealthResult { - tracing::info!(action = "create_patient", tenant_id = %tenant_id, name = %req.name, "Creating 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)?; } - - // 加密身份证号 + HMAC 索引 - let (encrypted_id_number, id_number_hash) = match req.id_number { - Some(ref plain) if !plain.is_empty() => { - let encrypted = pii::encrypt(state.crypto.kek(), plain)?; - let hash = pii::hmac_hash(state.crypto.hmac_key(), plain); - (Some(encrypted), Some(hash)) - } - _ => (None, None), - }; - - // 加密紧急联系人电话 + HMAC 索引 - let (encrypted_phone, phone_hash) = match req.emergency_contact_phone { - Some(ref p) if !p.is_empty() => { - let encrypted = pii::encrypt(state.crypto.kek(), p)?; - let hash = pii::hmac_hash(state.crypto.hmac_key(), p); - (Some(encrypted), Some(hash)) - } - _ => (None, None), - }; - - // 加密过敏史 - let encrypted_allergy = req.allergy_history.as_ref() - .filter(|a| !a.is_empty()) - .map(|a| pii::encrypt(state.crypto.kek(), a)) - .transpose()?; - - // 加密病史摘要 - let encrypted_medical = req.medical_history_summary.as_ref() - .filter(|m| !m.is_empty()) - .map(|m| pii::encrypt(state.crypto.kek(), m)) - .transpose()?; - - // 盲索引去重:同租户内相同身份证号不允许重复建档 - if let Some(ref hash) = id_number_hash { - let dup = crate::entity::blind_index::Entity::find() - .filter(crate::entity::blind_index::Column::TenantId.eq(tenant_id)) - .filter(crate::entity::blind_index::Column::EntityType.eq("patient")) - .filter(crate::entity::blind_index::Column::FieldName.eq("id_number")) - .filter(crate::entity::blind_index::Column::BlindHash.eq(hash.as_str())) - .one(&state.db) - .await?; - if dup.is_some() { - tracing::warn!(action = "create_patient", tenant_id = %tenant_id, "身份证号重复,拒绝创建"); - return Err(HealthError::Validation("该身份证号已存在患者档案".to_string())); - } - } - // 保留副本供写入 blind_indexes 表(active model 构建 会 move 原值) - let bi_id_hash = id_number_hash.clone(); - let bi_phone_hash = phone_hash.clone(); - - let active = patient::ActiveModel { - id: Set(id), - tenant_id: Set(tenant_id), - user_id: Set(None), - name: Set(req.name), - gender: Set(req.gender), - birth_date: Set(req.birth_date), - blood_type: Set(req.blood_type), - id_number: Set(encrypted_id_number), - id_number_hash: Set(id_number_hash), - allergy_history: Set(encrypted_allergy), - medical_history_summary: Set(encrypted_medical), - emergency_contact_name: Set(req.emergency_contact_name), - emergency_contact_phone: Set(encrypted_phone), - emergency_contact_phone_hash: Set(phone_hash), - key_version: Set(Some(1)), - status: Set("active".to_string()), - verification_status: Set("pending".to_string()), - source: Set(req.source), - 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 model = active.insert(&state.db).await?; - - // 写入盲索引到统一索引表(用于跨系统去重查询) - let now_bi = Utc::now(); - if let Some(hash) = bi_id_hash { - let bi = crate::entity::blind_index::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - entity_type: Set("patient".to_string()), - entity_id: Set(model.id), - field_name: Set("id_number".to_string()), - blind_hash: Set(hash), - created_at: Set(now_bi), - updated_at: Set(now_bi), - }; - bi.insert(&state.db).await?; - } - if let Some(hash) = bi_phone_hash { - let bi = crate::entity::blind_index::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - entity_type: Set("patient".to_string()), - entity_id: Set(model.id), - field_name: Set("emergency_contact_phone".to_string()), - blind_hash: Set(hash), - created_at: Set(now_bi), - updated_at: Set(now_bi), - }; - bi.insert(&state.db).await?; - } - - let event = DomainEvent::new( - crate::event::PATIENT_CREATED, - tenant_id, - erp_core::events::build_event_payload(serde_json::json!({ "patient_id": model.id })), - ); - state.event_bus.publish(event, &state.db).await; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.created", "patient") - .with_resource_id(model.id), - &state.db, - ).await; - - Ok(model_to_resp(model)) -} - -/// 获取患者详情(解密身份证号) -pub async fn get_patient( - state: &HealthState, - tenant_id: Uuid, - id: Uuid, -) -> HealthResult { - tracing::info!(action = "get_patient", patient_id = %id, "Fetching patient"); - let model = find_patient(&state.db, tenant_id, id).await?; - Ok(model_to_resp_decrypted(&state.crypto, model)) -} - -/// 更新患者信息(乐观锁) -pub async fn update_patient( - state: &HealthState, - tenant_id: Uuid, - id: Uuid, - operator_id: Option, - req: UpdatePatientReq, - expected_version: i32, -) -> HealthResult { - tracing::info!(action = "update_patient", patient_id = %id, "Updating patient"); - let model = find_patient(&state.db, tenant_id, id).await?; - let next_ver = check_version(expected_version, model.version) - .map_err(|_| { - tracing::warn!(action = "update_patient", patient_id = %id, expected = expected_version, actual = model.version, "版本冲突"); - HealthError::VersionMismatch - })?; - - 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 { - 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 old_snapshot = serde_json::json!({ - "allergy_history": model.allergy_history, - "medical_history_summary": model.medical_history_summary, - "status": model.status, - "verification_status": model.verification_status, - }); - - let mut active: patient::ActiveModel = model.into(); - - if let Some(v) = req.name { active.name = Set(v); } - if let Some(v) = req.gender { active.gender = Set(Some(v)); } - if req.birth_date.is_some() { active.birth_date = Set(req.birth_date); } - if let Some(v) = req.blood_type { active.blood_type = Set(Some(v)); } - if let Some(ref plain) = req.id_number { - let encrypted = pii::encrypt(state.crypto.kek(), plain)?; - let hash = pii::hmac_hash(state.crypto.hmac_key(), plain); - active.id_number = Set(Some(encrypted)); - active.id_number_hash = Set(Some(hash)); - } - if let Some(ref v) = req.allergy_history { - let encrypted = pii::encrypt(state.crypto.kek(), v)?; - active.allergy_history = Set(Some(encrypted)); - } - if let Some(ref v) = req.medical_history_summary { - let encrypted = pii::encrypt(state.crypto.kek(), v)?; - active.medical_history_summary = Set(Some(encrypted)); - } - if let Some(v) = req.emergency_contact_name { active.emergency_contact_name = Set(Some(v)); } - if let Some(ref v) = req.emergency_contact_phone { - let encrypted = pii::encrypt(state.crypto.kek(), v)?; - let hash = pii::hmac_hash(state.crypto.hmac_key(), v); - active.emergency_contact_phone = Set(Some(encrypted)); - active.emergency_contact_phone_hash = Set(Some(hash)); - } - 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); - active.version = Set(next_ver); - - let updated = active.update(&state.db).await?; - - // 变更后快照 - let new_snapshot = serde_json::json!({ - "allergy_history": updated.allergy_history, - "medical_history_summary": updated.medical_history_summary, - "status": updated.status, - "verification_status": updated.verification_status, - }); - - // 根据状态变更发布不同事件 - let event_type = if req.status.as_deref() == Some("deceased") { - crate::event::PATIENT_DECEASED - } else if req.verification_status.as_deref() == Some("verified") { - crate::event::PATIENT_VERIFIED - } else { - crate::event::PATIENT_UPDATED - }; - let event = DomainEvent::new( - event_type, - tenant_id, - erp_core::events::build_event_payload(serde_json::json!({ "patient_id": updated.id })), - ); - state.event_bus.publish(event, &state.db).await; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.updated", "patient") - .with_resource_id(updated.id) - .with_changes(Some(old_snapshot), Some(new_snapshot)), - &state.db, - ).await; - - Ok(model_to_resp(updated)) -} - -/// 软删除患者 -pub async fn delete_patient( - state: &HealthState, - tenant_id: Uuid, - id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - tracing::info!(action = "delete_patient", patient_id = %id, "Soft deleting patient"); - let model = find_patient(&state.db, tenant_id, id).await?; - let next_ver = check_version(expected_version, model.version) - .map_err(|_| { - tracing::warn!(action = "delete_patient", patient_id = %id, expected = expected_version, actual = model.version, "版本冲突"); - 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(next_ver); - active.update(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.deleted", "patient") - .with_resource_id(id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 标签管理 -// --------------------------------------------------------------------------- - -/// 管理患者标签(覆盖式) -pub async fn manage_patient_tags( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - req: ManageTagsReq, - operator_id: Option, -) -> HealthResult<()> { - tracing::info!(action = "manage_patient_tags", patient_id = %patient_id, tag_count = req.tag_ids.len(), "Managing patient tags"); - // 确认患者存在 - find_patient(&state.db, tenant_id, patient_id).await?; - - // H-1: 校验所有 tag_ids 属于当前租户 - if !req.tag_ids.is_empty() { - let valid_count = patient_tag::Entity::find() - .filter(patient_tag::Column::TenantId.eq(tenant_id)) - .filter(patient_tag::Column::Id.is_in(req.tag_ids.iter().copied())) - .filter(patient_tag::Column::DeletedAt.is_null()) - .count(&state.db) - .await?; - if valid_count != req.tag_ids.len() as u64 { - return Err(HealthError::Validation("部分标签不存在或不属于当前租户".to_string())); - } - } - - let now = Utc::now(); - - // 在事务中执行:软删除旧关联 + 插入新关联,防止进程崩溃导致标签丢失 - let txn = state.db.begin().await?; - - // 软删除旧的关联 - patient_tag_relation::Entity::update_many() - .col_expr( - patient_tag_relation::Column::DeletedAt, - Expr::value(Some(now)), - ) - .col_expr( - patient_tag_relation::Column::UpdatedAt, - Expr::value(now), - ) - .filter(patient_tag_relation::Column::TenantId.eq(tenant_id)) - .filter(patient_tag_relation::Column::PatientId.eq(patient_id)) - .filter(patient_tag_relation::Column::DeletedAt.is_null()) - .exec(&txn) - .await?; - - // 插入新的关联 - for tag_id in req.tag_ids { - let rel = patient_tag_relation::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - tag_id: Set(tag_id), - 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), - }; - rel.insert(&txn).await?; - } - - txn.commit().await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.tags_updated", "patient") - .with_resource_id(patient_id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 健康摘要 -// --------------------------------------------------------------------------- - -/// 获取患者健康摘要 -pub async fn get_health_summary( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, -) -> HealthResult { - tracing::info!(action = "get_health_summary", patient_id = %patient_id, "Fetching health summary"); - find_patient(&state.db, tenant_id, patient_id).await?; - - use crate::entity::{vital_signs, lab_report, appointment, follow_up_task}; - use sea_orm::QueryOrder; - - // 4 个查询并行执行 - let (latest_vitals_res, latest_lab_res, upcoming_res, pending_follow_ups_res) = tokio::join!( - // 最新体征 - vital_signs::Entity::find() - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::PatientId.eq(patient_id)) - .filter(vital_signs::Column::DeletedAt.is_null()) - .order_by_desc(vital_signs::Column::RecordDate) - .one(&state.db), - // 最新化验 - lab_report::Entity::find() - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::PatientId.eq(patient_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .order_by_desc(lab_report::Column::ReportDate) - .one(&state.db), - // 待处理预约数 - appointment::Entity::find() - .filter(appointment::Column::TenantId.eq(tenant_id)) - .filter(appointment::Column::PatientId.eq(patient_id)) - .filter(appointment::Column::Status.eq("pending")) - .filter(appointment::Column::DeletedAt.is_null()) - .count(&state.db), - // 待办随访数 - follow_up_task::Entity::find() - .filter(follow_up_task::Column::TenantId.eq(tenant_id)) - .filter(follow_up_task::Column::PatientId.eq(patient_id)) - .filter(follow_up_task::Column::Status.eq("pending")) - .filter(follow_up_task::Column::DeletedAt.is_null()) - .count(&state.db), - ); - - let latest_vitals = latest_vitals_res?; - let latest_lab = latest_lab_res?; - let upcoming = upcoming_res?; - let pending_follow_ups = pending_follow_ups_res?; - - Ok(serde_json::json!({ - "patient_id": patient_id, - "latest_vital_signs": latest_vitals.map(|v| serde_json::to_value(v).unwrap_or_default()), - "latest_lab_report": latest_lab.map(|v| serde_json::to_value(v).unwrap_or_default()), - "upcoming_appointments": upcoming, - "pending_follow_ups": pending_follow_ups, - })) -} - -// --------------------------------------------------------------------------- -// 家庭成员 -// --------------------------------------------------------------------------- - -/// 家庭成员列表 -pub async fn list_family_members( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, -) -> HealthResult> { - tracing::info!(action = "list_family_members", patient_id = %patient_id, "Listing family members"); - let models = patient_family_member::Entity::find() - .filter(patient_family_member::Column::TenantId.eq(tenant_id)) - .filter(patient_family_member::Column::PatientId.eq(patient_id)) - .filter(patient_family_member::Column::DeletedAt.is_null()) - .order_by_asc(patient_family_member::Column::CreatedAt) - .all(&state.db) - .await?; - - let kek = state.crypto.kek(); - Ok(models.into_iter().map(|m| { - let phone = m.phone.as_ref() - .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) - .map(|p| mask_phone(Some(&p)).unwrap_or(p)); - FamilyMemberResp { - id: m.id, - patient_id: m.patient_id, - name: m.name, - relationship: m.relationship, - phone, - birth_date: m.birth_date, - notes: m.notes, - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, - } - }).collect()) -} - -/// 创建家庭成员 -pub async fn create_family_member( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - operator_id: Option, - req: FamilyMemberReq, -) -> HealthResult { - tracing::info!(action = "create_family_member", patient_id = %patient_id, name = %req.name, "Creating family member"); - find_patient(&state.db, tenant_id, patient_id).await?; - - let now = Utc::now(); - let id = Uuid::now_v7(); - - let kek = state.crypto.kek(); - let (encrypted_phone, phone_hash) = match req.phone { - Some(ref p) if !p.is_empty() => { - let encrypted = pii::encrypt(kek, p)?; - let hash = pii::hmac_hash(kek, p); - (Some(encrypted), Some(hash)) - } - _ => (None, None), - }; - - let active = patient_family_member::ActiveModel { - id: Set(id), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - name: Set(req.name), - relationship: Set(req.relationship), - phone: Set(encrypted_phone), - phone_hash: Set(phone_hash), - birth_date: Set(req.birth_date), - 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), - key_version: Set(Some(1)), - }; - - let model = active.insert(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.family_member_created", "patient_family_member") - .with_resource_id(model.id), - &state.db, - ).await; - - let decrypted_phone = model.phone.as_ref() - .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) - .map(|p| mask_phone(Some(&p)).unwrap_or(p)); - Ok(FamilyMemberResp { - id: model.id, - patient_id: model.patient_id, - name: model.name, - relationship: model.relationship, - phone: decrypted_phone, - birth_date: model.birth_date, - notes: model.notes, - created_at: model.created_at, - updated_at: model.updated_at, - version: model.version, - }) -} - -/// 更新家庭成员(乐观锁) -pub async fn update_family_member( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - family_member_id: Uuid, - operator_id: Option, - req: FamilyMemberReq, - expected_version: i32, -) -> HealthResult { - tracing::info!(action = "update_family_member", patient_id = %patient_id, family_member_id = %family_member_id, "Updating family member"); - 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::FamilyMemberNotFound)?; - - let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; - - let kek = state.crypto.kek(); - let hmac_key = state.crypto.hmac_key(); - - // 记录变更前的关键字段(phone 为加密值,不记录原文) - let old_values = serde_json::json!({ - "name": model.name, - "relationship": model.relationship, - "birth_date": model.birth_date, - "notes": model.notes, - }); - - let mut active: patient_family_member::ActiveModel = model.into(); - active.name = Set(req.name); - active.relationship = Set(req.relationship); - if let Some(ref p) = req.phone { - let encrypted = pii::encrypt(kek, p)?; - let hash = pii::hmac_hash(hmac_key, p); - active.phone = Set(Some(encrypted)); - active.phone_hash = Set(Some(hash)); - active.key_version = Set(Some(1)); - } - active.birth_date = Set(req.birth_date); - active.notes = Set(req.notes); - active.updated_at = Set(Utc::now()); - active.updated_by = Set(operator_id); - active.version = Set(next_ver); - - let updated = active.update(&state.db).await?; - - // 变更后快照 - let new_values = serde_json::json!({ - "name": updated.name, - "relationship": updated.relationship, - "birth_date": updated.birth_date, - "notes": updated.notes, - }); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.family_member_updated", "patient_family_member") - .with_resource_id(updated.id) - .with_changes(Some(old_values), Some(new_values)), - &state.db, - ).await; - - let decrypted_phone = updated.phone.as_ref() - .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) - .map(|p| mask_phone(Some(&p)).unwrap_or(p)); - Ok(FamilyMemberResp { - id: updated.id, - patient_id: updated.patient_id, - name: updated.name, - relationship: updated.relationship, - phone: decrypted_phone, - birth_date: updated.birth_date, - notes: updated.notes, - created_at: updated.created_at, - updated_at: updated.updated_at, - version: updated.version, - }) -} - -/// 删除家庭成员 -pub async fn delete_family_member( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - family_member_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - tracing::info!(action = "delete_family_member", family_member_id = %family_member_id, "Soft deleting family member"); - 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::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(next_ver); - active.update(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.family_member_deleted", "patient_family_member") - .with_resource_id(family_member_id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 患者-医生关联 -// --------------------------------------------------------------------------- - -/// 分配负责医生 -pub async fn assign_doctor( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - doctor_id: Uuid, - relationship_type: String, - operator_id: Option, -) -> HealthResult<()> { - tracing::info!(action = "assign_doctor", patient_id = %patient_id, doctor_id = %doctor_id, "Assigning doctor to patient"); - find_patient(&state.db, tenant_id, patient_id).await?; - - // 验证医生存在 - doctor_profile::Entity::find() - .filter(doctor_profile::Column::Id.eq(doctor_id)) - .filter(doctor_profile::Column::TenantId.eq(tenant_id)) - .filter(doctor_profile::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::DoctorNotFound)?; - - // H-2: 检查是否已存在相同的未删除关联 - let existing = patient_doctor_relation::Entity::find() - .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) - .filter(patient_doctor_relation::Column::PatientId.eq(patient_id)) - .filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id)) - .filter(patient_doctor_relation::Column::DeletedAt.is_null()) - .one(&state.db) - .await?; - if existing.is_some() { - return Err(HealthError::Validation("该医生已关联此患者".to_string())); - } - - let now = Utc::now(); - let active = patient_doctor_relation::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - doctor_id: Set(doctor_id), - relationship_type: Set(relationship_type), - 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 relation = active.insert(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.doctor_assigned", "patient_doctor_relation") - .with_resource_id(relation.id), - &state.db, - ).await; - - Ok(()) -} - -/// 移除负责医生 -pub async fn remove_doctor( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - doctor_id: Uuid, - operator_id: Option, -) -> HealthResult<()> { - tracing::info!(action = "remove_doctor", patient_id = %patient_id, doctor_id = %doctor_id, "Removing doctor from patient"); - let model = patient_doctor_relation::Entity::find() - .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) - .filter(patient_doctor_relation::Column::PatientId.eq(patient_id)) - .filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id)) - .filter(patient_doctor_relation::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::DoctorNotFound)?; - - let relation_id = model.id; - let mut active: patient_doctor_relation::ActiveModel = model.into(); - active.deleted_at = Set(Some(Utc::now())); - active.updated_at = Set(Utc::now()); - active.updated_by = Set(operator_id); - active.update(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient.doctor_removed", "patient_doctor_relation") - .with_resource_id(relation_id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 内部辅助 -// --------------------------------------------------------------------------- - -/// 按租户+ID查找未删除患者 -async fn find_patient( - db: &DatabaseConnection, - tenant_id: Uuid, - id: Uuid, -) -> HealthResult { - patient::Entity::find() - .filter(patient::Column::Id.eq(id)) - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()) - .one(db) - .await? - .ok_or(HealthError::PatientNotFound) -} - -/// Entity Model → DTO Resp -/// 列表用 — 不含敏感字段 -fn model_to_resp(m: patient::Model) -> PatientResp { - PatientResp { - id: m.id, - user_id: m.user_id, - name: m.name, - gender: m.gender, - birth_date: m.birth_date, - blood_type: m.blood_type, - id_number: None, - allergy_history: None, - medical_history_summary: None, - emergency_contact_name: m.emergency_contact_name, - emergency_contact_phone: None, - status: m.status, - verification_status: m.verification_status, - source: m.source, - notes: m.notes, - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, - } -} - -/// 详情用 — 解密 Tier 1 字段 -fn model_to_resp_decrypted(crypto: &PiiCrypto, m: patient::Model) -> PatientResp { - let kek = crypto.kek(); - let decrypted_id_number = m.id_number.as_ref() - .map(|enc| pii::decrypt(kek, enc)) - .transpose().ok().flatten(); - let decrypted_allergy = m.allergy_history.as_ref() - .map(|enc| pii::decrypt(kek, enc)) - .transpose().ok().flatten(); - let decrypted_medical = m.medical_history_summary.as_ref() - .map(|enc| pii::decrypt(kek, enc)) - .transpose().ok().flatten(); - let decrypted_phone = m.emergency_contact_phone.as_ref() - .map(|enc| pii::decrypt(kek, enc)) - .transpose().ok().flatten(); - PatientResp { - id: m.id, - user_id: m.user_id, - name: m.name, - gender: m.gender, - birth_date: m.birth_date, - blood_type: m.blood_type, - id_number: decrypted_id_number.map(|id| mask_id_number(&id)), - allergy_history: decrypted_allergy, - medical_history_summary: decrypted_medical, - emergency_contact_name: m.emergency_contact_name, - emergency_contact_phone: mask_phone(decrypted_phone.as_deref()), - status: m.status, - verification_status: m.verification_status, - source: m.source, - notes: m.notes, - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, - } -} - - -pub async fn list_tags( - state: &crate::state::HealthState, - tenant_id: Uuid, -) -> HealthResult> { - tracing::info!(action = "list_tags", tenant_id = %tenant_id, "Listing patient tags"); - use crate::entity::patient_tag; - let tags = patient_tag::Entity::find() - .filter(patient_tag::Column::TenantId.eq(tenant_id)) - .filter(patient_tag::Column::DeletedAt.is_null()) - .order_by_asc(patient_tag::Column::Name) - .all(&state.db) - .await - .map_err(|e| crate::error::HealthError::DbError(e.to_string()))?; - - Ok(tags - .into_iter() - .map(|t| crate::dto::patient_dto::TagResp { - id: t.id, - name: t.name, - color: t.color, - description: t.description, - }) - .collect()) -} - -// --------------------------------------------------------------------------- -// 患者标签 CRUD -// --------------------------------------------------------------------------- - -#[derive(Debug, serde::Deserialize)] -pub struct CreateTagReq { - pub name: String, - pub color: Option, - pub description: Option, -} - -#[derive(Debug, serde::Serialize)] -pub struct TagResp { - pub id: Uuid, - pub name: String, - pub color: Option, - pub description: Option, -} - -pub async fn create_tag( - state: &HealthState, - tenant_id: Uuid, - operator_id: Option, - req: CreateTagReq, -) -> HealthResult { - tracing::info!(action = "create_tag", tenant_id = %tenant_id, name = %req.name, "Creating patient tag"); - let id = Uuid::now_v7(); - let now = Utc::now(); - let tag = patient_tag::ActiveModel { - id: Set(id), - tenant_id: Set(tenant_id), - name: Set(req.name), - color: Set(req.color), - description: Set(req.description), - is_system: Set(false), - 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 tag = tag.insert(&state.db).await.map_err(|e| HealthError::DbError(e.to_string()))?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient_tag.create", "patient_tag") - .with_resource_id(tag.id), - &state.db, - ).await; - - Ok(TagResp { - id: tag.id, name: tag.name, color: tag.color, description: tag.description, - }) -} - -#[derive(Debug, serde::Deserialize)] -pub struct UpdateTagReq { - pub name: Option, - pub color: Option, - pub description: Option, - pub version: i32, -} - -pub async fn update_tag( - state: &HealthState, - tenant_id: Uuid, - tag_id: Uuid, - operator_id: Option, - req: UpdateTagReq, -) -> HealthResult { - tracing::info!(action = "update_tag", tag_id = %tag_id, "Updating patient tag"); - let tag = patient_tag::Entity::find_by_id(tag_id) - .one(&state.db) - .await? - .ok_or(HealthError::TagNotFound)?; - - if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); } - check_version(req.version, tag.version)?; - - // 记录变更前的关键字段 - let old_values = serde_json::json!({ - "name": tag.name, - "color": tag.color, - "description": tag.description, - }); - - let mut active: patient_tag::ActiveModel = tag.into(); - if let Some(name) = req.name { active.name = Set(name); } - if let Some(color) = req.color { active.color = Set(Some(color)); } - if let Some(description) = req.description { active.description = Set(Some(description)); } - active.updated_at = Set(Utc::now()); - active.updated_by = Set(operator_id); - active.version = Set(req.version + 1); - - let updated = active.update(&state.db).await - .map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?; - - // 变更后快照 - let new_values = serde_json::json!({ - "name": updated.name, - "color": updated.color, - "description": updated.description, - }); - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient_tag.update", "patient_tag") - .with_resource_id(updated.id) - .with_changes(Some(old_values), Some(new_values)), - &state.db, - ).await; - - Ok(TagResp { - id: updated.id, name: updated.name, color: updated.color, description: updated.description, - }) -} - -pub async fn delete_tag( - state: &HealthState, - tenant_id: Uuid, - tag_id: Uuid, - operator_id: Option, - version: i32, -) -> HealthResult<()> { - tracing::info!(action = "delete_tag", tag_id = %tag_id, "Soft deleting patient tag"); - let tag = patient_tag::Entity::find_by_id(tag_id) - .one(&state.db) - .await? - .ok_or(HealthError::TagNotFound)?; - - if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); } - check_version(version, tag.version)?; - - let mut active: patient_tag::ActiveModel = tag.into(); - active.deleted_at = Set(Some(Utc::now())); - active.updated_at = Set(Utc::now()); - active.updated_by = Set(operator_id); - active.version = Set(version + 1); - active.update(&state.db).await - .map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "patient_tag.delete", "patient_tag") - .with_resource_id(tag_id), - &state.db, - ).await; - - Ok(()) -} diff --git a/crates/erp-health/src/service/patient_service/crud.rs b/crates/erp-health/src/service/patient_service/crud.rs new file mode 100644 index 0000000..3863a76 --- /dev/null +++ b/crates/erp-health/src/service/patient_service/crud.rs @@ -0,0 +1,395 @@ +//! 患者管理 Service — 基础 CRUD 操作 + +use chrono::Utc; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::crypto as pii; +use erp_core::events::DomainEvent; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::patient_dto::*; +use crate::entity::patient; +use crate::entity::patient_tag_relation; +use crate::error::{HealthError, HealthResult}; +use crate::service::validation::{validate_gender, validate_blood_type, validate_patient_status, validate_verification_status}; +use crate::service::masking::{validate_status_transition}; +use crate::state::HealthState; + +use super::helper::{find_patient, model_to_resp, model_to_resp_decrypted}; + +// --------------------------------------------------------------------------- +// 患者 CRUD +// --------------------------------------------------------------------------- + +/// 患者列表(分页 + 搜索 + 标签筛选) +pub async fn list_patients( + state: &HealthState, + tenant_id: Uuid, + page: u64, + page_size: u64, + search: Option, + tag_id: Option, +) -> HealthResult> { + tracing::info!(action = "list_patients", tenant_id = %tenant_id, page, page_size, "Listing patients"); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + // 如果按标签筛选,先查出关联的 patient_id 列表 + let tagged_patient_ids: Option> = if let Some(tid) = tag_id { + let rows: Vec = patient_tag_relation::Entity::find() + .filter(patient_tag_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_tag_relation::Column::TagId.eq(tid)) + .filter(patient_tag_relation::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + Some(rows.into_iter().map(|r| r.patient_id).collect()) + } else { + None + }; + + let mut query = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()); + + if let Some(ref search) = search { + let search_hash = pii::hmac_hash(state.crypto.hmac_key(), search); + query = query.filter( + Condition::any() + .add(patient::Column::Name.contains(search)) + .add(patient::Column::IdNumberHash.eq(&search_hash)) + .add(patient::Column::EmergencyContactPhoneHash.eq(search_hash)), + ); + } + + if let Some(ref ids) = tagged_patient_ids { + query = query.filter(patient::Column::Id.is_in(ids.clone())); + } + + let total = query + .clone() + .count(&state.db) + .await?; + + let models = query + .order_by_desc(patient::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 create_patient( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreatePatientReq, +) -> HealthResult { + tracing::info!(action = "create_patient", tenant_id = %tenant_id, name = %req.name, "Creating 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)?; } + + // 加密身份证号 + HMAC 索引 + let (encrypted_id_number, id_number_hash) = match req.id_number { + Some(ref plain) if !plain.is_empty() => { + let encrypted = pii::encrypt(state.crypto.kek(), plain)?; + let hash = pii::hmac_hash(state.crypto.hmac_key(), plain); + (Some(encrypted), Some(hash)) + } + _ => (None, None), + }; + + // 加密紧急联系人电话 + HMAC 索引 + let (encrypted_phone, phone_hash) = match req.emergency_contact_phone { + Some(ref p) if !p.is_empty() => { + let encrypted = pii::encrypt(state.crypto.kek(), p)?; + let hash = pii::hmac_hash(state.crypto.hmac_key(), p); + (Some(encrypted), Some(hash)) + } + _ => (None, None), + }; + + // 加密过敏史 + let encrypted_allergy = req.allergy_history.as_ref() + .filter(|a| !a.is_empty()) + .map(|a| pii::encrypt(state.crypto.kek(), a)) + .transpose()?; + + // 加密病史摘要 + let encrypted_medical = req.medical_history_summary.as_ref() + .filter(|m| !m.is_empty()) + .map(|m| pii::encrypt(state.crypto.kek(), m)) + .transpose()?; + + // 盲索引去重:同租户内相同身份证号不允许重复建档 + if let Some(ref hash) = id_number_hash { + let dup = crate::entity::blind_index::Entity::find() + .filter(crate::entity::blind_index::Column::TenantId.eq(tenant_id)) + .filter(crate::entity::blind_index::Column::EntityType.eq("patient")) + .filter(crate::entity::blind_index::Column::FieldName.eq("id_number")) + .filter(crate::entity::blind_index::Column::BlindHash.eq(hash.as_str())) + .one(&state.db) + .await?; + if dup.is_some() { + tracing::warn!(action = "create_patient", tenant_id = %tenant_id, "身份证号重复,拒绝创建"); + return Err(HealthError::Validation("该身份证号已存在患者档案".to_string())); + } + } + // 保留副本供写入 blind_indexes 表(active model 构建 会 move 原值) + let bi_id_hash = id_number_hash.clone(); + let bi_phone_hash = phone_hash.clone(); + + let active = patient::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + user_id: Set(None), + name: Set(req.name), + gender: Set(req.gender), + birth_date: Set(req.birth_date), + blood_type: Set(req.blood_type), + id_number: Set(encrypted_id_number), + id_number_hash: Set(id_number_hash), + allergy_history: Set(encrypted_allergy), + medical_history_summary: Set(encrypted_medical), + emergency_contact_name: Set(req.emergency_contact_name), + emergency_contact_phone: Set(encrypted_phone), + emergency_contact_phone_hash: Set(phone_hash), + key_version: Set(Some(1)), + status: Set("active".to_string()), + verification_status: Set("pending".to_string()), + source: Set(req.source), + 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 model = active.insert(&state.db).await?; + + // 写入盲索引到统一索引表(用于跨系统去重查询) + let now_bi = Utc::now(); + if let Some(hash) = bi_id_hash { + let bi = crate::entity::blind_index::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + entity_type: Set("patient".to_string()), + entity_id: Set(model.id), + field_name: Set("id_number".to_string()), + blind_hash: Set(hash), + created_at: Set(now_bi), + updated_at: Set(now_bi), + }; + bi.insert(&state.db).await?; + } + if let Some(hash) = bi_phone_hash { + let bi = crate::entity::blind_index::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + entity_type: Set("patient".to_string()), + entity_id: Set(model.id), + field_name: Set("emergency_contact_phone".to_string()), + blind_hash: Set(hash), + created_at: Set(now_bi), + updated_at: Set(now_bi), + }; + bi.insert(&state.db).await?; + } + + let event = DomainEvent::new( + crate::event::PATIENT_CREATED, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ "patient_id": model.id })), + ); + state.event_bus.publish(event, &state.db).await; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.created", "patient") + .with_resource_id(model.id), + &state.db, + ).await; + + Ok(model_to_resp(model)) +} + +/// 获取患者详情(解密身份证号) +pub async fn get_patient( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + tracing::info!(action = "get_patient", patient_id = %id, "Fetching patient"); + let model = find_patient(&state.db, tenant_id, id).await?; + Ok(model_to_resp_decrypted(&state.crypto, model)) +} + +/// 更新患者信息(乐观锁) +pub async fn update_patient( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: UpdatePatientReq, + expected_version: i32, +) -> HealthResult { + tracing::info!(action = "update_patient", patient_id = %id, "Updating patient"); + let model = find_patient(&state.db, tenant_id, id).await?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| { + tracing::warn!(action = "update_patient", patient_id = %id, expected = expected_version, actual = model.version, "版本冲突"); + HealthError::VersionMismatch + })?; + + 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 { + 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 old_snapshot = serde_json::json!({ + "allergy_history": model.allergy_history, + "medical_history_summary": model.medical_history_summary, + "status": model.status, + "verification_status": model.verification_status, + }); + + let mut active: patient::ActiveModel = model.into(); + + if let Some(v) = req.name { active.name = Set(v); } + if let Some(v) = req.gender { active.gender = Set(Some(v)); } + if req.birth_date.is_some() { active.birth_date = Set(req.birth_date); } + if let Some(v) = req.blood_type { active.blood_type = Set(Some(v)); } + if let Some(ref plain) = req.id_number { + let encrypted = pii::encrypt(state.crypto.kek(), plain)?; + let hash = pii::hmac_hash(state.crypto.hmac_key(), plain); + active.id_number = Set(Some(encrypted)); + active.id_number_hash = Set(Some(hash)); + } + if let Some(ref v) = req.allergy_history { + let encrypted = pii::encrypt(state.crypto.kek(), v)?; + active.allergy_history = Set(Some(encrypted)); + } + if let Some(ref v) = req.medical_history_summary { + let encrypted = pii::encrypt(state.crypto.kek(), v)?; + active.medical_history_summary = Set(Some(encrypted)); + } + if let Some(v) = req.emergency_contact_name { active.emergency_contact_name = Set(Some(v)); } + if let Some(ref v) = req.emergency_contact_phone { + let encrypted = pii::encrypt(state.crypto.kek(), v)?; + let hash = pii::hmac_hash(state.crypto.hmac_key(), v); + active.emergency_contact_phone = Set(Some(encrypted)); + active.emergency_contact_phone_hash = Set(Some(hash)); + } + 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); + active.version = Set(next_ver); + + let updated = active.update(&state.db).await?; + + // 变更后快照 + let new_snapshot = serde_json::json!({ + "allergy_history": updated.allergy_history, + "medical_history_summary": updated.medical_history_summary, + "status": updated.status, + "verification_status": updated.verification_status, + }); + + // 根据状态变更发布不同事件 + let event_type = if req.status.as_deref() == Some("deceased") { + crate::event::PATIENT_DECEASED + } else if req.verification_status.as_deref() == Some("verified") { + crate::event::PATIENT_VERIFIED + } else { + crate::event::PATIENT_UPDATED + }; + let event = DomainEvent::new( + event_type, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ "patient_id": updated.id })), + ); + state.event_bus.publish(event, &state.db).await; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.updated", "patient") + .with_resource_id(updated.id) + .with_changes(Some(old_snapshot), Some(new_snapshot)), + &state.db, + ).await; + + Ok(model_to_resp(updated)) +} + +/// 软删除患者 +pub async fn delete_patient( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + tracing::info!(action = "delete_patient", patient_id = %id, "Soft deleting patient"); + let model = find_patient(&state.db, tenant_id, id).await?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| { + tracing::warn!(action = "delete_patient", patient_id = %id, expected = expected_version, actual = model.version, "版本冲突"); + 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(next_ver); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.deleted", "patient") + .with_resource_id(id), + &state.db, + ).await; + + Ok(()) +} diff --git a/crates/erp-health/src/service/patient_service/helper.rs b/crates/erp-health/src/service/patient_service/helper.rs new file mode 100644 index 0000000..efce804 --- /dev/null +++ b/crates/erp-health/src/service/patient_service/helper.rs @@ -0,0 +1,91 @@ +//! 患者管理 Service — 共享类型和辅助函数 + +use sea_orm::entity::prelude::*; +use uuid::Uuid; + +use erp_core::crypto::{self as pii, PiiCrypto}; + +use crate::dto::patient_dto::*; +use crate::entity::patient; +use crate::error::{HealthError, HealthResult}; + +// --------------------------------------------------------------------------- +// 内部辅助 +// --------------------------------------------------------------------------- + +/// 按租户+ID查找未删除患者 +pub(crate) async fn find_patient( + db: &DatabaseConnection, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + patient::Entity::find() + .filter(patient::Column::Id.eq(id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or(HealthError::PatientNotFound) +} + +/// Entity Model → DTO Resp +/// 列表用 — 不含敏感字段 +pub(crate) fn model_to_resp(m: patient::Model) -> PatientResp { + PatientResp { + id: m.id, + user_id: m.user_id, + name: m.name, + gender: m.gender, + birth_date: m.birth_date, + blood_type: m.blood_type, + id_number: None, + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: m.emergency_contact_name, + emergency_contact_phone: None, + status: m.status, + verification_status: m.verification_status, + source: m.source, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +/// 详情用 — 解密 Tier 1 字段 +pub(crate) fn model_to_resp_decrypted(crypto: &PiiCrypto, m: patient::Model) -> PatientResp { + let kek = crypto.kek(); + let decrypted_id_number = m.id_number.as_ref() + .map(|enc| pii::decrypt(kek, enc)) + .transpose().ok().flatten(); + let decrypted_allergy = m.allergy_history.as_ref() + .map(|enc| pii::decrypt(kek, enc)) + .transpose().ok().flatten(); + let decrypted_medical = m.medical_history_summary.as_ref() + .map(|enc| pii::decrypt(kek, enc)) + .transpose().ok().flatten(); + let decrypted_phone = m.emergency_contact_phone.as_ref() + .map(|enc| pii::decrypt(kek, enc)) + .transpose().ok().flatten(); + PatientResp { + id: m.id, + user_id: m.user_id, + name: m.name, + gender: m.gender, + birth_date: m.birth_date, + blood_type: m.blood_type, + id_number: decrypted_id_number.map(|id| crate::service::masking::mask_id_number(&id)), + allergy_history: decrypted_allergy, + medical_history_summary: decrypted_medical, + emergency_contact_name: m.emergency_contact_name, + emergency_contact_phone: crate::service::masking::mask_phone(decrypted_phone.as_deref()), + status: m.status, + verification_status: m.verification_status, + source: m.source, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} diff --git a/crates/erp-health/src/service/patient_service/mod.rs b/crates/erp-health/src/service/patient_service/mod.rs new file mode 100644 index 0000000..d61839c --- /dev/null +++ b/crates/erp-health/src/service/patient_service/mod.rs @@ -0,0 +1,22 @@ +//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要 +//! +//! 按 4 个功能域组织: +//! - `crud` — 患者基础 CRUD 操作 +//! - `relation` — 家庭成员、医生关联、标签管理(患者关联)、健康摘要 +//! - `tag` — 患者标签 CRUD +//! - `helper` — 共享辅助函数 + +mod crud; +mod helper; +mod relation; +mod tag; + +// 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变 +pub use crud::{list_patients, create_patient, get_patient, update_patient, delete_patient}; +pub use relation::{ + manage_patient_tags, get_health_summary, + list_family_members, create_family_member, update_family_member, delete_family_member, + assign_doctor, remove_doctor, +}; +pub use tag::{list_tags, create_tag, update_tag, delete_tag}; +pub use tag::{CreateTagReq, UpdateTagReq, TagResp}; diff --git a/crates/erp-health/src/service/patient_service/relation.rs b/crates/erp-health/src/service/patient_service/relation.rs new file mode 100644 index 0000000..242d5cf --- /dev/null +++ b/crates/erp-health/src/service/patient_service/relation.rs @@ -0,0 +1,488 @@ +//! 患者管理 Service — 家庭成员、标签管理、医生关联、健康摘要 + +use chrono::Utc; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::crypto as pii; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, TransactionTrait}; +use uuid::Uuid; + +use erp_core::error::check_version; + +use crate::dto::patient_dto::*; +use crate::entity::patient_family_member; +use crate::entity::patient_tag; +use crate::entity::patient_tag_relation; +use crate::entity::patient_doctor_relation; +use crate::entity::doctor_profile; +use crate::error::{HealthError, HealthResult}; +use crate::service::masking::mask_phone; +use crate::state::HealthState; + +use super::helper::find_patient; + +// --------------------------------------------------------------------------- +// 标签管理(患者关联) +// --------------------------------------------------------------------------- + +/// 管理患者标签(覆盖式) +pub async fn manage_patient_tags( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: ManageTagsReq, + operator_id: Option, +) -> HealthResult<()> { + tracing::info!(action = "manage_patient_tags", patient_id = %patient_id, tag_count = req.tag_ids.len(), "Managing patient tags"); + // 确认患者存在 + find_patient(&state.db, tenant_id, patient_id).await?; + + // H-1: 校验所有 tag_ids 属于当前租户 + if !req.tag_ids.is_empty() { + let valid_count = patient_tag::Entity::find() + .filter(patient_tag::Column::TenantId.eq(tenant_id)) + .filter(patient_tag::Column::Id.is_in(req.tag_ids.iter().copied())) + .filter(patient_tag::Column::DeletedAt.is_null()) + .count(&state.db) + .await?; + if valid_count != req.tag_ids.len() as u64 { + return Err(HealthError::Validation("部分标签不存在或不属于当前租户".to_string())); + } + } + + let now = Utc::now(); + + // 在事务中执行:软删除旧关联 + 插入新关联,防止进程崩溃导致标签丢失 + let txn = state.db.begin().await?; + + // 软删除旧的关联 + patient_tag_relation::Entity::update_many() + .col_expr( + patient_tag_relation::Column::DeletedAt, + Expr::value(Some(now)), + ) + .col_expr( + patient_tag_relation::Column::UpdatedAt, + Expr::value(now), + ) + .filter(patient_tag_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_tag_relation::Column::PatientId.eq(patient_id)) + .filter(patient_tag_relation::Column::DeletedAt.is_null()) + .exec(&txn) + .await?; + + // 插入新的关联 + for tag_id in req.tag_ids { + let rel = patient_tag_relation::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + tag_id: Set(tag_id), + 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), + }; + rel.insert(&txn).await?; + } + + txn.commit().await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.tags_updated", "patient") + .with_resource_id(patient_id), + &state.db, + ).await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 健康摘要 +// --------------------------------------------------------------------------- + +/// 获取患者健康摘要 +pub async fn get_health_summary( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + tracing::info!(action = "get_health_summary", patient_id = %patient_id, "Fetching health summary"); + find_patient(&state.db, tenant_id, patient_id).await?; + + use crate::entity::{vital_signs, lab_report, appointment, follow_up_task}; + use sea_orm::QueryOrder; + + // 4 个查询并行执行 + let (latest_vitals_res, latest_lab_res, upcoming_res, pending_follow_ups_res) = tokio::join!( + // 最新体征 + vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .order_by_desc(vital_signs::Column::RecordDate) + .one(&state.db), + // 最新化验 + lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::PatientId.eq(patient_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .order_by_desc(lab_report::Column::ReportDate) + .one(&state.db), + // 待处理预约数 + appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::PatientId.eq(patient_id)) + .filter(appointment::Column::Status.eq("pending")) + .filter(appointment::Column::DeletedAt.is_null()) + .count(&state.db), + // 待办随访数 + follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::PatientId.eq(patient_id)) + .filter(follow_up_task::Column::Status.eq("pending")) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .count(&state.db), + ); + + let latest_vitals = latest_vitals_res?; + let latest_lab = latest_lab_res?; + let upcoming = upcoming_res?; + let pending_follow_ups = pending_follow_ups_res?; + + Ok(serde_json::json!({ + "patient_id": patient_id, + "latest_vital_signs": latest_vitals.map(|v| serde_json::to_value(v).unwrap_or_default()), + "latest_lab_report": latest_lab.map(|v| serde_json::to_value(v).unwrap_or_default()), + "upcoming_appointments": upcoming, + "pending_follow_ups": pending_follow_ups, + })) +} + +// --------------------------------------------------------------------------- +// 家庭成员 +// --------------------------------------------------------------------------- + +/// 家庭成员列表 +pub async fn list_family_members( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult> { + tracing::info!(action = "list_family_members", patient_id = %patient_id, "Listing family members"); + let models = patient_family_member::Entity::find() + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::PatientId.eq(patient_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .order_by_asc(patient_family_member::Column::CreatedAt) + .all(&state.db) + .await?; + + let kek = state.crypto.kek(); + Ok(models.into_iter().map(|m| { + let phone = m.phone.as_ref() + .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) + .map(|p| mask_phone(Some(&p)).unwrap_or(p)); + FamilyMemberResp { + id: m.id, + patient_id: m.patient_id, + name: m.name, + relationship: m.relationship, + phone, + birth_date: m.birth_date, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } + }).collect()) +} + +/// 创建家庭成员 +pub async fn create_family_member( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, + req: FamilyMemberReq, +) -> HealthResult { + tracing::info!(action = "create_family_member", patient_id = %patient_id, name = %req.name, "Creating family member"); + find_patient(&state.db, tenant_id, patient_id).await?; + + let now = Utc::now(); + let id = Uuid::now_v7(); + + let kek = state.crypto.kek(); + let (encrypted_phone, phone_hash) = match req.phone { + Some(ref p) if !p.is_empty() => { + let encrypted = pii::encrypt(kek, p)?; + let hash = pii::hmac_hash(kek, p); + (Some(encrypted), Some(hash)) + } + _ => (None, None), + }; + + let active = patient_family_member::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + name: Set(req.name), + relationship: Set(req.relationship), + phone: Set(encrypted_phone), + phone_hash: Set(phone_hash), + birth_date: Set(req.birth_date), + 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), + key_version: Set(Some(1)), + }; + + let model = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.family_member_created", "patient_family_member") + .with_resource_id(model.id), + &state.db, + ).await; + + let decrypted_phone = model.phone.as_ref() + .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) + .map(|p| mask_phone(Some(&p)).unwrap_or(p)); + Ok(FamilyMemberResp { + id: model.id, + patient_id: model.patient_id, + name: model.name, + relationship: model.relationship, + phone: decrypted_phone, + birth_date: model.birth_date, + notes: model.notes, + created_at: model.created_at, + updated_at: model.updated_at, + version: model.version, + }) +} + +/// 更新家庭成员(乐观锁) +pub async fn update_family_member( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + family_member_id: Uuid, + operator_id: Option, + req: FamilyMemberReq, + expected_version: i32, +) -> HealthResult { + tracing::info!(action = "update_family_member", patient_id = %patient_id, family_member_id = %family_member_id, "Updating family member"); + 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::FamilyMemberNotFound)?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let kek = state.crypto.kek(); + let hmac_key = state.crypto.hmac_key(); + + // 记录变更前的关键字段(phone 为加密值,不记录原文) + let old_values = serde_json::json!({ + "name": model.name, + "relationship": model.relationship, + "birth_date": model.birth_date, + "notes": model.notes, + }); + + let mut active: patient_family_member::ActiveModel = model.into(); + active.name = Set(req.name); + active.relationship = Set(req.relationship); + if let Some(ref p) = req.phone { + let encrypted = pii::encrypt(kek, p)?; + let hash = pii::hmac_hash(hmac_key, p); + active.phone = Set(Some(encrypted)); + active.phone_hash = Set(Some(hash)); + active.key_version = Set(Some(1)); + } + active.birth_date = Set(req.birth_date); + active.notes = Set(req.notes); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active.update(&state.db).await?; + + // 变更后快照 + let new_values = serde_json::json!({ + "name": updated.name, + "relationship": updated.relationship, + "birth_date": updated.birth_date, + "notes": updated.notes, + }); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.family_member_updated", "patient_family_member") + .with_resource_id(updated.id) + .with_changes(Some(old_values), Some(new_values)), + &state.db, + ).await; + + let decrypted_phone = updated.phone.as_ref() + .map(|p| pii::decrypt(kek, p).unwrap_or_else(|_| p.clone())) + .map(|p| mask_phone(Some(&p)).unwrap_or(p)); + Ok(FamilyMemberResp { + id: updated.id, + patient_id: updated.patient_id, + name: updated.name, + relationship: updated.relationship, + phone: decrypted_phone, + birth_date: updated.birth_date, + notes: updated.notes, + created_at: updated.created_at, + updated_at: updated.updated_at, + version: updated.version, + }) +} + +/// 删除家庭成员 +pub async fn delete_family_member( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + family_member_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + tracing::info!(action = "delete_family_member", family_member_id = %family_member_id, "Soft deleting family member"); + 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::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(next_ver); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.family_member_deleted", "patient_family_member") + .with_resource_id(family_member_id), + &state.db, + ).await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 患者-医生关联 +// --------------------------------------------------------------------------- + +/// 分配负责医生 +pub async fn assign_doctor( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + doctor_id: Uuid, + relationship_type: String, + operator_id: Option, +) -> HealthResult<()> { + tracing::info!(action = "assign_doctor", patient_id = %patient_id, doctor_id = %doctor_id, "Assigning doctor to patient"); + find_patient(&state.db, tenant_id, patient_id).await?; + + // 验证医生存在 + doctor_profile::Entity::find() + .filter(doctor_profile::Column::Id.eq(doctor_id)) + .filter(doctor_profile::Column::TenantId.eq(tenant_id)) + .filter(doctor_profile::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::DoctorNotFound)?; + + // H-2: 检查是否已存在相同的未删除关联 + let existing = patient_doctor_relation::Entity::find() + .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_doctor_relation::Column::PatientId.eq(patient_id)) + .filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id)) + .filter(patient_doctor_relation::Column::DeletedAt.is_null()) + .one(&state.db) + .await?; + if existing.is_some() { + return Err(HealthError::Validation("该医生已关联此患者".to_string())); + } + + let now = Utc::now(); + let active = patient_doctor_relation::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + doctor_id: Set(doctor_id), + relationship_type: Set(relationship_type), + 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 relation = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.doctor_assigned", "patient_doctor_relation") + .with_resource_id(relation.id), + &state.db, + ).await; + + Ok(()) +} + +/// 移除负责医生 +pub async fn remove_doctor( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + doctor_id: Uuid, + operator_id: Option, +) -> HealthResult<()> { + tracing::info!(action = "remove_doctor", patient_id = %patient_id, doctor_id = %doctor_id, "Removing doctor from patient"); + let model = patient_doctor_relation::Entity::find() + .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_doctor_relation::Column::PatientId.eq(patient_id)) + .filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id)) + .filter(patient_doctor_relation::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::DoctorNotFound)?; + + let relation_id = model.id; + let mut active: patient_doctor_relation::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient.doctor_removed", "patient_doctor_relation") + .with_resource_id(relation_id), + &state.db, + ).await; + + Ok(()) +} diff --git a/crates/erp-health/src/service/patient_service/tag.rs b/crates/erp-health/src/service/patient_service/tag.rs new file mode 100644 index 0000000..8cef1d2 --- /dev/null +++ b/crates/erp-health/src/service/patient_service/tag.rs @@ -0,0 +1,195 @@ +//! 患者管理 Service — 患者标签 CRUD + +use chrono::Utc; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder}; +use uuid::Uuid; + +use erp_core::error::check_version; + +use crate::entity::patient_tag; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 标签类型定义(原本定义在 patient_service.rs 中) +// --------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize)] +pub struct CreateTagReq { + pub name: String, + pub color: Option, + pub description: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct TagResp { + pub id: Uuid, + pub name: String, + pub color: Option, + pub description: Option, +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpdateTagReq { + pub name: Option, + pub color: Option, + pub description: Option, + pub version: i32, +} + +// --------------------------------------------------------------------------- +// 标签列表 +// --------------------------------------------------------------------------- + +pub async fn list_tags( + state: &crate::state::HealthState, + tenant_id: Uuid, +) -> HealthResult> { + tracing::info!(action = "list_tags", tenant_id = %tenant_id, "Listing patient tags"); + let tags = patient_tag::Entity::find() + .filter(patient_tag::Column::TenantId.eq(tenant_id)) + .filter(patient_tag::Column::DeletedAt.is_null()) + .order_by_asc(patient_tag::Column::Name) + .all(&state.db) + .await + .map_err(|e| crate::error::HealthError::DbError(e.to_string()))?; + + Ok(tags + .into_iter() + .map(|t| crate::dto::patient_dto::TagResp { + id: t.id, + name: t.name, + color: t.color, + description: t.description, + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// 标签 CRUD +// --------------------------------------------------------------------------- + +pub async fn create_tag( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateTagReq, +) -> HealthResult { + tracing::info!(action = "create_tag", tenant_id = %tenant_id, name = %req.name, "Creating patient tag"); + let id = Uuid::now_v7(); + let now = Utc::now(); + let tag = patient_tag::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name), + color: Set(req.color), + description: Set(req.description), + is_system: Set(false), + 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 tag = tag.insert(&state.db).await.map_err(|e| HealthError::DbError(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient_tag.create", "patient_tag") + .with_resource_id(tag.id), + &state.db, + ).await; + + Ok(TagResp { + id: tag.id, name: tag.name, color: tag.color, description: tag.description, + }) +} + +pub async fn update_tag( + state: &HealthState, + tenant_id: Uuid, + tag_id: Uuid, + operator_id: Option, + req: UpdateTagReq, +) -> HealthResult { + tracing::info!(action = "update_tag", tag_id = %tag_id, "Updating patient tag"); + let tag = patient_tag::Entity::find_by_id(tag_id) + .one(&state.db) + .await? + .ok_or(HealthError::TagNotFound)?; + + if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); } + check_version(req.version, tag.version)?; + + // 记录变更前的关键字段 + let old_values = serde_json::json!({ + "name": tag.name, + "color": tag.color, + "description": tag.description, + }); + + let mut active: patient_tag::ActiveModel = tag.into(); + if let Some(name) = req.name { active.name = Set(name); } + if let Some(color) = req.color { active.color = Set(Some(color)); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(req.version + 1); + + let updated = active.update(&state.db).await + .map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?; + + // 变更后快照 + let new_values = serde_json::json!({ + "name": updated.name, + "color": updated.color, + "description": updated.description, + }); + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient_tag.update", "patient_tag") + .with_resource_id(updated.id) + .with_changes(Some(old_values), Some(new_values)), + &state.db, + ).await; + + Ok(TagResp { + id: updated.id, name: updated.name, color: updated.color, description: updated.description, + }) +} + +pub async fn delete_tag( + state: &HealthState, + tenant_id: Uuid, + tag_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + tracing::info!(action = "delete_tag", tag_id = %tag_id, "Soft deleting patient tag"); + let tag = patient_tag::Entity::find_by_id(tag_id) + .one(&state.db) + .await? + .ok_or(HealthError::TagNotFound)?; + + if tag.tenant_id != tenant_id { return Err(HealthError::TagNotFound); } + check_version(version, tag.version)?; + + let mut active: patient_tag::ActiveModel = tag.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(version + 1); + active.update(&state.db).await + .map_err(|e: sea_orm::DbErr| HealthError::DbError(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "patient_tag.delete", "patient_tag") + .with_resource_id(tag_id), + &state.db, + ).await; + + Ok(()) +} diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs deleted file mode 100644 index df4898e..0000000 --- a/crates/erp-health/src/service/points_service.rs +++ /dev/null @@ -1,1863 +0,0 @@ -//! 积分商城 Service — 积分获取、FIFO 消费、兑换核销、线下活动 - -use chrono::{Duration, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::sea_query::Expr; -use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait}; -use uuid::Uuid; - -use erp_core::audit::AuditLog; -use erp_core::audit_service; -use erp_core::error::check_version; -use erp_core::events::DomainEvent; -use erp_core::sea_orm_ext::bump_version; -use erp_core::types::PaginatedResponse; - -use crate::dto::points_dto::*; -use crate::entity::{ - offline_event, offline_event_registration, points_account, points_checkin, - points_order, points_product, points_rule, points_transaction, -}; -use crate::error::{HealthError, HealthResult}; -use crate::state::HealthState; - -// --------------------------------------------------------------------------- -// 积分账户 -// --------------------------------------------------------------------------- - -/// 获取或创建患者的积分账户(支持事务和非事务连接) -async fn get_or_create_account( - db: &C, - tenant_id: Uuid, - patient_id: Uuid, -) -> HealthResult { - if let Some(acc) = points_account::Entity::find() - .filter(points_account::Column::TenantId.eq(tenant_id)) - .filter(points_account::Column::PatientId.eq(patient_id)) - .filter(points_account::Column::DeletedAt.is_null()) - .one(db) - .await? - { - return Ok(acc); - } - let now = Utc::now(); - let active = points_account::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - balance: Set(0), - total_earned: Set(0), - total_spent: Set(0), - total_expired: Set(0), - version: Set(1), - created_at: Set(now), - updated_at: Set(now), - created_by: Set(None), - updated_by: Set(None), - deleted_at: Set(None), - }; - Ok(active.insert(db).await?) -} - -pub async fn get_account( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, -) -> HealthResult { - let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; - Ok(PointsAccountResp { - id: acc.id, - patient_id: acc.patient_id, - balance: acc.balance, - total_earned: acc.total_earned, - total_spent: acc.total_spent, - total_expired: acc.total_expired, - created_at: acc.created_at, - updated_at: acc.updated_at, - version: acc.version, - }) -} - -// --------------------------------------------------------------------------- -// 积分获取(事件触发) -// --------------------------------------------------------------------------- - -/// 核心方法:根据事件类型给患者加积分 -pub async fn earn_points( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - event_type: &str, - operator_id: Option, -) -> HealthResult { - // 1. 查找匹配规则 - let rule = points_rule::Entity::find() - .filter(points_rule::Column::TenantId.eq(tenant_id)) - .filter(points_rule::Column::EventType.eq(event_type)) - .filter(points_rule::Column::IsActive.eq(true)) - .filter(points_rule::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or_else(|| HealthError::Validation(format!("无匹配的积分规则: {}", event_type)))?; - - // 2. 先获取/创建账户(需要 account_id 来做日上限查询) - let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; - - // 3. 检查每日上限(用 account.id 而非 patient_id) - if rule.daily_cap > 0 { - let today = Utc::now().date_naive(); - let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc(); - let earned_today: i32 = points_transaction::Entity::find() - .filter(points_transaction::Column::TenantId.eq(tenant_id)) - .filter(points_transaction::Column::AccountId.eq(acc.id)) - .filter(points_transaction::Column::TransactionType.eq("earn")) - .filter(points_transaction::Column::RuleId.eq(rule.id)) - .filter(points_transaction::Column::CreatedAt.gte(today_start)) - .all(&state.db) - .await? - .iter() - .map(|t| t.amount) - .sum(); - - if earned_today + rule.points_value > rule.daily_cap { - return Err(HealthError::Validation("今日该渠道积分已达上限".into())); - } - } - - // 4. 在事务中执行积分获取 - let txn = state.db.begin().await?; - // 重新读取账户以获取最新 version(事务内) - let acc = points_account::Entity::find_by_id(acc.id) - .one(&txn) - .await? - .ok_or(HealthError::Validation("积分账户不存在".into()))?; - - // 使用数据库级 CAS 防止并发赚取导致余额丢失 - let now = Utc::now(); - let expires_at = now + Duration::days(365); // 12 个月过期 - - // 写入流水 - let txn_record = points_transaction::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - account_id: Set(acc.id), - transaction_type: Set("earn".to_string()), - amount: Set(rule.points_value), - remaining_amount: Set(rule.points_value), - status: Set("active".to_string()), - expires_at: Set(Some(expires_at)), - balance_after: Set(acc.balance + rule.points_value), - rule_id: Set(Some(rule.id)), - order_id: Set(None), - description: Set(Some(format!("{}: +{}", rule.name, rule.points_value))), - 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 inserted = txn_record.insert(&txn).await?; - - // CAS 更新账户余额:基于 version 字段防止并发覆盖 - let cas_result = points_account::Entity::update_many() - .col_expr( - points_account::Column::Balance, - Expr::col(points_account::Column::Balance).add(rule.points_value), - ) - .col_expr( - points_account::Column::TotalEarned, - Expr::col(points_account::Column::TotalEarned).add(rule.points_value), - ) - .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) - .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) - .col_expr( - points_account::Column::Version, - Expr::col(points_account::Column::Version).add(1), - ) - .filter(points_account::Column::Id.eq(acc.id)) - .filter(points_account::Column::Version.eq(acc.version)) - .exec(&txn) - .await?; - if cas_result.rows_affected == 0 { - txn.rollback().await?; - return Err(HealthError::VersionMismatch); - } - - txn.commit().await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "points.earned", "points_transaction") - .with_resource_id(inserted.id), - &state.db, - ).await; - - state.event_bus.publish( - DomainEvent::new(crate::event::POINTS_EARNED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({ - "transaction_id": inserted.id, "account_id": inserted.account_id, - "amount": inserted.amount, "balance_after": inserted.balance_after, - "patient_id": patient_id.to_string(), "reason": event_type, - }))), - &state.db, - ).await; - - Ok(PointsTransactionResp { - id: inserted.id, - account_id: inserted.account_id, - transaction_type: inserted.transaction_type, - amount: inserted.amount, - remaining_amount: inserted.remaining_amount, - status: inserted.status, - expires_at: inserted.expires_at, - balance_after: inserted.balance_after, - description: inserted.description, - created_at: inserted.created_at, - }) -} - -// --------------------------------------------------------------------------- -// 每日打卡 -// --------------------------------------------------------------------------- - -pub async fn daily_checkin( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - operator_id: Option, -) -> HealthResult { - let today = Utc::now().date_naive(); - - // 检查今日是否已打卡 - let existing = points_checkin::Entity::find() - .filter(points_checkin::Column::TenantId.eq(tenant_id)) - .filter(points_checkin::Column::PatientId.eq(patient_id)) - .filter(points_checkin::Column::CheckinDate.eq(today)) - .filter(points_checkin::Column::DeletedAt.is_null()) - .one(&state.db) - .await?; - - if existing.is_some() { - let consecutive = compute_consecutive_days(&state.db, tenant_id, patient_id, today).await?; - return Ok(CheckinStatusResp { - checked_in_today: true, - consecutive_days: consecutive, - next_streak_milestone: next_milestone(consecutive), - }); - } - - // 计算连续天数 - let consecutive = compute_consecutive_days(&state.db, tenant_id, patient_id, today).await? + 1; - - // 事务:写入打卡记录 + 积分获取 + 阶梯奖励 - let txn = state.db.begin().await?; - - let now = Utc::now(); - let active = points_checkin::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - checkin_date: Set(today), - consecutive_days: Set(consecutive), - 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), - }; - active.insert(&txn).await?; - - // 在同一事务中执行积分获取 - earn_points_in_txn(&txn, tenant_id, patient_id, "daily_checkin", operator_id).await?; - - // 检查阶梯奖励(同一事务内) - let _streak_bonus = check_streak_bonus_in_txn(&txn, tenant_id, patient_id, consecutive, operator_id).await?; - - txn.commit().await?; - - let final_consecutive = consecutive; - Ok(CheckinStatusResp { - checked_in_today: true, - consecutive_days: final_consecutive, - next_streak_milestone: next_milestone(final_consecutive), - }) -} - -async fn compute_consecutive_days( - db: &C, - tenant_id: Uuid, - patient_id: Uuid, - today: chrono::NaiveDate, -) -> HealthResult { - let yesterday = today - Duration::days(1); - let yesterday_checkin = points_checkin::Entity::find() - .filter(points_checkin::Column::TenantId.eq(tenant_id)) - .filter(points_checkin::Column::PatientId.eq(patient_id)) - .filter(points_checkin::Column::CheckinDate.eq(yesterday)) - .one(db) - .await?; - Ok(yesterday_checkin.map(|c| c.consecutive_days).unwrap_or(0)) -} - -/// 事务内版本的积分获取(由 daily_checkin 调用) -async fn earn_points_in_txn( - db: &C, - tenant_id: Uuid, - patient_id: Uuid, - event_type: &str, - operator_id: Option, -) -> HealthResult<()> { - // 1. 查找匹配规则 - let rule = points_rule::Entity::find() - .filter(points_rule::Column::TenantId.eq(tenant_id)) - .filter(points_rule::Column::EventType.eq(event_type)) - .filter(points_rule::Column::IsActive.eq(true)) - .filter(points_rule::Column::DeletedAt.is_null()) - .one(db) - .await? - .ok_or_else(|| HealthError::Validation(format!("无匹配的积分规则: {}", event_type)))?; - - // 2. 获取账户 - let acc = get_or_create_account(db, tenant_id, patient_id).await?; - - // 3. 检查每日上限 - if rule.daily_cap > 0 { - let today = Utc::now().date_naive(); - let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc(); - let earned_today: i32 = points_transaction::Entity::find() - .filter(points_transaction::Column::TenantId.eq(tenant_id)) - .filter(points_transaction::Column::AccountId.eq(acc.id)) - .filter(points_transaction::Column::TransactionType.eq("earn")) - .filter(points_transaction::Column::RuleId.eq(rule.id)) - .filter(points_transaction::Column::CreatedAt.gte(today_start)) - .all(db) - .await? - .iter() - .map(|t| t.amount) - .sum(); - if earned_today + rule.points_value > rule.daily_cap { - return Err(HealthError::Validation("今日该渠道积分已达上限".into())); - } - } - - // 4. 写入流水 - let now = Utc::now(); - let txn_record = points_transaction::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - account_id: Set(acc.id), - transaction_type: Set("earn".to_string()), - amount: Set(rule.points_value), - remaining_amount: Set(rule.points_value), - status: Set("active".to_string()), - expires_at: Set(Some(now + Duration::days(365))), - balance_after: Set(acc.balance + rule.points_value), - rule_id: Set(Some(rule.id)), - order_id: Set(None), - description: Set(Some(format!("{}: +{}", rule.name, rule.points_value))), - 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), - }; - txn_record.insert(db).await?; - - // 5. CAS 更新账户余额 - let cas_result = points_account::Entity::update_many() - .col_expr( - points_account::Column::Balance, - Expr::col(points_account::Column::Balance).add(rule.points_value), - ) - .col_expr( - points_account::Column::TotalEarned, - Expr::col(points_account::Column::TotalEarned).add(rule.points_value), - ) - .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) - .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) - .col_expr( - points_account::Column::Version, - Expr::col(points_account::Column::Version).add(1), - ) - .filter(points_account::Column::Id.eq(acc.id)) - .filter(points_account::Column::Version.eq(acc.version)) - .exec(db) - .await?; - if cas_result.rows_affected == 0 { - return Err(HealthError::VersionMismatch); - } - - Ok(()) -} - -/// 事务内版本的阶梯奖励检查(由 daily_checkin 调用) -async fn check_streak_bonus_in_txn( - db: &C, - tenant_id: Uuid, - patient_id: Uuid, - consecutive: i32, - operator_id: Option, -) -> HealthResult { - let mut bonus = 0i32; - if consecutive == 7 { - bonus = get_streak_bonus_value(db, tenant_id, "streak_7d_bonus").await?; - } else if consecutive == 14 { - bonus = get_streak_bonus_value(db, tenant_id, "streak_14d_bonus").await?; - } else if consecutive == 30 { - bonus = get_streak_bonus_value(db, tenant_id, "streak_30d_bonus").await?; - } - if bonus > 0 { - let acc = get_or_create_account(db, tenant_id, patient_id).await?; - let now = Utc::now(); - let txn_record = points_transaction::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - account_id: Set(acc.id), - transaction_type: Set("earn".to_string()), - amount: Set(bonus), - remaining_amount: Set(bonus), - status: Set("active".to_string()), - expires_at: Set(Some(now + Duration::days(365))), - balance_after: Set(acc.balance + bonus), - rule_id: Set(None), - order_id: Set(None), - description: Set(Some(format!("连续打卡{}天奖励: +{}", consecutive, bonus))), - 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), - }; - txn_record.insert(db).await?; - - let cas_result = points_account::Entity::update_many() - .col_expr( - points_account::Column::Balance, - Expr::col(points_account::Column::Balance).add(bonus), - ) - .col_expr( - points_account::Column::TotalEarned, - Expr::col(points_account::Column::TotalEarned).add(bonus), - ) - .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) - .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) - .col_expr( - points_account::Column::Version, - Expr::col(points_account::Column::Version).add(1), - ) - .filter(points_account::Column::Id.eq(acc.id)) - .filter(points_account::Column::Version.eq(acc.version)) - .exec(db) - .await?; - if cas_result.rows_affected == 0 { - return Err(HealthError::VersionMismatch); - } - } - Ok(bonus) -} - -async fn get_streak_bonus_value( - db: &C, - tenant_id: Uuid, - field: &str, -) -> HealthResult { - let rule = points_rule::Entity::find() - .filter(points_rule::Column::TenantId.eq(tenant_id)) - .filter(points_rule::Column::EventType.eq("daily_checkin")) - .filter(points_rule::Column::IsActive.eq(true)) - .filter(points_rule::Column::DeletedAt.is_null()) - .one(db) - .await?; - Ok(rule.map(|r| match field { - "streak_7d_bonus" => r.streak_7d_bonus, - "streak_14d_bonus" => r.streak_14d_bonus, - "streak_30d_bonus" => r.streak_30d_bonus, - _ => 0, - }).unwrap_or(0)) -} - -fn next_milestone(consecutive: i32) -> Option { - [7, 14, 30].iter().find(|&&m| m > consecutive).copied() -} - -pub async fn get_checkin_status( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, -) -> HealthResult { - let today = Utc::now().date_naive(); - let existing = points_checkin::Entity::find() - .filter(points_checkin::Column::TenantId.eq(tenant_id)) - .filter(points_checkin::Column::PatientId.eq(patient_id)) - .filter(points_checkin::Column::CheckinDate.eq(today)) - .one(&state.db) - .await?; - - let consecutive = if let Some(ref ck) = existing { - ck.consecutive_days - } else { - compute_consecutive_days(&state.db, tenant_id, patient_id, today).await? - }; - - Ok(CheckinStatusResp { - checked_in_today: existing.is_some(), - consecutive_days: consecutive, - next_streak_milestone: next_milestone(consecutive), - }) -} - -// --------------------------------------------------------------------------- -// 积分流水查询 -// --------------------------------------------------------------------------- - -pub async fn list_transactions( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = points_transaction::Entity::find() - .filter(points_transaction::Column::TenantId.eq(tenant_id)) - .filter(points_transaction::Column::AccountId.eq(acc.id)); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(points_transaction::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(|m| PointsTransactionResp { - id: m.id, account_id: m.account_id, transaction_type: m.transaction_type, - amount: m.amount, remaining_amount: m.remaining_amount, - status: m.status, expires_at: m.expires_at, - balance_after: m.balance_after, description: m.description, - created_at: m.created_at, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -// --------------------------------------------------------------------------- -// 商品管理 -// --------------------------------------------------------------------------- - -pub async fn list_products( - state: &HealthState, - tenant_id: Uuid, - product_type: Option, - page: u64, - page_size: u64, -) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let mut query = points_product::Entity::find() - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::IsActive.eq(true)) - .filter(points_product::Column::DeletedAt.is_null()); - - if let Some(ref pt) = product_type { - query = query.filter(points_product::Column::ProductType.eq(pt.as_str())); - } - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_asc(points_product::Column::SortOrder) - .order_by_desc(points_product::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(|m| PointsProductResp { - id: m.id, name: m.name, product_type: m.product_type, - points_cost: m.points_cost, stock: m.stock, - image_url: m.image_url, description: m.description, - is_active: m.is_active, sort_order: m.sort_order, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -/// 管理端商品列表 — 不过滤 is_active,显示全部商品 -pub async fn admin_list_products( - state: &HealthState, - tenant_id: Uuid, - product_type: Option, - is_active: Option, - page: u64, - page_size: u64, -) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let mut query = points_product::Entity::find() - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::DeletedAt.is_null()); - - if let Some(ref pt) = product_type { - query = query.filter(points_product::Column::ProductType.eq(pt.as_str())); - } - if let Some(active) = is_active { - query = query.filter(points_product::Column::IsActive.eq(active)); - } - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_asc(points_product::Column::SortOrder) - .order_by_desc(points_product::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(|m| PointsProductResp { - id: m.id, name: m.name, product_type: m.product_type, - points_cost: m.points_cost, stock: m.stock, - image_url: m.image_url, description: m.description, - is_active: m.is_active, sort_order: m.sort_order, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -pub async fn get_product( - state: &HealthState, - tenant_id: Uuid, - product_id: Uuid, -) -> HealthResult { - let m = points_product::Entity::find() - .filter(points_product::Column::Id.eq(product_id)) - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PointsProductNotFound)?; - - Ok(PointsProductResp { - id: m.id, name: m.name, product_type: m.product_type, - points_cost: m.points_cost, stock: m.stock, - image_url: m.image_url, description: m.description, - is_active: m.is_active, sort_order: m.sort_order, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn create_product( - state: &HealthState, - tenant_id: Uuid, - operator_id: Option, - req: CreatePointsProductReq, -) -> HealthResult { - let now = Utc::now(); - let active = points_product::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - name: Set(req.name), - product_type: Set(req.product_type.unwrap_or_else(|| "physical".into())), - points_cost: Set(req.points_cost), - stock: Set(req.stock.unwrap_or(-1)), - image_url: Set(req.image_url), - description: Set(req.description), - service_config: Set(req.service_config), - is_active: Set(true), - sort_order: Set(req.sort_order.unwrap_or(0)), - 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, "points_product.created", "points_product") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(PointsProductResp { - id: m.id, name: m.name, product_type: m.product_type, - points_cost: m.points_cost, stock: m.stock, - image_url: m.image_url, description: m.description, - is_active: m.is_active, sort_order: m.sort_order, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn update_product( - state: &HealthState, - tenant_id: Uuid, - product_id: Uuid, - operator_id: Option, - req: UpdatePointsProductReq, - expected_version: i32, -) -> HealthResult { - let model = points_product::Entity::find() - .filter(points_product::Column::Id.eq(product_id)) - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PointsProductNotFound)?; - - let next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: points_product::ActiveModel = model.into(); - if let Some(name) = req.name { active.name = Set(name); } - if let Some(product_type) = req.product_type { active.product_type = Set(product_type); } - if let Some(points_cost) = req.points_cost { active.points_cost = Set(points_cost); } - if let Some(stock) = req.stock { active.stock = Set(stock); } - if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); } - if let Some(description) = req.description { active.description = Set(Some(description)); } - if let Some(service_config) = req.service_config { active.service_config = Set(Some(service_config)); } - if let Some(is_active) = req.is_active { active.is_active = Set(is_active); } - if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); } - active.updated_at = Set(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, "points_product.updated", "points_product") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(PointsProductResp { - id: m.id, name: m.name, product_type: m.product_type, - points_cost: m.points_cost, stock: m.stock, - image_url: m.image_url, description: m.description, - is_active: m.is_active, sort_order: m.sort_order, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn delete_product( - state: &HealthState, - tenant_id: Uuid, - product_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - let model = points_product::Entity::find() - .filter(points_product::Column::Id.eq(product_id)) - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PointsProductNotFound)?; - - let _next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: points_product::ActiveModel = model.into(); - active.deleted_at = Set(Some(now)); - active.updated_at = Set(now); - active.updated_by = Set(operator_id); - active.version = Set(bump_version(&active.version)); - let m = active.update(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "points_product.deleted", "points_product") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 兑换(FIFO 消费积分) -// --------------------------------------------------------------------------- - -pub async fn exchange_product( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - req: ExchangeReq, - operator_id: Option, -) -> HealthResult { - // 1. 查商品 - let product = points_product::Entity::find() - .filter(points_product::Column::Id.eq(req.product_id)) - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::IsActive.eq(true)) - .filter(points_product::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PointsProductNotFound)?; - - // 2. 检查库存 - if product.stock != -1 && product.stock <= 0 { - return Err(HealthError::Validation("商品库存不足".into())); - } - - // 3. 检查积分余额 - let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; - if acc.balance < product.points_cost { - return Err(HealthError::Validation(format!( - "积分不足: 需要 {},当前 {}", product.points_cost, acc.balance - ))); - } - - // 4. 事务执行:FIFO 扣减积分 + 创建订单 - let txn = state.db.begin().await?; - let cost = product.points_cost; - let mut remaining_cost = cost; - - // FIFO:从最老的未过期 earn 记录开始扣减 - let earn_records = points_transaction::Entity::find() - .filter(points_transaction::Column::TenantId.eq(tenant_id)) - .filter(points_transaction::Column::AccountId.eq(acc.id)) - .filter(points_transaction::Column::TransactionType.eq("earn")) - .filter(points_transaction::Column::Status.eq("active")) - .filter(points_transaction::Column::RemainingAmount.gt(0)) - .filter(points_transaction::Column::ExpiresAt.gt(Utc::now())) - .order_by_asc(points_transaction::Column::CreatedAt) - .all(&txn) - .await?; - - let mut consumed_txn_ids: Vec = Vec::new(); - for earn in earn_records { - if remaining_cost <= 0 { break; } - let consume = remaining_cost.min(earn.remaining_amount); - let new_remaining = earn.remaining_amount - consume; - let new_status = if new_remaining == 0 { "consumed" } else { "active" }; - - // 数据库级 CAS:基于 version 防止并发消费同一笔积分 - let cas_result = points_transaction::Entity::update_many() - .col_expr( - points_transaction::Column::RemainingAmount, - Expr::value(new_remaining), - ) - .col_expr(points_transaction::Column::Status, Expr::value(new_status)) - .col_expr(points_transaction::Column::UpdatedAt, Expr::value(Utc::now())) - .col_expr( - points_transaction::Column::Version, - Expr::col(points_transaction::Column::Version).add(1), - ) - .filter(points_transaction::Column::Id.eq(earn.id)) - .filter(points_transaction::Column::Version.eq(earn.version)) - .exec(&txn) - .await?; - if cas_result.rows_affected == 0 { - txn.rollback().await?; - return Err(HealthError::VersionMismatch); - } - - consumed_txn_ids.push(earn.id); - remaining_cost -= consume; - } - - if remaining_cost > 0 { - txn.rollback().await?; - return Err(HealthError::Validation("可用积分不足以兑换(部分积分可能已过期)".into())); - } - - // 写入消费流水 - let now = Utc::now(); - let spend_txn = points_transaction::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - account_id: Set(acc.id), - transaction_type: Set("spend".to_string()), - amount: Set(-cost), - remaining_amount: Set(0), - status: Set("active".to_string()), - expires_at: Set(None), - balance_after: Set(acc.balance - cost), - rule_id: Set(None), - order_id: Set(None), - description: Set(Some(format!("兑换: {}", product.name))), - 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 spend = spend_txn.insert(&txn).await?; - - // CAS 更新账户余额:基于 version 防止并发覆盖 - let acc_cas = points_account::Entity::update_many() - .col_expr( - points_account::Column::Balance, - Expr::col(points_account::Column::Balance).sub(cost), - ) - .col_expr( - points_account::Column::TotalSpent, - Expr::col(points_account::Column::TotalSpent).add(cost), - ) - .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) - .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) - .col_expr( - points_account::Column::Version, - Expr::col(points_account::Column::Version).add(1), - ) - .filter(points_account::Column::Id.eq(acc.id)) - .filter(points_account::Column::Version.eq(acc.version)) - .exec(&txn) - .await?; - if acc_cas.rows_affected == 0 { - txn.rollback().await?; - return Err(HealthError::VersionMismatch); - } - - // CAS 扣减库存:防止超卖 - if product.stock != -1 { - let stock_cas = points_product::Entity::update_many() - .col_expr( - points_product::Column::Stock, - Expr::col(points_product::Column::Stock).sub(1), - ) - .col_expr(points_product::Column::UpdatedAt, Expr::value(now)) - .col_expr( - points_product::Column::Version, - Expr::col(points_product::Column::Version).add(1), - ) - .filter(points_product::Column::Id.eq(product.id)) - .filter(points_product::Column::Version.eq(product.version)) - .filter(points_product::Column::Stock.gt(0)) - .exec(&txn) - .await?; - if stock_cas.rows_affected == 0 { - txn.rollback().await?; - return Err(HealthError::Validation("商品库存不足".into())); - } - } - - // 创建订单 - let order = points_order::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(patient_id), - product_id: Set(product.id), - points_cost: Set(cost), - status: Set("pending".to_string()), - qr_code: Set(Some(Uuid::now_v7())), - verified_by: Set(None), - verified_at: Set(None), - expires_at: Set(Some(now + Duration::days(30))), - notes: Set(None), - created_at: Set(now), - updated_at: Set(now), - created_by: Set(operator_id), - updated_by: Set(operator_id), - deleted_at: Set(None), - version: Set(1), - }; - let inserted_order = order.insert(&txn).await?; - - // 关联消费流水的 order_id - let mut spend_active: points_transaction::ActiveModel = spend.into(); - spend_active.order_id = Set(Some(inserted_order.id)); - spend_active.update(&txn).await?; - - txn.commit().await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "points_order.created", "points_order") - .with_resource_id(inserted_order.id), - &state.db, - ).await; - - state.event_bus.publish( - DomainEvent::new(crate::event::POINTS_EXCHANGED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({ - "order_id": inserted_order.id, "patient_id": inserted_order.patient_id, - "product_id": inserted_order.product_id, "points_cost": inserted_order.points_cost, - "product_name": product.name, - }))), - &state.db, - ).await; - - Ok(PointsOrderResp { - id: inserted_order.id, - patient_id: inserted_order.patient_id, - product_id: inserted_order.product_id, - product_name: Some(product.name), - points_cost: inserted_order.points_cost, - status: inserted_order.status, - qr_code: inserted_order.qr_code, - verified_by: inserted_order.verified_by, - verified_at: inserted_order.verified_at, - expires_at: inserted_order.expires_at, - notes: inserted_order.notes, - created_at: inserted_order.created_at, - updated_at: inserted_order.updated_at, - version: inserted_order.version, - }) -} - -// --------------------------------------------------------------------------- -// 订单管理 -// --------------------------------------------------------------------------- - -pub async fn list_orders( - state: &HealthState, - tenant_id: Uuid, - patient_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = points_order::Entity::find() - .filter(points_order::Column::TenantId.eq(tenant_id)) - .filter(points_order::Column::PatientId.eq(patient_id)) - .filter(points_order::Column::DeletedAt.is_null()); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(points_order::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(|m| PointsOrderResp { - id: m.id, patient_id: m.patient_id, product_id: m.product_id, - product_name: None, points_cost: m.points_cost, - status: m.status, qr_code: m.qr_code, - verified_by: m.verified_by, verified_at: m.verified_at, - expires_at: m.expires_at, notes: m.notes, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -/// 管理端查看所有订单(不按 patient_id 过滤) -pub async fn admin_list_orders( - state: &HealthState, - tenant_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = points_order::Entity::find() - .filter(points_order::Column::TenantId.eq(tenant_id)) - .filter(points_order::Column::DeletedAt.is_null()); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(points_order::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(|m| PointsOrderResp { - id: m.id, patient_id: m.patient_id, product_id: m.product_id, - product_name: None, points_cost: m.points_cost, - status: m.status, qr_code: m.qr_code, - verified_by: m.verified_by, verified_at: m.verified_at, - expires_at: m.expires_at, notes: m.notes, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -pub async fn verify_order( - state: &HealthState, - tenant_id: Uuid, - qr_code: Uuid, - verifier_id: Uuid, -) -> HealthResult { - let order = points_order::Entity::find() - .filter(points_order::Column::TenantId.eq(tenant_id)) - .filter(points_order::Column::QrCode.eq(qr_code)) - .filter(points_order::Column::Status.eq("pending")) - .filter(points_order::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PointsOrderNotFound)?; - - let now = Utc::now(); - let expected_version = order.version; - - // 数据库级 CAS:防止并发核销同一订单 - let cas_result = points_order::Entity::update_many() - .col_expr(points_order::Column::Status, Expr::value("verified")) - .col_expr(points_order::Column::VerifiedBy, Expr::value(Some(verifier_id))) - .col_expr(points_order::Column::VerifiedAt, Expr::value(Some(now))) - .col_expr(points_order::Column::UpdatedAt, Expr::value(now)) - .col_expr(points_order::Column::UpdatedBy, Expr::value(Some(verifier_id))) - .col_expr( - points_order::Column::Version, - Expr::col(points_order::Column::Version).add(1), - ) - .filter(points_order::Column::Id.eq(order.id)) - .filter(points_order::Column::TenantId.eq(tenant_id)) - .filter(points_order::Column::Version.eq(expected_version)) - .exec(&state.db) - .await?; - if cas_result.rows_affected == 0 { - return Err(HealthError::VersionMismatch); - } - - // 重新查询获取更新后的数据 - let m = points_order::Entity::find_by_id(order.id) - .one(&state.db) - .await? - .ok_or(HealthError::PointsOrderNotFound)?; - - audit_service::record( - AuditLog::new(tenant_id, Some(verifier_id), "points_order.verified", "points_order") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(PointsOrderResp { - id: m.id, patient_id: m.patient_id, product_id: m.product_id, - product_name: None, points_cost: m.points_cost, - status: m.status, qr_code: m.qr_code, - verified_by: m.verified_by, verified_at: m.verified_at, - expires_at: m.expires_at, notes: m.notes, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - }) -} - -// --------------------------------------------------------------------------- -// 积分规则管理 -// --------------------------------------------------------------------------- - -pub async fn list_rules( - state: &HealthState, - tenant_id: Uuid, -) -> HealthResult> { - let models = points_rule::Entity::find() - .filter(points_rule::Column::TenantId.eq(tenant_id)) - .filter(points_rule::Column::DeletedAt.is_null()) - .order_by_asc(points_rule::Column::CreatedAt) - .all(&state.db) - .await?; - - Ok(models.into_iter().map(|m| PointsRuleResp { - id: m.id, event_type: m.event_type, name: m.name, - description: m.description, points_value: m.points_value, - daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus, - streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus, - is_active: m.is_active, created_at: m.created_at, - updated_at: m.updated_at, version: m.version, - }).collect()) -} - -pub async fn create_rule( - state: &HealthState, - tenant_id: Uuid, - operator_id: Option, - req: CreatePointsRuleReq, -) -> HealthResult { - let now = Utc::now(); - let active = points_rule::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - event_type: Set(req.event_type), - name: Set(req.name), - description: Set(req.description), - points_value: Set(req.points_value), - daily_cap: Set(req.daily_cap), - streak_7d_bonus: Set(req.streak_7d_bonus), - streak_14d_bonus: Set(req.streak_14d_bonus), - streak_30d_bonus: Set(req.streak_30d_bonus), - is_active: Set(true), - 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?; - Ok(PointsRuleResp { - id: m.id, event_type: m.event_type, name: m.name, - description: m.description, points_value: m.points_value, - daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus, - streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus, - is_active: m.is_active, created_at: m.created_at, - updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn update_rule( - state: &HealthState, - tenant_id: Uuid, - rule_id: Uuid, - operator_id: Option, - req: UpdatePointsRuleReq, - expected_version: i32, -) -> HealthResult { - let model = points_rule::Entity::find() - .filter(points_rule::Column::Id.eq(rule_id)) - .filter(points_rule::Column::TenantId.eq(tenant_id)) - .filter(points_rule::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PointsRuleNotFound)?; - - let next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: points_rule::ActiveModel = model.into(); - if let Some(name) = req.name { active.name = Set(name); } - if let Some(description) = req.description { active.description = Set(Some(description)); } - if let Some(points_value) = req.points_value { active.points_value = Set(points_value); } - if let Some(daily_cap) = req.daily_cap { active.daily_cap = Set(daily_cap); } - if let Some(streak_7d_bonus) = req.streak_7d_bonus { active.streak_7d_bonus = Set(streak_7d_bonus); } - if let Some(streak_14d_bonus) = req.streak_14d_bonus { active.streak_14d_bonus = Set(streak_14d_bonus); } - if let Some(streak_30d_bonus) = req.streak_30d_bonus { active.streak_30d_bonus = Set(streak_30d_bonus); } - if let Some(is_active) = req.is_active { active.is_active = Set(is_active); } - active.updated_at = Set(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, "points_rule.updated", "points_rule") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(PointsRuleResp { - id: m.id, event_type: m.event_type, name: m.name, - description: m.description, points_value: m.points_value, - daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus, - streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus, - is_active: m.is_active, created_at: m.created_at, - updated_at: m.updated_at, version: m.version, - }) -} - -pub async fn delete_rule( - state: &HealthState, - tenant_id: Uuid, - rule_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - let model = points_rule::Entity::find() - .filter(points_rule::Column::Id.eq(rule_id)) - .filter(points_rule::Column::TenantId.eq(tenant_id)) - .filter(points_rule::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PointsRuleNotFound)?; - - let _next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: points_rule::ActiveModel = model.into(); - active.deleted_at = Set(Some(now)); - active.updated_at = Set(now); - active.updated_by = Set(operator_id); - active.version = Set(bump_version(&active.version)); - let m = active.update(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "points_rule.deleted", "points_rule") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 线下活动 -// --------------------------------------------------------------------------- - -pub async fn list_offline_events( - state: &HealthState, - tenant_id: Uuid, - page: u64, - page_size: u64, -) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = offline_event::Entity::find() - .filter(offline_event::Column::TenantId.eq(tenant_id)) - .filter(offline_event::Column::DeletedAt.is_null()) - .filter(offline_event::Column::Status.is_in(["published", "ongoing", "completed"])); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(offline_event::Column::EventDate) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - let total_pages = total.div_ceil(limit.max(1)); - let data = models.into_iter().map(event_to_resp).collect(); - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -pub async fn register_event( - state: &HealthState, - tenant_id: Uuid, - event_id: Uuid, - patient_id: Uuid, - operator_id: Option, -) -> HealthResult<()> { - let event = offline_event::Entity::find() - .filter(offline_event::Column::Id.eq(event_id)) - .filter(offline_event::Column::TenantId.eq(tenant_id)) - .filter(offline_event::Column::DeletedAt.is_null()) - .filter(offline_event::Column::Status.is_in(["published", "ongoing"])) - .one(&state.db) - .await? - .ok_or(HealthError::OfflineEventNotFound)?; - - if event.max_participants > 0 && event.current_participants >= event.max_participants { - return Err(HealthError::Validation("活动报名已满".into())); - } - - let now = Utc::now(); - - // 在事务中执行报名 + 参与人数 CAS 更新 - let txn = state.db.begin().await?; - - let reg = offline_event_registration::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - event_id: Set(event_id), - patient_id: Set(patient_id), - status: Set("registered".to_string()), - checked_in_at: Set(None), - checked_in_by: Set(None), - points_granted: Set(false), - 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), - }; - reg.insert(&txn).await?; - - // CAS 更新参与人数:防止并发超出 max_participants - let mut cas = offline_event::Entity::update_many() - .col_expr( - offline_event::Column::CurrentParticipants, - Expr::col(offline_event::Column::CurrentParticipants).add(1), - ) - .col_expr(offline_event::Column::UpdatedAt, Expr::value(now)) - .col_expr( - offline_event::Column::Version, - Expr::col(offline_event::Column::Version).add(1), - ) - .filter(offline_event::Column::Id.eq(event_id)) - .filter(offline_event::Column::TenantId.eq(tenant_id)) - .filter(offline_event::Column::Version.eq(event.version)); - - if event.max_participants > 0 { - cas = cas.filter(offline_event::Column::CurrentParticipants.lt(event.max_participants)); - } - - let cas_result = cas.exec(&txn).await?; - if cas_result.rows_affected == 0 { - txn.rollback().await?; - return Err(HealthError::Validation("活动报名已满或版本冲突,请重试".into())); - } - - txn.commit().await?; - - Ok(()) -} - -fn event_to_resp(m: offline_event::Model) -> OfflineEventResp { - OfflineEventResp { - id: m.id, title: m.title, description: m.description, - event_date: m.event_date, start_time: m.start_time, end_time: m.end_time, - location: m.location, points_reward: m.points_reward, - max_participants: m.max_participants, current_participants: m.current_participants, - status: m.status, image_url: m.image_url, - created_at: m.created_at, updated_at: m.updated_at, version: m.version, - } -} - -// --------------------------------------------------------------------------- -// 线下活动 — 管理端 CRUD -// --------------------------------------------------------------------------- - -/// 管理端:创建线下活动 -pub async fn create_offline_event( - state: &HealthState, - tenant_id: Uuid, - operator_id: Option, - req: CreateOfflineEventReq, -) -> HealthResult { - let now = Utc::now(); - let active = offline_event::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - title: Set(req.title), - description: Set(req.description), - event_date: Set(req.event_date), - start_time: Set(req.start_time), - end_time: Set(req.end_time), - location: Set(req.location), - points_reward: Set(req.points_reward.unwrap_or(0)), - max_participants: Set(req.max_participants.unwrap_or(0)), - current_participants: Set(0), - status: Set("draft".to_string()), - image_url: Set(req.image_url), - 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, "offline_event.created", "offline_event") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(event_to_resp(m)) -} - -/// 管理端:更新线下活动 -pub async fn update_offline_event( - state: &HealthState, - tenant_id: Uuid, - event_id: Uuid, - operator_id: Option, - req: UpdateOfflineEventReq, - expected_version: i32, -) -> HealthResult { - let model = offline_event::Entity::find() - .filter(offline_event::Column::Id.eq(event_id)) - .filter(offline_event::Column::TenantId.eq(tenant_id)) - .filter(offline_event::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::OfflineEventNotFound)?; - - let next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: offline_event::ActiveModel = model.into(); - if let Some(title) = req.title { active.title = Set(title); } - if let Some(description) = req.description { active.description = Set(Some(description)); } - if let Some(event_date) = req.event_date { active.event_date = Set(event_date); } - if let Some(start_time) = req.start_time { active.start_time = Set(Some(start_time)); } - if let Some(end_time) = req.end_time { active.end_time = Set(Some(end_time)); } - if let Some(location) = req.location { active.location = Set(Some(location)); } - if let Some(points_reward) = req.points_reward { active.points_reward = Set(points_reward); } - if let Some(max_participants) = req.max_participants { active.max_participants = Set(max_participants); } - if let Some(status) = req.status { active.status = Set(status); } - if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); } - active.updated_at = Set(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, "offline_event.updated", "offline_event") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(event_to_resp(m)) -} - -/// 管理端:软删除线下活动 -pub async fn delete_offline_event( - state: &HealthState, - tenant_id: Uuid, - event_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> HealthResult<()> { - let model = offline_event::Entity::find() - .filter(offline_event::Column::Id.eq(event_id)) - .filter(offline_event::Column::TenantId.eq(tenant_id)) - .filter(offline_event::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::OfflineEventNotFound)?; - - let _next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: offline_event::ActiveModel = model.into(); - active.deleted_at = Set(Some(now)); - active.updated_at = Set(now); - active.updated_by = Set(operator_id); - active.version = Set(bump_version(&active.version)); - let m = active.update(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "offline_event.deleted", "offline_event") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(()) -} - -/// 管理端:分页列出所有线下活动(可按状态筛选) -pub async fn admin_list_offline_events( - state: &HealthState, - tenant_id: Uuid, - status_filter: Option, - page: u64, - page_size: u64, -) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let mut query = offline_event::Entity::find() - .filter(offline_event::Column::TenantId.eq(tenant_id)) - .filter(offline_event::Column::DeletedAt.is_null()); - - if let Some(ref status) = status_filter { - query = query.filter(offline_event::Column::Status.eq(status.as_str())); - } - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(offline_event::Column::EventDate) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - let total_pages = total.div_ceil(limit.max(1)); - let data = models.into_iter().map(event_to_resp).collect(); - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -/// 管理端:扫码签到 + 自动发积分 -pub async fn admin_checkin_event( - state: &HealthState, - tenant_id: Uuid, - event_id: Uuid, - patient_id: Uuid, - operator_id: Option, -) -> HealthResult<()> { - // 1. 查找活动 - let event = offline_event::Entity::find() - .filter(offline_event::Column::Id.eq(event_id)) - .filter(offline_event::Column::TenantId.eq(tenant_id)) - .filter(offline_event::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::OfflineEventNotFound)?; - - // 2. 查找报名记录 - let reg = offline_event_registration::Entity::find() - .filter(offline_event_registration::Column::TenantId.eq(tenant_id)) - .filter(offline_event_registration::Column::EventId.eq(event_id)) - .filter(offline_event_registration::Column::PatientId.eq(patient_id)) - .filter(offline_event_registration::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::Validation("该患者未报名此活动".into()))?; - - if reg.status == "checked_in" { - return Err(HealthError::Validation("该患者已签到".into())); - } - - // 3. 事务:签到 + 发积分 - let txn = state.db.begin().await?; - let now = Utc::now(); - - // 更新报名记录状态 - let mut reg_active: offline_event_registration::ActiveModel = reg.into(); - reg_active.status = Set("checked_in".to_string()); - reg_active.checked_in_at = Set(Some(now)); - reg_active.checked_in_by = Set(operator_id); - reg_active.updated_at = Set(now); - reg_active.updated_by = Set(operator_id); - reg_active.version = Set(bump_version(®_active.version)); - let updated_reg = reg_active.update(&txn).await?; - - // 4. 如果活动有积分奖励且尚未发放,则发放积分 - if event.points_reward > 0 && !updated_reg.points_granted { - let acc = get_or_create_account(&txn, tenant_id, patient_id).await?; - - // 写入积分流水 - let txn_record = points_transaction::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - account_id: Set(acc.id), - transaction_type: Set("earn".to_string()), - amount: Set(event.points_reward), - remaining_amount: Set(event.points_reward), - status: Set("active".to_string()), - expires_at: Set(Some(now + Duration::days(365))), - balance_after: Set(acc.balance + event.points_reward), - rule_id: Set(None), - order_id: Set(None), - description: Set(Some(format!("线下活动签到奖励「{}」: +{}", event.title, event.points_reward))), - 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), - }; - txn_record.insert(&txn).await?; - - // CAS 更新账户余额:基于 version 字段防止并发覆盖 - let cas_result = points_account::Entity::update_many() - .col_expr( - points_account::Column::Balance, - Expr::col(points_account::Column::Balance).add(event.points_reward), - ) - .col_expr( - points_account::Column::TotalEarned, - Expr::col(points_account::Column::TotalEarned).add(event.points_reward), - ) - .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) - .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) - .col_expr( - points_account::Column::Version, - Expr::col(points_account::Column::Version).add(1), - ) - .filter(points_account::Column::Id.eq(acc.id)) - .filter(points_account::Column::Version.eq(acc.version)) - .exec(&txn) - .await?; - if cas_result.rows_affected == 0 { - txn.rollback().await?; - return Err(HealthError::VersionMismatch); - } - - // 标记积分已发放 - let mut reg_active2: offline_event_registration::ActiveModel = updated_reg.into(); - reg_active2.points_granted = Set(true); - reg_active2.updated_at = Set(now); - reg_active2.version = Set(bump_version(®_active2.version)); - reg_active2.update(&txn).await?; - } - - txn.commit().await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "offline_event.checked_in", "offline_event_registration") - .with_resource_id(event_id), - &state.db, - ).await; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// 积分统计 — 管理端 -// --------------------------------------------------------------------------- - -/// 管理端:积分统计汇总 -pub async fn get_points_statistics( - state: &HealthState, - tenant_id: Uuid, -) -> HealthResult { - use sea_orm::FromQueryResult; - - #[derive(Debug, FromQueryResult)] - struct AggRow { - total_issued: Option, - total_spent: Option, - total_expired: Option, - active_accounts: Option, - } - - #[derive(Debug, FromQueryResult)] - struct TopEarnerRow { - id: Uuid, - patient_id: Uuid, - total_earned: Option, - } - - // 聚合查询:总发放/总消费/总过期/活跃账户数 - let agg_sql = r#" - SELECT - COALESCE(SUM(total_earned), 0) AS total_issued, - COALESCE(SUM(total_spent), 0) AS total_spent, - COALESCE(SUM(total_expired), 0) AS total_expired, - COUNT(*) AS active_accounts - FROM points_account - WHERE tenant_id = $1 AND deleted_at IS NULL - "#; - let agg = AggRow::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - agg_sql, - [tenant_id.into()], - ), - ) - .one(&state.db) - .await? - .unwrap_or(AggRow { - total_issued: Some(0), - total_spent: Some(0), - total_expired: Some(0), - active_accounts: Some(0), - }); - - // Top 10 积分获取者 - let top_sql = r#" - SELECT id, patient_id, total_earned - FROM points_account - WHERE tenant_id = $1 AND deleted_at IS NULL - ORDER BY total_earned DESC - LIMIT 10 - "#; - let top_rows = TopEarnerRow::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - top_sql, - [tenant_id.into()], - ), - ) - .all(&state.db) - .await?; - - let top_earners = top_rows.into_iter().map(|r| TopEarner { - account_id: r.id, - patient_id: r.patient_id, - total_earned: r.total_earned.unwrap_or(0), - }).collect(); - - Ok(PointsStatisticsResp { - total_issued: agg.total_issued.unwrap_or(0), - total_spent: agg.total_spent.unwrap_or(0), - total_expired: agg.total_expired.unwrap_or(0), - active_accounts: agg.active_accounts.unwrap_or(0), - top_earners, - }) -} - -// --------------------------------------------------------------------------- -// 积分过期清理 -// --------------------------------------------------------------------------- - -/// 扫描已过期的 earn 交易,扣减账户余额,更新 total_expired -/// 返回处理的过期交易数量 -pub async fn expire_points(db: &sea_orm::DatabaseConnection, event_bus: &erp_core::events::EventBus) -> HealthResult { - let now = Utc::now(); - - // 查找所有已过期但未标记 expired 的 earn 交易 - let expired_txns: Vec = points_transaction::Entity::find() - .filter(points_transaction::Column::TransactionType.eq("earn")) - .filter(points_transaction::Column::Status.eq("active")) - .filter(points_transaction::Column::ExpiresAt.is_not_null()) - .filter(points_transaction::Column::ExpiresAt.lt(now)) - .filter(points_transaction::Column::DeletedAt.is_null()) - .filter(points_transaction::Column::RemainingAmount.gt(0)) - .all(db) - .await?; - - if expired_txns.is_empty() { - return Ok(0); - } - - let tenant_id = expired_txns.first().map(|t| t.tenant_id).unwrap_or_default(); - - let mut processed: u64 = 0; - - for txn in expired_txns { - let txn_id = txn.id; - let account_id = txn.account_id; - let remaining = txn.remaining_amount; - - let txn_result = db - .transaction::<_, (), HealthError>(|txn_db| { - Box::pin(async move { - // 标记交易为 expired - let mut active_txn: points_transaction::ActiveModel = txn.into(); - active_txn.status = Set("expired".to_string()); - active_txn.remaining_amount = Set(0); - active_txn.version = Set(bump_version(&active_txn.version)); - active_txn.updated_at = Set(Utc::now()); - active_txn.update(txn_db).await?; - - // 扣减账户余额,更新 total_expired - let account = points_account::Entity::find_by_id(account_id) - .one(txn_db) - .await? - .ok_or_else(|| HealthError::Validation("积分账户不存在".to_string()))?; - - let new_balance = (account.balance - remaining).max(0); - let new_expired = account.total_expired + remaining; - - let mut active_account: points_account::ActiveModel = account.into(); - let original_ver: i32 = match active_account.version { - sea_orm::ActiveValue::Unchanged(v) => v, - _ => { - return Err(HealthError::Validation( - "积分账户版本号状态异常".to_string(), - )) - } - }; - active_account.balance = Set(new_balance); - active_account.total_expired = Set(new_expired); - active_account.version = Set(original_ver + 1); - active_account.updated_at = Set(Utc::now()); - // 重新从 DB 读取当前版本,防止并发修改导致伪 CAS 通过 - let current = points_account::Entity::find_by_id(account_id) - .one(txn_db) - .await? - .ok_or_else(|| { - HealthError::Validation("积分账户不存在".to_string()) - })?; - let _next_ver = check_version(original_ver, current.version)?; - active_account.update(txn_db).await?; - - Ok(()) - }) - }) - .await; - - match txn_result { - Ok(()) => { - processed += 1; - tracing::debug!(txn_id = %txn_id, remaining = remaining, "积分过期处理完成"); - } - Err(e) => { - tracing::warn!(txn_id = %txn_id, error = %e, "积分过期处理失败,跳过"); - } - } - } - - if processed > 0 { - tracing::info!(count = processed, "积分过期清理完成"); - let event = erp_core::events::DomainEvent::new( - crate::event::POINTS_EXPIRED, - tenant_id, - erp_core::events::build_event_payload(serde_json::json!({ "expired_count": processed })), - ); - event_bus.publish(event, db).await; - } - - Ok(processed) -} diff --git a/crates/erp-health/src/service/points_service/account.rs b/crates/erp-health/src/service/points_service/account.rs new file mode 100644 index 0000000..48e3492 --- /dev/null +++ b/crates/erp-health/src/service/points_service/account.rs @@ -0,0 +1,250 @@ +//! 积分账户管理 — 账户查询、积分获取、流水查询 + +use chrono::{Duration, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::events::DomainEvent; +use erp_core::types::PaginatedResponse; + +use crate::dto::points_dto::*; +use crate::entity::{ + points_account, points_rule, points_transaction, +}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 内部辅助 +// --------------------------------------------------------------------------- + +/// 获取或创建患者的积分账户(支持事务和非事务连接) +pub(crate) async fn get_or_create_account( + db: &C, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + if let Some(acc) = points_account::Entity::find() + .filter(points_account::Column::TenantId.eq(tenant_id)) + .filter(points_account::Column::PatientId.eq(patient_id)) + .filter(points_account::Column::DeletedAt.is_null()) + .one(db) + .await? + { + return Ok(acc); + } + let now = Utc::now(); + let active = points_account::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + balance: Set(0), + total_earned: Set(0), + total_spent: Set(0), + total_expired: Set(0), + version: Set(1), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(None), + updated_by: Set(None), + deleted_at: Set(None), + }; + Ok(active.insert(db).await?) +} + +// --------------------------------------------------------------------------- +// 公开 API +// --------------------------------------------------------------------------- + +/// 查询积分账户 +pub async fn get_account( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; + Ok(PointsAccountResp { + id: acc.id, + patient_id: acc.patient_id, + balance: acc.balance, + total_earned: acc.total_earned, + total_spent: acc.total_spent, + total_expired: acc.total_expired, + created_at: acc.created_at, + updated_at: acc.updated_at, + version: acc.version, + }) +} + +/// 核心方法:根据事件类型给患者加积分 +pub async fn earn_points( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + event_type: &str, + operator_id: Option, +) -> HealthResult { + // 1. 查找匹配规则 + let rule = points_rule::Entity::find() + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::EventType.eq(event_type)) + .filter(points_rule::Column::IsActive.eq(true)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or_else(|| HealthError::Validation(format!("无匹配的积分规则: {}", event_type)))?; + + // 2. 先获取/创建账户(需要 account_id 来做日上限查询) + let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; + + // 3. 检查每日上限(用 account.id 而非 patient_id) + if rule.daily_cap > 0 { + let today = Utc::now().date_naive(); + let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc(); + let earned_today: i32 = points_transaction::Entity::find() + .filter(points_transaction::Column::TenantId.eq(tenant_id)) + .filter(points_transaction::Column::AccountId.eq(acc.id)) + .filter(points_transaction::Column::TransactionType.eq("earn")) + .filter(points_transaction::Column::RuleId.eq(rule.id)) + .filter(points_transaction::Column::CreatedAt.gte(today_start)) + .all(&state.db) + .await? + .iter() + .map(|t| t.amount) + .sum(); + + if earned_today + rule.points_value > rule.daily_cap { + return Err(HealthError::Validation("今日该渠道积分已达上限".into())); + } + } + + // 4. 在事务中执行积分获取 + let txn = state.db.begin().await?; + // 重新读取账户以获取最新 version(事务内) + let acc = points_account::Entity::find_by_id(acc.id) + .one(&txn) + .await? + .ok_or(HealthError::Validation("积分账户不存在".into()))?; + + // 使用数据库级 CAS 防止并发赚取导致余额丢失 + let now = Utc::now(); + let expires_at = now + Duration::days(365); // 12 个月过期 + + // 写入流水 + let txn_record = points_transaction::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + account_id: Set(acc.id), + transaction_type: Set("earn".to_string()), + amount: Set(rule.points_value), + remaining_amount: Set(rule.points_value), + status: Set("active".to_string()), + expires_at: Set(Some(expires_at)), + balance_after: Set(acc.balance + rule.points_value), + rule_id: Set(Some(rule.id)), + order_id: Set(None), + description: Set(Some(format!("{}: +{}", rule.name, rule.points_value))), + 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 inserted = txn_record.insert(&txn).await?; + + // CAS 更新账户余额:基于 version 字段防止并发覆盖 + let cas_result = points_account::Entity::update_many() + .col_expr( + points_account::Column::Balance, + Expr::col(points_account::Column::Balance).add(rule.points_value), + ) + .col_expr( + points_account::Column::TotalEarned, + Expr::col(points_account::Column::TotalEarned).add(rule.points_value), + ) + .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) + .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) + .col_expr( + points_account::Column::Version, + Expr::col(points_account::Column::Version).add(1), + ) + .filter(points_account::Column::Id.eq(acc.id)) + .filter(points_account::Column::Version.eq(acc.version)) + .exec(&txn) + .await?; + if cas_result.rows_affected == 0 { + txn.rollback().await?; + return Err(HealthError::VersionMismatch); + } + + txn.commit().await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points.earned", "points_transaction") + .with_resource_id(inserted.id), + &state.db, + ).await; + + state.event_bus.publish( + DomainEvent::new(crate::event::POINTS_EARNED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({ + "transaction_id": inserted.id, "account_id": inserted.account_id, + "amount": inserted.amount, "balance_after": inserted.balance_after, + "patient_id": patient_id.to_string(), "reason": event_type, + }))), + &state.db, + ).await; + + Ok(PointsTransactionResp { + id: inserted.id, + account_id: inserted.account_id, + transaction_type: inserted.transaction_type, + amount: inserted.amount, + remaining_amount: inserted.remaining_amount, + status: inserted.status, + expires_at: inserted.expires_at, + balance_after: inserted.balance_after, + description: inserted.description, + created_at: inserted.created_at, + }) +} + +/// 查询积分流水 +pub async fn list_transactions( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = points_transaction::Entity::find() + .filter(points_transaction::Column::TenantId.eq(tenant_id)) + .filter(points_transaction::Column::AccountId.eq(acc.id)); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(points_transaction::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(|m| PointsTransactionResp { + id: m.id, account_id: m.account_id, transaction_type: m.transaction_type, + amount: m.amount, remaining_amount: m.remaining_amount, + status: m.status, expires_at: m.expires_at, + balance_after: m.balance_after, description: m.description, + created_at: m.created_at, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} diff --git a/crates/erp-health/src/service/points_service/checkin.rs b/crates/erp-health/src/service/points_service/checkin.rs new file mode 100644 index 0000000..89cda87 --- /dev/null +++ b/crates/erp-health/src/service/points_service/checkin.rs @@ -0,0 +1,315 @@ +//! 每日打卡 — 签到、连续天数、阶梯奖励 + +use chrono::{Duration, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, TransactionTrait}; +use uuid::Uuid; + +use crate::dto::points_dto::*; +use crate::entity::{ + points_account, points_checkin, points_rule, points_transaction, +}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +use super::account::get_or_create_account; + +// --------------------------------------------------------------------------- +// 公开 API +// --------------------------------------------------------------------------- + +/// 每日打卡 +pub async fn daily_checkin( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + operator_id: Option, +) -> HealthResult { + let today = Utc::now().date_naive(); + + // 检查今日是否已打卡 + let existing = points_checkin::Entity::find() + .filter(points_checkin::Column::TenantId.eq(tenant_id)) + .filter(points_checkin::Column::PatientId.eq(patient_id)) + .filter(points_checkin::Column::CheckinDate.eq(today)) + .filter(points_checkin::Column::DeletedAt.is_null()) + .one(&state.db) + .await?; + + if existing.is_some() { + let consecutive = compute_consecutive_days(&state.db, tenant_id, patient_id, today).await?; + return Ok(CheckinStatusResp { + checked_in_today: true, + consecutive_days: consecutive, + next_streak_milestone: next_milestone(consecutive), + }); + } + + // 计算连续天数 + let consecutive = compute_consecutive_days(&state.db, tenant_id, patient_id, today).await? + 1; + + // 事务:写入打卡记录 + 积分获取 + 阶梯奖励 + let txn = state.db.begin().await?; + + let now = Utc::now(); + let active = points_checkin::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + checkin_date: Set(today), + consecutive_days: Set(consecutive), + 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), + }; + active.insert(&txn).await?; + + // 在同一事务中执行积分获取 + earn_points_in_txn(&txn, tenant_id, patient_id, "daily_checkin", operator_id).await?; + + // 检查阶梯奖励(同一事务内) + let _streak_bonus = check_streak_bonus_in_txn(&txn, tenant_id, patient_id, consecutive, operator_id).await?; + + txn.commit().await?; + + let final_consecutive = consecutive; + Ok(CheckinStatusResp { + checked_in_today: true, + consecutive_days: final_consecutive, + next_streak_milestone: next_milestone(final_consecutive), + }) +} + +/// 查询打卡状态 +pub async fn get_checkin_status( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + let today = Utc::now().date_naive(); + let existing = points_checkin::Entity::find() + .filter(points_checkin::Column::TenantId.eq(tenant_id)) + .filter(points_checkin::Column::PatientId.eq(patient_id)) + .filter(points_checkin::Column::CheckinDate.eq(today)) + .one(&state.db) + .await?; + + let consecutive = if let Some(ref ck) = existing { + ck.consecutive_days + } else { + compute_consecutive_days(&state.db, tenant_id, patient_id, today).await? + }; + + Ok(CheckinStatusResp { + checked_in_today: existing.is_some(), + consecutive_days: consecutive, + next_streak_milestone: next_milestone(consecutive), + }) +} + +// --------------------------------------------------------------------------- +// 内部辅助 +// --------------------------------------------------------------------------- + +async fn compute_consecutive_days( + db: &C, + tenant_id: Uuid, + patient_id: Uuid, + today: chrono::NaiveDate, +) -> HealthResult { + let yesterday = today - Duration::days(1); + let yesterday_checkin = points_checkin::Entity::find() + .filter(points_checkin::Column::TenantId.eq(tenant_id)) + .filter(points_checkin::Column::PatientId.eq(patient_id)) + .filter(points_checkin::Column::CheckinDate.eq(yesterday)) + .one(db) + .await?; + Ok(yesterday_checkin.map(|c| c.consecutive_days).unwrap_or(0)) +} + +/// 事务内版本的积分获取(由 daily_checkin 调用) +async fn earn_points_in_txn( + db: &C, + tenant_id: Uuid, + patient_id: Uuid, + event_type: &str, + operator_id: Option, +) -> HealthResult<()> { + // 1. 查找匹配规则 + let rule = points_rule::Entity::find() + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::EventType.eq(event_type)) + .filter(points_rule::Column::IsActive.eq(true)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| HealthError::Validation(format!("无匹配的积分规则: {}", event_type)))?; + + // 2. 获取账户 + let acc = get_or_create_account(db, tenant_id, patient_id).await?; + + // 3. 检查每日上限 + if rule.daily_cap > 0 { + let today = Utc::now().date_naive(); + let today_start = today.and_hms_opt(0, 0, 0).expect("00:00:00 is always a valid time").and_utc(); + let earned_today: i32 = points_transaction::Entity::find() + .filter(points_transaction::Column::TenantId.eq(tenant_id)) + .filter(points_transaction::Column::AccountId.eq(acc.id)) + .filter(points_transaction::Column::TransactionType.eq("earn")) + .filter(points_transaction::Column::RuleId.eq(rule.id)) + .filter(points_transaction::Column::CreatedAt.gte(today_start)) + .all(db) + .await? + .iter() + .map(|t| t.amount) + .sum(); + if earned_today + rule.points_value > rule.daily_cap { + return Err(HealthError::Validation("今日该渠道积分已达上限".into())); + } + } + + // 4. 写入流水 + let now = Utc::now(); + let txn_record = points_transaction::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + account_id: Set(acc.id), + transaction_type: Set("earn".to_string()), + amount: Set(rule.points_value), + remaining_amount: Set(rule.points_value), + status: Set("active".to_string()), + expires_at: Set(Some(now + Duration::days(365))), + balance_after: Set(acc.balance + rule.points_value), + rule_id: Set(Some(rule.id)), + order_id: Set(None), + description: Set(Some(format!("{}: +{}", rule.name, rule.points_value))), + 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), + }; + txn_record.insert(db).await?; + + // 5. CAS 更新账户余额 + let cas_result = points_account::Entity::update_many() + .col_expr( + points_account::Column::Balance, + Expr::col(points_account::Column::Balance).add(rule.points_value), + ) + .col_expr( + points_account::Column::TotalEarned, + Expr::col(points_account::Column::TotalEarned).add(rule.points_value), + ) + .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) + .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) + .col_expr( + points_account::Column::Version, + Expr::col(points_account::Column::Version).add(1), + ) + .filter(points_account::Column::Id.eq(acc.id)) + .filter(points_account::Column::Version.eq(acc.version)) + .exec(db) + .await?; + if cas_result.rows_affected == 0 { + return Err(HealthError::VersionMismatch); + } + + Ok(()) +} + +/// 事务内版本的阶梯奖励检查(由 daily_checkin 调用) +async fn check_streak_bonus_in_txn( + db: &C, + tenant_id: Uuid, + patient_id: Uuid, + consecutive: i32, + operator_id: Option, +) -> HealthResult { + let mut bonus = 0i32; + if consecutive == 7 { + bonus = get_streak_bonus_value(db, tenant_id, "streak_7d_bonus").await?; + } else if consecutive == 14 { + bonus = get_streak_bonus_value(db, tenant_id, "streak_14d_bonus").await?; + } else if consecutive == 30 { + bonus = get_streak_bonus_value(db, tenant_id, "streak_30d_bonus").await?; + } + if bonus > 0 { + let acc = get_or_create_account(db, tenant_id, patient_id).await?; + let now = Utc::now(); + let txn_record = points_transaction::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + account_id: Set(acc.id), + transaction_type: Set("earn".to_string()), + amount: Set(bonus), + remaining_amount: Set(bonus), + status: Set("active".to_string()), + expires_at: Set(Some(now + Duration::days(365))), + balance_after: Set(acc.balance + bonus), + rule_id: Set(None), + order_id: Set(None), + description: Set(Some(format!("连续打卡{}天奖励: +{}", consecutive, bonus))), + 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), + }; + txn_record.insert(db).await?; + + let cas_result = points_account::Entity::update_many() + .col_expr( + points_account::Column::Balance, + Expr::col(points_account::Column::Balance).add(bonus), + ) + .col_expr( + points_account::Column::TotalEarned, + Expr::col(points_account::Column::TotalEarned).add(bonus), + ) + .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) + .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) + .col_expr( + points_account::Column::Version, + Expr::col(points_account::Column::Version).add(1), + ) + .filter(points_account::Column::Id.eq(acc.id)) + .filter(points_account::Column::Version.eq(acc.version)) + .exec(db) + .await?; + if cas_result.rows_affected == 0 { + return Err(HealthError::VersionMismatch); + } + } + Ok(bonus) +} + +async fn get_streak_bonus_value( + db: &C, + tenant_id: Uuid, + field: &str, +) -> HealthResult { + let rule = points_rule::Entity::find() + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::EventType.eq("daily_checkin")) + .filter(points_rule::Column::IsActive.eq(true)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(db) + .await?; + Ok(rule.map(|r| match field { + "streak_7d_bonus" => r.streak_7d_bonus, + "streak_14d_bonus" => r.streak_14d_bonus, + "streak_30d_bonus" => r.streak_30d_bonus, + _ => 0, + }).unwrap_or(0)) +} + +fn next_milestone(consecutive: i32) -> Option { + [7, 14, 30].iter().find(|&&m| m > consecutive).copied() +} diff --git a/crates/erp-health/src/service/points_service/event.rs b/crates/erp-health/src/service/points_service/event.rs new file mode 100644 index 0000000..1089784 --- /dev/null +++ b/crates/erp-health/src/service/points_service/event.rs @@ -0,0 +1,745 @@ +//! 线下活动、积分规则管理、积分统计、积分过期清理 + +use chrono::{Duration, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::sea_orm_ext::bump_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::points_dto::*; +use crate::entity::{ + offline_event, offline_event_registration, points_account, points_rule, + points_transaction, +}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +use super::account::get_or_create_account; + +// --------------------------------------------------------------------------- +// 积分规则管理 +// --------------------------------------------------------------------------- + +pub async fn list_rules( + state: &HealthState, + tenant_id: Uuid, +) -> HealthResult> { + let models = points_rule::Entity::find() + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::DeletedAt.is_null()) + .order_by_asc(points_rule::Column::CreatedAt) + .all(&state.db) + .await?; + + Ok(models.into_iter().map(|m| PointsRuleResp { + id: m.id, event_type: m.event_type, name: m.name, + description: m.description, points_value: m.points_value, + daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus, + streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus, + is_active: m.is_active, created_at: m.created_at, + updated_at: m.updated_at, version: m.version, + }).collect()) +} + +pub async fn create_rule( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreatePointsRuleReq, +) -> HealthResult { + let now = Utc::now(); + let active = points_rule::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + event_type: Set(req.event_type), + name: Set(req.name), + description: Set(req.description), + points_value: Set(req.points_value), + daily_cap: Set(req.daily_cap), + streak_7d_bonus: Set(req.streak_7d_bonus), + streak_14d_bonus: Set(req.streak_14d_bonus), + streak_30d_bonus: Set(req.streak_30d_bonus), + is_active: Set(true), + 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?; + Ok(PointsRuleResp { + id: m.id, event_type: m.event_type, name: m.name, + description: m.description, points_value: m.points_value, + daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus, + streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus, + is_active: m.is_active, created_at: m.created_at, + updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn update_rule( + state: &HealthState, + tenant_id: Uuid, + rule_id: Uuid, + operator_id: Option, + req: UpdatePointsRuleReq, + expected_version: i32, +) -> HealthResult { + let model = points_rule::Entity::find() + .filter(points_rule::Column::Id.eq(rule_id)) + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsRuleNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_rule::ActiveModel = model.into(); + if let Some(name) = req.name { active.name = Set(name); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + if let Some(points_value) = req.points_value { active.points_value = Set(points_value); } + if let Some(daily_cap) = req.daily_cap { active.daily_cap = Set(daily_cap); } + if let Some(streak_7d_bonus) = req.streak_7d_bonus { active.streak_7d_bonus = Set(streak_7d_bonus); } + if let Some(streak_14d_bonus) = req.streak_14d_bonus { active.streak_14d_bonus = Set(streak_14d_bonus); } + if let Some(streak_30d_bonus) = req.streak_30d_bonus { active.streak_30d_bonus = Set(streak_30d_bonus); } + if let Some(is_active) = req.is_active { active.is_active = Set(is_active); } + active.updated_at = Set(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, "points_rule.updated", "points_rule") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(PointsRuleResp { + id: m.id, event_type: m.event_type, name: m.name, + description: m.description, points_value: m.points_value, + daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus, + streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus, + is_active: m.is_active, created_at: m.created_at, + updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn delete_rule( + state: &HealthState, + tenant_id: Uuid, + rule_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + let model = points_rule::Entity::find() + .filter(points_rule::Column::Id.eq(rule_id)) + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsRuleNotFound)?; + + let _next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_rule::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(bump_version(&active.version)); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points_rule.deleted", "points_rule") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 线下活动 — 患者端 +// --------------------------------------------------------------------------- + +pub async fn list_offline_events( + state: &HealthState, + tenant_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = offline_event::Entity::find() + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::DeletedAt.is_null()) + .filter(offline_event::Column::Status.is_in(["published", "ongoing", "completed"])); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(offline_event::Column::EventDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(event_to_resp).collect(); + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn register_event( + state: &HealthState, + tenant_id: Uuid, + event_id: Uuid, + patient_id: Uuid, + operator_id: Option, +) -> HealthResult<()> { + let event = offline_event::Entity::find() + .filter(offline_event::Column::Id.eq(event_id)) + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::DeletedAt.is_null()) + .filter(offline_event::Column::Status.is_in(["published", "ongoing"])) + .one(&state.db) + .await? + .ok_or(HealthError::OfflineEventNotFound)?; + + if event.max_participants > 0 && event.current_participants >= event.max_participants { + return Err(HealthError::Validation("活动报名已满".into())); + } + + let now = Utc::now(); + + // 在事务中执行报名 + 参与人数 CAS 更新 + let txn = state.db.begin().await?; + + let reg = offline_event_registration::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + event_id: Set(event_id), + patient_id: Set(patient_id), + status: Set("registered".to_string()), + checked_in_at: Set(None), + checked_in_by: Set(None), + points_granted: Set(false), + 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), + }; + reg.insert(&txn).await?; + + // CAS 更新参与人数:防止并发超出 max_participants + let mut cas = offline_event::Entity::update_many() + .col_expr( + offline_event::Column::CurrentParticipants, + Expr::col(offline_event::Column::CurrentParticipants).add(1), + ) + .col_expr(offline_event::Column::UpdatedAt, Expr::value(now)) + .col_expr( + offline_event::Column::Version, + Expr::col(offline_event::Column::Version).add(1), + ) + .filter(offline_event::Column::Id.eq(event_id)) + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::Version.eq(event.version)); + + if event.max_participants > 0 { + cas = cas.filter(offline_event::Column::CurrentParticipants.lt(event.max_participants)); + } + + let cas_result = cas.exec(&txn).await?; + if cas_result.rows_affected == 0 { + txn.rollback().await?; + return Err(HealthError::Validation("活动报名已满或版本冲突,请重试".into())); + } + + txn.commit().await?; + + Ok(()) +} + +fn event_to_resp(m: offline_event::Model) -> OfflineEventResp { + OfflineEventResp { + id: m.id, title: m.title, description: m.description, + event_date: m.event_date, start_time: m.start_time, end_time: m.end_time, + location: m.location, points_reward: m.points_reward, + max_participants: m.max_participants, current_participants: m.current_participants, + status: m.status, image_url: m.image_url, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + } +} + +// --------------------------------------------------------------------------- +// 线下活动 — 管理端 CRUD +// --------------------------------------------------------------------------- + +/// 管理端:创建线下活动 +pub async fn create_offline_event( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateOfflineEventReq, +) -> HealthResult { + let now = Utc::now(); + let active = offline_event::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + title: Set(req.title), + description: Set(req.description), + event_date: Set(req.event_date), + start_time: Set(req.start_time), + end_time: Set(req.end_time), + location: Set(req.location), + points_reward: Set(req.points_reward.unwrap_or(0)), + max_participants: Set(req.max_participants.unwrap_or(0)), + current_participants: Set(0), + status: Set("draft".to_string()), + image_url: Set(req.image_url), + 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, "offline_event.created", "offline_event") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(event_to_resp(m)) +} + +/// 管理端:更新线下活动 +pub async fn update_offline_event( + state: &HealthState, + tenant_id: Uuid, + event_id: Uuid, + operator_id: Option, + req: UpdateOfflineEventReq, + expected_version: i32, +) -> HealthResult { + let model = offline_event::Entity::find() + .filter(offline_event::Column::Id.eq(event_id)) + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::OfflineEventNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: offline_event::ActiveModel = model.into(); + if let Some(title) = req.title { active.title = Set(title); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + if let Some(event_date) = req.event_date { active.event_date = Set(event_date); } + if let Some(start_time) = req.start_time { active.start_time = Set(Some(start_time)); } + if let Some(end_time) = req.end_time { active.end_time = Set(Some(end_time)); } + if let Some(location) = req.location { active.location = Set(Some(location)); } + if let Some(points_reward) = req.points_reward { active.points_reward = Set(points_reward); } + if let Some(max_participants) = req.max_participants { active.max_participants = Set(max_participants); } + if let Some(status) = req.status { active.status = Set(status); } + if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); } + active.updated_at = Set(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, "offline_event.updated", "offline_event") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(event_to_resp(m)) +} + +/// 管理端:软删除线下活动 +pub async fn delete_offline_event( + state: &HealthState, + tenant_id: Uuid, + event_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + let model = offline_event::Entity::find() + .filter(offline_event::Column::Id.eq(event_id)) + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::OfflineEventNotFound)?; + + let _next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: offline_event::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(bump_version(&active.version)); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "offline_event.deleted", "offline_event") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(()) +} + +/// 管理端:分页列出所有线下活动(可按状态筛选) +pub async fn admin_list_offline_events( + state: &HealthState, + tenant_id: Uuid, + status_filter: Option, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = offline_event::Entity::find() + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::DeletedAt.is_null()); + + if let Some(ref status) = status_filter { + query = query.filter(offline_event::Column::Status.eq(status.as_str())); + } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(offline_event::Column::EventDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(event_to_resp).collect(); + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +/// 管理端:扫码签到 + 自动发积分 +pub async fn admin_checkin_event( + state: &HealthState, + tenant_id: Uuid, + event_id: Uuid, + patient_id: Uuid, + operator_id: Option, +) -> HealthResult<()> { + // 1. 查找活动 + let event = offline_event::Entity::find() + .filter(offline_event::Column::Id.eq(event_id)) + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::OfflineEventNotFound)?; + + // 2. 查找报名记录 + let reg = offline_event_registration::Entity::find() + .filter(offline_event_registration::Column::TenantId.eq(tenant_id)) + .filter(offline_event_registration::Column::EventId.eq(event_id)) + .filter(offline_event_registration::Column::PatientId.eq(patient_id)) + .filter(offline_event_registration::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::Validation("该患者未报名此活动".into()))?; + + if reg.status == "checked_in" { + return Err(HealthError::Validation("该患者已签到".into())); + } + + // 3. 事务:签到 + 发积分 + let txn = state.db.begin().await?; + let now = Utc::now(); + + // 更新报名记录状态 + let mut reg_active: offline_event_registration::ActiveModel = reg.into(); + reg_active.status = Set("checked_in".to_string()); + reg_active.checked_in_at = Set(Some(now)); + reg_active.checked_in_by = Set(operator_id); + reg_active.updated_at = Set(now); + reg_active.updated_by = Set(operator_id); + reg_active.version = Set(bump_version(®_active.version)); + let updated_reg = reg_active.update(&txn).await?; + + // 4. 如果活动有积分奖励且尚未发放,则发放积分 + if event.points_reward > 0 && !updated_reg.points_granted { + let acc = get_or_create_account(&txn, tenant_id, patient_id).await?; + + // 写入积分流水 + let txn_record = points_transaction::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + account_id: Set(acc.id), + transaction_type: Set("earn".to_string()), + amount: Set(event.points_reward), + remaining_amount: Set(event.points_reward), + status: Set("active".to_string()), + expires_at: Set(Some(now + Duration::days(365))), + balance_after: Set(acc.balance + event.points_reward), + rule_id: Set(None), + order_id: Set(None), + description: Set(Some(format!("线下活动签到奖励「{}」: +{}", event.title, event.points_reward))), + 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), + }; + txn_record.insert(&txn).await?; + + // CAS 更新账户余额:基于 version 字段防止并发覆盖 + let cas_result = points_account::Entity::update_many() + .col_expr( + points_account::Column::Balance, + Expr::col(points_account::Column::Balance).add(event.points_reward), + ) + .col_expr( + points_account::Column::TotalEarned, + Expr::col(points_account::Column::TotalEarned).add(event.points_reward), + ) + .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) + .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) + .col_expr( + points_account::Column::Version, + Expr::col(points_account::Column::Version).add(1), + ) + .filter(points_account::Column::Id.eq(acc.id)) + .filter(points_account::Column::Version.eq(acc.version)) + .exec(&txn) + .await?; + if cas_result.rows_affected == 0 { + txn.rollback().await?; + return Err(HealthError::VersionMismatch); + } + + // 标记积分已发放 + let mut reg_active2: offline_event_registration::ActiveModel = updated_reg.into(); + reg_active2.points_granted = Set(true); + reg_active2.updated_at = Set(now); + reg_active2.version = Set(bump_version(®_active2.version)); + reg_active2.update(&txn).await?; + } + + txn.commit().await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "offline_event.checked_in", "offline_event_registration") + .with_resource_id(event_id), + &state.db, + ).await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 积分统计 — 管理端 +// --------------------------------------------------------------------------- + +/// 管理端:积分统计汇总 +pub async fn get_points_statistics( + state: &HealthState, + tenant_id: Uuid, +) -> HealthResult { + use sea_orm::FromQueryResult; + + #[derive(Debug, FromQueryResult)] + struct AggRow { + total_issued: Option, + total_spent: Option, + total_expired: Option, + active_accounts: Option, + } + + #[derive(Debug, FromQueryResult)] + struct TopEarnerRow { + id: Uuid, + patient_id: Uuid, + total_earned: Option, + } + + // 聚合查询:总发放/总消费/总过期/活跃账户数 + let agg_sql = r#" + SELECT + COALESCE(SUM(total_earned), 0) AS total_issued, + COALESCE(SUM(total_spent), 0) AS total_spent, + COALESCE(SUM(total_expired), 0) AS total_expired, + COUNT(*) AS active_accounts + FROM points_account + WHERE tenant_id = $1 AND deleted_at IS NULL + "#; + let agg = AggRow::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + agg_sql, + [tenant_id.into()], + ), + ) + .one(&state.db) + .await? + .unwrap_or(AggRow { + total_issued: Some(0), + total_spent: Some(0), + total_expired: Some(0), + active_accounts: Some(0), + }); + + // Top 10 积分获取者 + let top_sql = r#" + SELECT id, patient_id, total_earned + FROM points_account + WHERE tenant_id = $1 AND deleted_at IS NULL + ORDER BY total_earned DESC + LIMIT 10 + "#; + let top_rows = TopEarnerRow::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + top_sql, + [tenant_id.into()], + ), + ) + .all(&state.db) + .await?; + + let top_earners = top_rows.into_iter().map(|r| TopEarner { + account_id: r.id, + patient_id: r.patient_id, + total_earned: r.total_earned.unwrap_or(0), + }).collect(); + + Ok(PointsStatisticsResp { + total_issued: agg.total_issued.unwrap_or(0), + total_spent: agg.total_spent.unwrap_or(0), + total_expired: agg.total_expired.unwrap_or(0), + active_accounts: agg.active_accounts.unwrap_or(0), + top_earners, + }) +} + +// --------------------------------------------------------------------------- +// 积分过期清理 +// --------------------------------------------------------------------------- + +/// 扫描已过期的 earn 交易,扣减账户余额,更新 total_expired +/// 返回处理的过期交易数量 +pub async fn expire_points(db: &sea_orm::DatabaseConnection, event_bus: &erp_core::events::EventBus) -> HealthResult { + let now = Utc::now(); + + // 查找所有已过期但未标记 expired 的 earn 交易 + let expired_txns: Vec = points_transaction::Entity::find() + .filter(points_transaction::Column::TransactionType.eq("earn")) + .filter(points_transaction::Column::Status.eq("active")) + .filter(points_transaction::Column::ExpiresAt.is_not_null()) + .filter(points_transaction::Column::ExpiresAt.lt(now)) + .filter(points_transaction::Column::DeletedAt.is_null()) + .filter(points_transaction::Column::RemainingAmount.gt(0)) + .all(db) + .await?; + + if expired_txns.is_empty() { + return Ok(0); + } + + let tenant_id = expired_txns.first().map(|t| t.tenant_id).unwrap_or_default(); + + let mut processed: u64 = 0; + + for txn in expired_txns { + let txn_id = txn.id; + let account_id = txn.account_id; + let remaining = txn.remaining_amount; + + let txn_result = db + .transaction::<_, (), HealthError>(|txn_db| { + Box::pin(async move { + // 标记交易为 expired + let mut active_txn: points_transaction::ActiveModel = txn.into(); + active_txn.status = Set("expired".to_string()); + active_txn.remaining_amount = Set(0); + active_txn.version = Set(bump_version(&active_txn.version)); + active_txn.updated_at = Set(Utc::now()); + active_txn.update(txn_db).await?; + + // 扣减账户余额,更新 total_expired + let account = points_account::Entity::find_by_id(account_id) + .one(txn_db) + .await? + .ok_or_else(|| HealthError::Validation("积分账户不存在".to_string()))?; + + let new_balance = (account.balance - remaining).max(0); + let new_expired = account.total_expired + remaining; + + let mut active_account: points_account::ActiveModel = account.into(); + let original_ver: i32 = match active_account.version { + sea_orm::ActiveValue::Unchanged(v) => v, + _ => { + return Err(HealthError::Validation( + "积分账户版本号状态异常".to_string(), + )) + } + }; + active_account.balance = Set(new_balance); + active_account.total_expired = Set(new_expired); + active_account.version = Set(original_ver + 1); + active_account.updated_at = Set(Utc::now()); + // 重新从 DB 读取当前版本,防止并发修改导致伪 CAS 通过 + let current = points_account::Entity::find_by_id(account_id) + .one(txn_db) + .await? + .ok_or_else(|| { + HealthError::Validation("积分账户不存在".to_string()) + })?; + let _next_ver = check_version(original_ver, current.version)?; + active_account.update(txn_db).await?; + + Ok(()) + }) + }) + .await; + + match txn_result { + Ok(()) => { + processed += 1; + tracing::debug!(txn_id = %txn_id, remaining = remaining, "积分过期处理完成"); + } + Err(e) => { + tracing::warn!(txn_id = %txn_id, error = %e, "积分过期处理失败,跳过"); + } + } + } + + if processed > 0 { + tracing::info!(count = processed, "积分过期清理完成"); + let event = erp_core::events::DomainEvent::new( + crate::event::POINTS_EXPIRED, + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ "expired_count": processed })), + ); + event_bus.publish(event, db).await; + } + + Ok(processed) +} diff --git a/crates/erp-health/src/service/points_service/mod.rs b/crates/erp-health/src/service/points_service/mod.rs new file mode 100644 index 0000000..87d8dd5 --- /dev/null +++ b/crates/erp-health/src/service/points_service/mod.rs @@ -0,0 +1,27 @@ +//! 积分商城 Service — 积分获取、FIFO 消费、兑换核销、线下活动 +//! +//! 按 4 个功能域组织: +//! - `account` — 积分账户管理、积分获取、流水查询 +//! - `checkin` — 每日打卡、连续天数、阶梯奖励 +//! - `product` — 商品管理、积分兑换(FIFO 消费)、订单管理 +//! - `event` — 线下活动、积分规则管理、积分统计、积分过期清理 + +mod account; +mod checkin; +mod event; +mod product; + +// 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变 +pub use account::{get_account, earn_points, list_transactions}; +pub use checkin::{daily_checkin, get_checkin_status}; +pub use product::{ + list_products, admin_list_products, get_product, create_product, update_product, + delete_product, exchange_product, list_orders, admin_list_orders, verify_order, +}; +pub use event::{ + list_rules, create_rule, update_rule, delete_rule, + list_offline_events, register_event, + create_offline_event, update_offline_event, delete_offline_event, + admin_list_offline_events, admin_checkin_event, + get_points_statistics, expire_points, +}; diff --git a/crates/erp-health/src/service/points_service/product.rs b/crates/erp-health/src/service/points_service/product.rs new file mode 100644 index 0000000..1fbca42 --- /dev/null +++ b/crates/erp-health/src/service/points_service/product.rs @@ -0,0 +1,616 @@ +//! 商品管理、积分兑换(FIFO 消费)、订单管理 + +use chrono::{Duration, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::sea_orm_ext::bump_version; +use erp_core::events::DomainEvent; +use erp_core::types::PaginatedResponse; + +use crate::dto::points_dto::*; +use crate::entity::{ + points_account, points_order, points_product, points_transaction, +}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +use super::account::get_or_create_account; + +// --------------------------------------------------------------------------- +// 商品管理 +// --------------------------------------------------------------------------- + +pub async fn list_products( + state: &HealthState, + tenant_id: Uuid, + product_type: Option, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = points_product::Entity::find() + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::IsActive.eq(true)) + .filter(points_product::Column::DeletedAt.is_null()); + + if let Some(ref pt) = product_type { + query = query.filter(points_product::Column::ProductType.eq(pt.as_str())); + } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_asc(points_product::Column::SortOrder) + .order_by_desc(points_product::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(|m| PointsProductResp { + id: m.id, name: m.name, product_type: m.product_type, + points_cost: m.points_cost, stock: m.stock, + image_url: m.image_url, description: m.description, + is_active: m.is_active, sort_order: m.sort_order, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +/// 管理端商品列表 — 不过滤 is_active,显示全部商品 +pub async fn admin_list_products( + state: &HealthState, + tenant_id: Uuid, + product_type: Option, + is_active: Option, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = points_product::Entity::find() + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()); + + if let Some(ref pt) = product_type { + query = query.filter(points_product::Column::ProductType.eq(pt.as_str())); + } + if let Some(active) = is_active { + query = query.filter(points_product::Column::IsActive.eq(active)); + } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_asc(points_product::Column::SortOrder) + .order_by_desc(points_product::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(|m| PointsProductResp { + id: m.id, name: m.name, product_type: m.product_type, + points_cost: m.points_cost, stock: m.stock, + image_url: m.image_url, description: m.description, + is_active: m.is_active, sort_order: m.sort_order, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn get_product( + state: &HealthState, + tenant_id: Uuid, + product_id: Uuid, +) -> HealthResult { + let m = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsProductNotFound)?; + + Ok(PointsProductResp { + id: m.id, name: m.name, product_type: m.product_type, + points_cost: m.points_cost, stock: m.stock, + image_url: m.image_url, description: m.description, + is_active: m.is_active, sort_order: m.sort_order, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn create_product( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreatePointsProductReq, +) -> HealthResult { + let now = Utc::now(); + let active = points_product::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + name: Set(req.name), + product_type: Set(req.product_type.unwrap_or_else(|| "physical".into())), + points_cost: Set(req.points_cost), + stock: Set(req.stock.unwrap_or(-1)), + image_url: Set(req.image_url), + description: Set(req.description), + service_config: Set(req.service_config), + is_active: Set(true), + sort_order: Set(req.sort_order.unwrap_or(0)), + 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, "points_product.created", "points_product") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(PointsProductResp { + id: m.id, name: m.name, product_type: m.product_type, + points_cost: m.points_cost, stock: m.stock, + image_url: m.image_url, description: m.description, + is_active: m.is_active, sort_order: m.sort_order, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn update_product( + state: &HealthState, + tenant_id: Uuid, + product_id: Uuid, + operator_id: Option, + req: UpdatePointsProductReq, + expected_version: i32, +) -> HealthResult { + let model = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsProductNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_product::ActiveModel = model.into(); + if let Some(name) = req.name { active.name = Set(name); } + if let Some(product_type) = req.product_type { active.product_type = Set(product_type); } + if let Some(points_cost) = req.points_cost { active.points_cost = Set(points_cost); } + if let Some(stock) = req.stock { active.stock = Set(stock); } + if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + if let Some(service_config) = req.service_config { active.service_config = Set(Some(service_config)); } + if let Some(is_active) = req.is_active { active.is_active = Set(is_active); } + if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); } + active.updated_at = Set(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, "points_product.updated", "points_product") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(PointsProductResp { + id: m.id, name: m.name, product_type: m.product_type, + points_cost: m.points_cost, stock: m.stock, + image_url: m.image_url, description: m.description, + is_active: m.is_active, sort_order: m.sort_order, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} + +pub async fn delete_product( + state: &HealthState, + tenant_id: Uuid, + product_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + let model = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsProductNotFound)?; + + let _next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_product::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(bump_version(&active.version)); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points_product.deleted", "points_product") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 兑换(FIFO 消费积分) +// --------------------------------------------------------------------------- + +pub async fn exchange_product( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: ExchangeReq, + operator_id: Option, +) -> HealthResult { + // 1. 查商品 + let product = points_product::Entity::find() + .filter(points_product::Column::Id.eq(req.product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::IsActive.eq(true)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsProductNotFound)?; + + // 2. 检查库存 + if product.stock != -1 && product.stock <= 0 { + return Err(HealthError::Validation("商品库存不足".into())); + } + + // 3. 检查积分余额 + let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; + if acc.balance < product.points_cost { + return Err(HealthError::Validation(format!( + "积分不足: 需要 {},当前 {}", product.points_cost, acc.balance + ))); + } + + // 4. 事务执行:FIFO 扣减积分 + 创建订单 + let txn = state.db.begin().await?; + let cost = product.points_cost; + let mut remaining_cost = cost; + + // FIFO:从最老的未过期 earn 记录开始扣减 + let earn_records = points_transaction::Entity::find() + .filter(points_transaction::Column::TenantId.eq(tenant_id)) + .filter(points_transaction::Column::AccountId.eq(acc.id)) + .filter(points_transaction::Column::TransactionType.eq("earn")) + .filter(points_transaction::Column::Status.eq("active")) + .filter(points_transaction::Column::RemainingAmount.gt(0)) + .filter(points_transaction::Column::ExpiresAt.gt(Utc::now())) + .order_by_asc(points_transaction::Column::CreatedAt) + .all(&txn) + .await?; + + let mut consumed_txn_ids: Vec = Vec::new(); + for earn in earn_records { + if remaining_cost <= 0 { break; } + let consume = remaining_cost.min(earn.remaining_amount); + let new_remaining = earn.remaining_amount - consume; + let new_status = if new_remaining == 0 { "consumed" } else { "active" }; + + // 数据库级 CAS:基于 version 防止并发消费同一笔积分 + let cas_result = points_transaction::Entity::update_many() + .col_expr( + points_transaction::Column::RemainingAmount, + Expr::value(new_remaining), + ) + .col_expr(points_transaction::Column::Status, Expr::value(new_status)) + .col_expr(points_transaction::Column::UpdatedAt, Expr::value(Utc::now())) + .col_expr( + points_transaction::Column::Version, + Expr::col(points_transaction::Column::Version).add(1), + ) + .filter(points_transaction::Column::Id.eq(earn.id)) + .filter(points_transaction::Column::Version.eq(earn.version)) + .exec(&txn) + .await?; + if cas_result.rows_affected == 0 { + txn.rollback().await?; + return Err(HealthError::VersionMismatch); + } + + consumed_txn_ids.push(earn.id); + remaining_cost -= consume; + } + + if remaining_cost > 0 { + txn.rollback().await?; + return Err(HealthError::Validation("可用积分不足以兑换(部分积分可能已过期)".into())); + } + + // 写入消费流水 + let now = Utc::now(); + let spend_txn = points_transaction::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + account_id: Set(acc.id), + transaction_type: Set("spend".to_string()), + amount: Set(-cost), + remaining_amount: Set(0), + status: Set("active".to_string()), + expires_at: Set(None), + balance_after: Set(acc.balance - cost), + rule_id: Set(None), + order_id: Set(None), + description: Set(Some(format!("兑换: {}", product.name))), + 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 spend = spend_txn.insert(&txn).await?; + + // CAS 更新账户余额:基于 version 防止并发覆盖 + let acc_cas = points_account::Entity::update_many() + .col_expr( + points_account::Column::Balance, + Expr::col(points_account::Column::Balance).sub(cost), + ) + .col_expr( + points_account::Column::TotalSpent, + Expr::col(points_account::Column::TotalSpent).add(cost), + ) + .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) + .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) + .col_expr( + points_account::Column::Version, + Expr::col(points_account::Column::Version).add(1), + ) + .filter(points_account::Column::Id.eq(acc.id)) + .filter(points_account::Column::Version.eq(acc.version)) + .exec(&txn) + .await?; + if acc_cas.rows_affected == 0 { + txn.rollback().await?; + return Err(HealthError::VersionMismatch); + } + + // CAS 扣减库存:防止超卖 + if product.stock != -1 { + let stock_cas = points_product::Entity::update_many() + .col_expr( + points_product::Column::Stock, + Expr::col(points_product::Column::Stock).sub(1), + ) + .col_expr(points_product::Column::UpdatedAt, Expr::value(now)) + .col_expr( + points_product::Column::Version, + Expr::col(points_product::Column::Version).add(1), + ) + .filter(points_product::Column::Id.eq(product.id)) + .filter(points_product::Column::Version.eq(product.version)) + .filter(points_product::Column::Stock.gt(0)) + .exec(&txn) + .await?; + if stock_cas.rows_affected == 0 { + txn.rollback().await?; + return Err(HealthError::Validation("商品库存不足".into())); + } + } + + // 创建订单 + let order = points_order::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + product_id: Set(product.id), + points_cost: Set(cost), + status: Set("pending".to_string()), + qr_code: Set(Some(Uuid::now_v7())), + verified_by: Set(None), + verified_at: Set(None), + expires_at: Set(Some(now + Duration::days(30))), + notes: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let inserted_order = order.insert(&txn).await?; + + // 关联消费流水的 order_id + let mut spend_active: points_transaction::ActiveModel = spend.into(); + spend_active.order_id = Set(Some(inserted_order.id)); + spend_active.update(&txn).await?; + + txn.commit().await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "points_order.created", "points_order") + .with_resource_id(inserted_order.id), + &state.db, + ).await; + + state.event_bus.publish( + DomainEvent::new(crate::event::POINTS_EXCHANGED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({ + "order_id": inserted_order.id, "patient_id": inserted_order.patient_id, + "product_id": inserted_order.product_id, "points_cost": inserted_order.points_cost, + "product_name": product.name, + }))), + &state.db, + ).await; + + Ok(PointsOrderResp { + id: inserted_order.id, + patient_id: inserted_order.patient_id, + product_id: inserted_order.product_id, + product_name: Some(product.name), + points_cost: inserted_order.points_cost, + status: inserted_order.status, + qr_code: inserted_order.qr_code, + verified_by: inserted_order.verified_by, + verified_at: inserted_order.verified_at, + expires_at: inserted_order.expires_at, + notes: inserted_order.notes, + created_at: inserted_order.created_at, + updated_at: inserted_order.updated_at, + version: inserted_order.version, + }) +} + +// --------------------------------------------------------------------------- +// 订单管理 +// --------------------------------------------------------------------------- + +pub async fn list_orders( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = points_order::Entity::find() + .filter(points_order::Column::TenantId.eq(tenant_id)) + .filter(points_order::Column::PatientId.eq(patient_id)) + .filter(points_order::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(points_order::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(|m| PointsOrderResp { + id: m.id, patient_id: m.patient_id, product_id: m.product_id, + product_name: None, points_cost: m.points_cost, + status: m.status, qr_code: m.qr_code, + verified_by: m.verified_by, verified_at: m.verified_at, + expires_at: m.expires_at, notes: m.notes, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +/// 管理端查看所有订单(不按 patient_id 过滤) +pub async fn admin_list_orders( + state: &HealthState, + tenant_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = points_order::Entity::find() + .filter(points_order::Column::TenantId.eq(tenant_id)) + .filter(points_order::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(points_order::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(|m| PointsOrderResp { + id: m.id, patient_id: m.patient_id, product_id: m.product_id, + product_name: None, points_cost: m.points_cost, + status: m.status, qr_code: m.qr_code, + verified_by: m.verified_by, verified_at: m.verified_at, + expires_at: m.expires_at, notes: m.notes, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) +} + +pub async fn verify_order( + state: &HealthState, + tenant_id: Uuid, + qr_code: Uuid, + verifier_id: Uuid, +) -> HealthResult { + let order = points_order::Entity::find() + .filter(points_order::Column::TenantId.eq(tenant_id)) + .filter(points_order::Column::QrCode.eq(qr_code)) + .filter(points_order::Column::Status.eq("pending")) + .filter(points_order::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PointsOrderNotFound)?; + + let now = Utc::now(); + let expected_version = order.version; + + // 数据库级 CAS:防止并发核销同一订单 + let cas_result = points_order::Entity::update_many() + .col_expr(points_order::Column::Status, Expr::value("verified")) + .col_expr(points_order::Column::VerifiedBy, Expr::value(Some(verifier_id))) + .col_expr(points_order::Column::VerifiedAt, Expr::value(Some(now))) + .col_expr(points_order::Column::UpdatedAt, Expr::value(now)) + .col_expr(points_order::Column::UpdatedBy, Expr::value(Some(verifier_id))) + .col_expr( + points_order::Column::Version, + Expr::col(points_order::Column::Version).add(1), + ) + .filter(points_order::Column::Id.eq(order.id)) + .filter(points_order::Column::TenantId.eq(tenant_id)) + .filter(points_order::Column::Version.eq(expected_version)) + .exec(&state.db) + .await?; + if cas_result.rows_affected == 0 { + return Err(HealthError::VersionMismatch); + } + + // 重新查询获取更新后的数据 + let m = points_order::Entity::find_by_id(order.id) + .one(&state.db) + .await? + .ok_or(HealthError::PointsOrderNotFound)?; + + audit_service::record( + AuditLog::new(tenant_id, Some(verifier_id), "points_order.verified", "points_order") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(PointsOrderResp { + id: m.id, patient_id: m.patient_id, product_id: m.product_id, + product_name: None, points_cost: m.points_cost, + status: m.status, qr_code: m.qr_code, + verified_by: m.verified_by, verified_at: m.verified_at, + expires_at: m.expires_at, notes: m.notes, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) +} diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs deleted file mode 100644 index 6cd630c..0000000 --- a/crates/erp-health/src/service/stats_service.rs +++ /dev/null @@ -1,1117 +0,0 @@ -use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult}; - -use erp_core::error::AppResult; - -use crate::dto::stats_dto::*; -use crate::entity::{ - patient, consultation_session, follow_up_task, - points_transaction, lab_report, - appointment, vital_signs, patient_doctor_relation, doctor_profile, -}; -use crate::state::HealthState; - -// --------------------------------------------------------------------------- -// 基础运营统计 -// --------------------------------------------------------------------------- - -pub async fn get_patient_statistics( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - let db = &state.db; - - let total = patient::Entity::find() - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()) - .count(db) - .await?; - - let new_this_month = patient::Entity::find() - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()) - .filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await?; - - let new_this_week = patient::Entity::find() - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()) - .filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('week', NOW())"))) - .count(db) - .await?; - - let active_this_month = points_transaction::Entity::find() - .filter(points_transaction::Column::TenantId.eq(tenant_id)) - .filter(Expr::col(points_transaction::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await?; - - Ok(PatientStatisticsResp { - total_patients: total as i64, - new_this_month: new_this_month as i64, - new_this_week: new_this_week as i64, - active_this_month: active_this_month as i64, - }) -} - -pub async fn get_consultation_statistics( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - let db = &state.db; - - let total_sessions = consultation_session::Entity::find() - .filter(consultation_session::Column::TenantId.eq(tenant_id)) - .filter(consultation_session::Column::DeletedAt.is_null()) - .count(db) - .await?; - - let pending_reply = consultation_session::Entity::find() - .filter(consultation_session::Column::TenantId.eq(tenant_id)) - .filter(consultation_session::Column::DeletedAt.is_null()) - .filter(consultation_session::Column::Status.eq("waiting")) - .count(db) - .await?; - - let this_month = consultation_session::Entity::find() - .filter(consultation_session::Column::TenantId.eq(tenant_id)) - .filter(consultation_session::Column::DeletedAt.is_null()) - .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await?; - - let avg_response_time_minutes = compute_avg_response_time(db, tenant_id).await?; - - Ok(ConsultationStatisticsResp { - total_sessions: total_sessions as i64, - pending_reply: pending_reply as i64, - avg_response_time_minutes, - this_month: this_month as i64, - }) -} - -pub async fn get_follow_up_statistics( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - let db = &state.db; - - // 单次 GROUP BY 查询替代 4 次独立 COUNT - let sql = r#" - SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt - FROM follow_up_task - WHERE tenant_id = $1 AND deleted_at IS NULL - GROUP BY GROUPING SETS ((status), ()) - "#; - - #[derive(Debug, sea_orm::FromQueryResult)] - struct StatusCount { - status: String, - cnt: i64, - } - - let rows: Vec = sea_orm::FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .all(db) - .await?; - - let mut total_tasks: i64 = 0; - let mut completed: i64 = 0; - let mut pending: i64 = 0; - let mut overdue: i64 = 0; - - for row in &rows { - match row.status.as_str() { - "__total" => total_tasks = row.cnt, - "completed" => completed = row.cnt, - "pending" => pending = row.cnt, - "overdue" => overdue = row.cnt, - _ => {} - } - } - - let completion_rate = if completed + pending + overdue > 0 { - (completed as f64 / (completed + pending + overdue) as f64) * 100.0 - } else { - 0.0 - }; - - Ok(FollowUpStatisticsResp { - total_tasks, - completed, - pending, - overdue, - completion_rate, - }) -} - -#[derive(Debug, FromQueryResult)] -struct AvgResponseTime { - avg_minutes: Option, -} - -async fn compute_avg_response_time( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, -) -> AppResult> { - let sql = r#" - SELECT AVG(EXTRACT(EPOCH FROM (m.created_at - s.created_at)) / 60) AS avg_minutes - FROM consultation_session s - INNER JOIN consultation_message m ON m.session_id = s.id AND m.tenant_id = $1 AND m.deleted_at IS NULL - WHERE s.tenant_id = $1 AND s.deleted_at IS NULL - AND m.sender_role = 'doctor' - "#; - - let result: Option = sea_orm::FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .one(db) - .await?; - - Ok(result.and_then(|r| r.avg_minutes)) -} - -// --------------------------------------------------------------------------- -// 健康数据统计 -// --------------------------------------------------------------------------- - -pub async fn get_lab_report_statistics( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - let db = &state.db; - - let total_reports = lab_report::Entity::find() - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .count(db) - .await?; - - let this_month = lab_report::Entity::find() - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .filter(Expr::col(lab_report::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await?; - - let pending_review = lab_report::Entity::find() - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .filter(lab_report::Column::Status.eq("pending")) - .count(db) - .await?; - - let reviewed = lab_report::Entity::find() - .filter(lab_report::Column::TenantId.eq(tenant_id)) - .filter(lab_report::Column::DeletedAt.is_null()) - .filter(lab_report::Column::Status.eq("reviewed")) - .count(db) - .await?; - - let type_distribution = count_by_field( - db, tenant_id, - "SELECT report_type AS name, COUNT(*) AS value FROM lab_report \ - WHERE tenant_id = $1 AND deleted_at IS NULL \ - AND created_at >= date_trunc('month', NOW()) \ - GROUP BY report_type ORDER BY value DESC", - ).await?; - - let abnormal_items = count_abnormal_lab_items(db, tenant_id).await?; - - Ok(LabReportStatisticsResp { - total_reports: total_reports as i64, - this_month: this_month as i64, - type_distribution, - abnormal_items, - pending_review: pending_review as i64, - reviewed: reviewed as i64, - }) -} - -pub async fn get_appointment_statistics( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - let db = &state.db; - - let total_appointments = appointment::Entity::find() - .filter(appointment::Column::TenantId.eq(tenant_id)) - .filter(appointment::Column::DeletedAt.is_null()) - .count(db) - .await?; - - let this_month = appointment::Entity::find() - .filter(appointment::Column::TenantId.eq(tenant_id)) - .filter(appointment::Column::DeletedAt.is_null()) - .filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await?; - - let status_distribution = count_by_field( - db, tenant_id, - "SELECT status AS name, COUNT(*) AS value FROM appointment \ - WHERE tenant_id = $1 AND deleted_at IS NULL \ - AND created_at >= date_trunc('month', NOW()) \ - GROUP BY status ORDER BY value DESC", - ).await?; - - let type_distribution = count_by_field( - db, tenant_id, - "SELECT appointment_type AS name, COUNT(*) AS value FROM appointment \ - WHERE tenant_id = $1 AND deleted_at IS NULL \ - AND created_at >= date_trunc('month', NOW()) \ - GROUP BY appointment_type ORDER BY value DESC", - ).await?; - - let cancelled = appointment::Entity::find() - .filter(appointment::Column::TenantId.eq(tenant_id)) - .filter(appointment::Column::DeletedAt.is_null()) - .filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .filter(appointment::Column::Status.eq("cancelled")) - .count(db) - .await?; - - let cancel_rate = if this_month > 0 { - (cancelled as f64 / this_month as f64) * 100.0 - } else { - 0.0 - }; - - Ok(AppointmentStatisticsResp { - total_appointments: total_appointments as i64, - this_month: this_month as i64, - status_distribution, - type_distribution, - cancel_rate, - }) -} - -pub async fn get_vital_signs_report_rate( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - let db = &state.db; - - let total_patients = patient::Entity::find() - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()) - .count(db) - .await?; - - let total_records = vital_signs::Entity::find() - .filter(vital_signs::Column::TenantId.eq(tenant_id)) - .filter(vital_signs::Column::DeletedAt.is_null()) - .filter(Expr::col(vital_signs::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await?; - - let reported_patients = count_distinct_patients_vital_signs(db, tenant_id).await?; - - let report_rate = if total_patients > 0 { - (reported_patients as f64 / total_patients as f64) * 100.0 - } else { - 0.0 - }; - - let daily_trend = compute_daily_report_rate(db, tenant_id).await?; - - Ok(VitalSignsReportRateResp { - total_patients: total_patients as i64, - reported_patients: reported_patients as i64, - report_rate, - total_records: total_records as i64, - daily_trend, - }) -} - -pub async fn get_health_data_stats( - state: &HealthState, - tenant_id: uuid::Uuid, -) -> AppResult { - let lab_reports = get_lab_report_statistics(state, tenant_id).await?; - let appointments = get_appointment_statistics(state, tenant_id).await?; - let vital_signs_report_rate = get_vital_signs_report_rate(state, tenant_id).await?; - - Ok(HealthDataStatsResp { - lab_reports, - appointments, - vital_signs_report_rate, - }) -} - -// --------------------------------------------------------------------------- -// 辅助查询 -// --------------------------------------------------------------------------- - -#[derive(Debug, FromQueryResult)] -struct NameValueRow { - name: String, - value: i64, -} - -async fn count_by_field( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, - sql: &str, -) -> AppResult> { - let rows: Vec = sea_orm::FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .all(db) - .await?; - - Ok(rows.into_iter().map(|r| NameValue { name: r.name, value: r.value }).collect()) -} - -async fn count_abnormal_lab_items( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, -) -> AppResult { - let sql = r#" - SELECT COALESCE(SUM(jsonb_array_length( - COALESCE( - (SELECT jsonb_agg(elem) FROM jsonb_array_elements(items) elem WHERE elem->>'is_abnormal' = 'true'), - '[]'::jsonb - ) - )), 0::bigint) AS total - FROM lab_report - WHERE tenant_id = $1 AND deleted_at IS NULL AND items IS NOT NULL - AND created_at >= date_trunc('month', NOW()) - "#; - - #[derive(Debug, FromQueryResult)] - struct AbnormalCount { - total: Option, - } - - let result: Option = sea_orm::FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .one(db) - .await?; - - Ok(result.and_then(|r| r.total).unwrap_or(0)) -} - -async fn count_distinct_patients_vital_signs( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, -) -> AppResult { - let sql = r#" - SELECT COUNT(DISTINCT patient_id) AS cnt - FROM vital_signs - WHERE tenant_id = $1 AND deleted_at IS NULL - AND created_at >= date_trunc('month', NOW()) - "#; - - #[derive(Debug, FromQueryResult)] - struct DistinctCount { - cnt: i64, - } - - let result: Option = sea_orm::FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .one(db) - .await?; - - Ok(result.map(|r| r.cnt as u64).unwrap_or(0)) -} - -async fn compute_daily_report_rate( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, -) -> AppResult> { - let sql = r#" - SELECT d::date::text AS date, - COUNT(DISTINCT vs.patient_id) AS reported, - 0::bigint AS total - FROM generate_series( - CURRENT_DATE - INTERVAL '6 days', - CURRENT_DATE, - INTERVAL '1 day' - ) d - LEFT JOIN vital_signs vs ON vs.record_date = d::date - AND vs.tenant_id = $1 AND vs.deleted_at IS NULL - GROUP BY d::date - ORDER BY d::date - "#; - - #[derive(Debug, FromQueryResult)] - struct DailyRow { - date: String, - reported: i64, - #[allow(dead_code)] // FromQueryResult 映射需要 total 字段,当前未读取 - total: i64, - } - - let rows: Vec = sea_orm::FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .all(db) - .await?; - - let total_patients = patient::Entity::find() - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()) - .count(db) - .await?; - - Ok(rows.into_iter().map(|r| { - let total = total_patients as i64; - let rate = if total > 0 { (r.reported as f64 / total as f64) * 100.0 } else { 0.0 }; - DailyReportRate { date: r.date, reported: r.reported, total, rate } - }).collect()) -} - -// --------------------------------------------------------------------------- -// 个人维度统计 -// --------------------------------------------------------------------------- - -pub async fn get_personal_stats( - state: &HealthState, - user_id: uuid::Uuid, - tenant_id: uuid::Uuid, -) -> AppResult { - let db = &state.db; - - // 通过 user_id 查找 doctor_profile 以获得 doctor_id - let doctor_profile = doctor_profile::Entity::find() - .filter(doctor_profile::Column::TenantId.eq(tenant_id)) - .filter(doctor_profile::Column::DeletedAt.is_null()) - .filter(doctor_profile::Column::UserId.eq(user_id)) - .one(db) - .await?; - - let doctor_id = doctor_profile.map(|p| p.id); - - // my_patients: 通过 patient_doctor_relation 统计 - let my_patients = if let Some(did) = doctor_id { - patient_doctor_relation::Entity::find() - .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) - .filter(patient_doctor_relation::Column::DeletedAt.is_null()) - .filter(patient_doctor_relation::Column::DoctorId.eq(did)) - .count(db) - .await? as i64 - } else { - 0 - }; - - // new_patients_this_month: 本月新增关联患者 - let new_patients_this_month = if let Some(did) = doctor_id { - let sql = r#" - SELECT COUNT(*) AS cnt - FROM patient_doctor_relation pdr - INNER JOIN patient p ON p.id = pdr.patient_id AND p.deleted_at IS NULL AND p.tenant_id = $1 - WHERE pdr.tenant_id = $1 AND pdr.deleted_at IS NULL - AND pdr.doctor_id = $2 - AND p.created_at >= date_trunc('month', NOW()) - "#; - - #[derive(Debug, FromQueryResult)] - struct Cnt { - cnt: i64, - } - - let result: Option = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into(), did.into()], - ), - ) - .one(db) - .await?; - - result.map(|r| r.cnt).unwrap_or(0) - } else { - 0 - }; - - // follow_up_rate: 分配给当前用户的随访完成率 - let sql = r#" - SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt - FROM follow_up_task - WHERE tenant_id = $1 AND deleted_at IS NULL AND assigned_to = $2 - GROUP BY GROUPING SETS ((status), ()) - "#; - - #[derive(Debug, FromQueryResult)] - struct StatusCount { - status: String, - cnt: i64, - } - - let fu_rows: Vec = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into(), user_id.into()], - ), - ) - .all(db) - .await?; - - let mut fu_total: i64 = 0; - let mut fu_completed: i64 = 0; - let mut overdue_follow_ups: i64 = 0; - for row in &fu_rows { - match row.status.as_str() { - "__total" => fu_total = row.cnt, - "completed" => fu_completed = row.cnt, - "overdue" => overdue_follow_ups = row.cnt, - _ => {} - } - } - let follow_up_rate = if fu_total > 0 { - (fu_completed as f64 / fu_total as f64) * 100.0 - } else { - 0.0 - }; - - // consultations_this_month / pending_consultations: 咨询统计 - let consultations_this_month = if let Some(did) = doctor_id { - consultation_session::Entity::find() - .filter(consultation_session::Column::TenantId.eq(tenant_id)) - .filter(consultation_session::Column::DeletedAt.is_null()) - .filter(consultation_session::Column::DoctorId.eq(did)) - .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .count(db) - .await? as i64 - } else { - 0 - }; - - let pending_consultations = if let Some(did) = doctor_id { - consultation_session::Entity::find() - .filter(consultation_session::Column::TenantId.eq(tenant_id)) - .filter(consultation_session::Column::DeletedAt.is_null()) - .filter(consultation_session::Column::DoctorId.eq(did)) - .filter(consultation_session::Column::Status.eq("active")) - .count(db) - .await? as i64 - } else { - 0 - }; - - // today_appointments: 今日预约 - let today_appointments = if let Some(did) = doctor_id { - appointment::Entity::find() - .filter(appointment::Column::TenantId.eq(tenant_id)) - .filter(appointment::Column::DeletedAt.is_null()) - .filter(appointment::Column::DoctorId.eq(did)) - .filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE"))) - .count(db) - .await? as i64 - } else { - 0 - }; - - // today_follow_ups: 今日随访任务 - let today_follow_ups = follow_up_task::Entity::find() - .filter(follow_up_task::Column::TenantId.eq(tenant_id)) - .filter(follow_up_task::Column::DeletedAt.is_null()) - .filter(follow_up_task::Column::AssignedTo.eq(user_id)) - .filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE"))) - .count(db) - .await? as i64; - - // vital_signs_report_rate: 当前医生的患者体征上报率 - let (vital_signs_reported, vital_signs_total, vital_signs_report_rate) = if my_patients > 0 { - let vs_sql = r#" - SELECT - COUNT(DISTINCT vs.patient_id) AS reported, - $3::bigint AS total - FROM vital_signs vs - WHERE vs.tenant_id = $1 AND vs.deleted_at IS NULL - AND vs.created_at >= date_trunc('month', NOW()) - AND vs.patient_id IN ( - SELECT patient_id FROM patient_doctor_relation - WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL - ) - "#; - - #[derive(Debug, FromQueryResult)] - struct VsCount { - reported: i64, - total: i64, - } - - let result: Option = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - vs_sql, - [tenant_id.into(), doctor_id.unwrap_or_default().into(), my_patients.into()], - ), - ) - .one(db) - .await?; - - match result { - Some(r) => { - let rate = if r.total > 0 { - (r.reported as f64 / r.total as f64) * 100.0 - } else { - 0.0 - }; - (r.reported, r.total, rate) - } - None => (0, my_patients, 0.0), - } - } else { - (0, 0, 0.0) - }; - - // pending_lab_reviews: 待审核化验报告(与当前医生的患者关联) - let pending_lab_reviews = if doctor_id.is_some() { - let lr_sql = r#" - SELECT COUNT(*) AS cnt - FROM lab_report lr - WHERE lr.tenant_id = $1 AND lr.deleted_at IS NULL - AND lr.status = 'pending' - AND lr.patient_id IN ( - SELECT patient_id FROM patient_doctor_relation - WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL - ) - "#; - - #[derive(Debug, FromQueryResult)] - struct LrCnt { - cnt: i64, - } - - let result: Option = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - lr_sql, - [tenant_id.into(), doctor_id.unwrap_or_default().into()], - ), - ) - .one(db) - .await?; - - result.map(|r| r.cnt).unwrap_or(0) - } else { - 0 - }; - - // abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置) - let abnormal_vital_signs: i64 = 0; - - // ── 昨日对比数据 ── - let yesterday_appointments = if let Some(did) = doctor_id { - appointment::Entity::find() - .filter(appointment::Column::TenantId.eq(tenant_id)) - .filter(appointment::Column::DeletedAt.is_null()) - .filter(appointment::Column::DoctorId.eq(did)) - .filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE - INTERVAL '1 day'"))) - .count(db) - .await? as i64 - } else { - 0 - }; - - let yesterday_follow_ups = follow_up_task::Entity::find() - .filter(follow_up_task::Column::TenantId.eq(tenant_id)) - .filter(follow_up_task::Column::DeletedAt.is_null()) - .filter(follow_up_task::Column::AssignedTo.eq(user_id)) - .filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE - INTERVAL '1 day'"))) - .count(db) - .await? as i64; - - let yesterday_overdue = follow_up_task::Entity::find() - .filter(follow_up_task::Column::TenantId.eq(tenant_id)) - .filter(follow_up_task::Column::DeletedAt.is_null()) - .filter(follow_up_task::Column::AssignedTo.eq(user_id)) - .filter(follow_up_task::Column::Status.eq("overdue")) - .filter(Expr::col(follow_up_task::Column::UpdatedAt).lt(Expr::cust("CURRENT_DATE"))) - .count(db) - .await? as i64; - - let yesterday_consultations = if let Some(did) = doctor_id { - consultation_session::Entity::find() - .filter(consultation_session::Column::TenantId.eq(tenant_id)) - .filter(consultation_session::Column::DeletedAt.is_null()) - .filter(consultation_session::Column::DoctorId.eq(did)) - .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) - .filter(Expr::col(consultation_session::Column::CreatedAt).lt(Expr::cust("CURRENT_DATE"))) - .count(db) - .await? as i64 - } else { - 0 - }; - - Ok(PersonalStatsResp { - my_patients, - new_patients_this_month, - follow_up_rate, - consultations_this_month, - pending_consultations, - vital_signs_report_rate, - today_appointments, - overdue_follow_ups, - today_follow_ups, - abnormal_vital_signs, - vital_signs_reported, - vital_signs_total, - pending_lab_reviews, - yesterday_my_patients: None, - yesterday_today_appointments: Some(yesterday_appointments), - yesterday_consultations_this_month: Some(yesterday_consultations), - yesterday_follow_up_rate: None, - yesterday_today_follow_ups: Some(yesterday_follow_ups), - yesterday_overdue_follow_ups: Some(yesterday_overdue), - }) -} - -// --------------------------------------------------------------------------- -// 工作台管理统计 -// --------------------------------------------------------------------------- - -/// 文章状态统计 -pub async fn get_article_stats( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, -) -> AppResult { - let sql = r#" - SELECT status, COUNT(*) AS cnt, COALESCE(SUM(view_count), 0) AS total_views - FROM article - WHERE tenant_id = $1 AND deleted_at IS NULL - GROUP BY status - "#; - - #[derive(Debug, FromQueryResult)] - struct Row { - status: String, - cnt: i64, - total_views: Option, - } - - let rows: Vec = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .all(db) - .await?; - - let mut published: i64 = 0; - let mut draft: i64 = 0; - let mut pending_review: i64 = 0; - let mut rejected: i64 = 0; - let mut total_views: i64 = 0; - - for row in &rows { - total_views += row.total_views.unwrap_or(0); - match row.status.as_str() { - "published" => published = row.cnt, - "draft" => draft = row.cnt, - "pending_review" => pending_review = row.cnt, - "rejected" => rejected = row.cnt, - _ => {} - } - } - - Ok(ArticleStatsResp { - published, - draft, - pending_review, - rejected, - total_views, - }) -} - -/// 积分最近动态 -pub async fn get_points_recent_activity( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, - limit: u64, -) -> AppResult> { - let sql = r#" - SELECT pt.id::text, COALESCE(p.name, '未知用户') AS user_name, - pt.description AS detail, - CASE WHEN pt.amount >= 0 THEN '+' || pt.amount ELSE pt.amount::text END AS amount, - CASE WHEN pt.amount >= 0 THEN 'earn' ELSE 'spend' END AS type, - pt.created_at::text - FROM points_transaction pt - LEFT JOIN patient p ON p.id = pt.patient_id AND p.deleted_at IS NULL - WHERE pt.tenant_id = $1 AND pt.deleted_at IS NULL - ORDER BY pt.created_at DESC - LIMIT $2 - "#; - - #[derive(Debug, FromQueryResult)] - struct Row { - id: String, - user_name: String, - detail: Option, - amount: String, - r#type: String, - created_at: String, - } - - let rows: Vec = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into(), (limit as i64).into()], - ), - ) - .all(db) - .await?; - - Ok(rows - .into_iter() - .map(|r| PointsActivityItem { - id: r.id, - user_name: r.user_name, - detail: r.detail.unwrap_or_default(), - amount: r.amount, - r#type: r.r#type, - created_at: r.created_at, - }) - .collect()) -} - -/// 模块状态 -pub async fn get_module_status( - _state: &HealthState, -) -> AppResult> { - let modules = vec![ - ModuleStatusResp { - name: "erp-auth".into(), - display_name: "身份权限".into(), - description: "用户/角色/权限/组织/部门".into(), - active: true, - entity_count: Some(9), - route_count: None, - }, - ModuleStatusResp { - name: "erp-config".into(), - display_name: "系统配置".into(), - description: "字典/菜单/设置/编号规则".into(), - active: true, - entity_count: Some(6), - route_count: None, - }, - ModuleStatusResp { - name: "erp-workflow".into(), - display_name: "工作流引擎".into(), - description: "BPMN 解析/任务分配".into(), - active: true, - entity_count: Some(5), - route_count: None, - }, - ModuleStatusResp { - name: "erp-message".into(), - display_name: "消息中心".into(), - description: "消息/模板/订阅/通知".into(), - active: true, - entity_count: Some(3), - route_count: None, - }, - ModuleStatusResp { - name: "erp-health".into(), - display_name: "健康管理".into(), - description: "患者/体征/预约/随访/咨询".into(), - active: true, - entity_count: Some(45), - route_count: None, - }, - ModuleStatusResp { - name: "erp-ai".into(), - display_name: "AI 分析".into(), - description: "智能分析/化验解读/趋势".into(), - active: true, - entity_count: Some(3), - route_count: None, - }, - ModuleStatusResp { - name: "erp-dialysis".into(), - display_name: "透析管理".into(), - description: "透析记录/处方/用药".into(), - active: true, - entity_count: Some(5), - route_count: None, - }, - ModuleStatusResp { - name: "erp-plugin".into(), - display_name: "插件系统".into(), - description: "WASM 运行时/动态表".into(), - active: true, - entity_count: Some(4), - route_count: None, - }, - ]; - - Ok(modules) -} - -/// 用户活跃度统计 -pub async fn get_user_activity( - db: &sea_orm::DatabaseConnection, - tenant_id: uuid::Uuid, -) -> AppResult { - let sql = r#" - SELECT - (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '1 day') AS daily_active, - (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '7 days') AS weekly_active, - (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '30 days') AS monthly_active, - (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL) AS total_registered - "#; - - #[derive(Debug, FromQueryResult)] - struct ActivityRow { - daily_active: i64, - weekly_active: i64, - monthly_active: i64, - total_registered: i64, - } - - let activity: Option = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - sql, - [tenant_id.into()], - ), - ) - .one(db) - .await?; - - let a = activity.unwrap_or(ActivityRow { - daily_active: 0, - weekly_active: 0, - monthly_active: 0, - total_registered: 0, - }); - - // 角色分布 - let role_sql = r#" - SELECT r.name AS role, COUNT(ur.user_id) AS count - FROM roles r - LEFT JOIN user_roles ur ON ur.role_id = r.id AND ur.tenant_id = $1 - LEFT JOIN users u ON u.id = ur.user_id AND u.deleted_at IS NULL - WHERE r.tenant_id = $1 AND r.deleted_at IS NULL - GROUP BY r.name - ORDER BY count DESC - "#; - - #[derive(Debug, FromQueryResult)] - struct RoleRow { - role: String, - count: i64, - } - - let role_rows: Vec = FromQueryResult::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - role_sql, - [tenant_id.into()], - ), - ) - .all(db) - .await?; - - Ok(UserActivityResp { - daily_active: a.daily_active, - weekly_active: a.weekly_active, - monthly_active: a.monthly_active, - total_registered: a.total_registered, - by_role: role_rows.into_iter().map(|r| RoleCount { role: r.role, count: r.count }).collect(), - }) -} - -/// 系统健康检查 -pub async fn get_system_health( - state: &HealthState, -) -> AppResult { - let mut services = Vec::new(); - let start = std::time::Instant::now(); - - // 数据库检查 - let db_start = std::time::Instant::now(); - let db_status = match state.db.execute(sea_orm::Statement::from_string( - sea_orm::DatabaseBackend::Postgres, - "SELECT 1".to_string(), - )).await - { - Ok(_) => "healthy".to_string(), - Err(e) => format!("down: {e}"), - }; - let db_ms = db_start.elapsed().as_millis() as i64; - - services.push(ServiceHealthStatus { - name: "PostgreSQL".into(), - status: if db_status == "healthy" { "healthy".into() } else { "down".into() }, - message: if db_status == "healthy" { "正常".into() } else { db_status }, - response_ms: Some(db_ms), - }); - - // 基础服务状态(简化版 — 无 Redis/SMTP 时标记 healthy) - services.push(ServiceHealthStatus { - name: "API 服务".into(), - status: "healthy".into(), - message: "运行中".into(), - response_ms: Some(start.elapsed().as_millis() as i64), - }); - - services.push(ServiceHealthStatus { - name: "定时任务".into(), - status: "healthy".into(), - message: "正常运行".into(), - response_ms: None, - }); - - services.push(ServiceHealthStatus { - name: "文件存储".into(), - status: "healthy".into(), - message: "可用".into(), - response_ms: None, - }); - - services.push(ServiceHealthStatus { - name: "消息队列".into(), - status: "healthy".into(), - message: "无积压".into(), - response_ms: None, - }); - - services.push(ServiceHealthStatus { - name: "缓存服务".into(), - status: "healthy".into(), - message: "正常".into(), - response_ms: None, - }); - - Ok(SystemHealthResp { - services, - checked_at: chrono::Utc::now().to_rfc3339(), - }) -} diff --git a/crates/erp-health/src/service/stats_service/dashboard.rs b/crates/erp-health/src/service/stats_service/dashboard.rs new file mode 100644 index 0000000..9323b82 --- /dev/null +++ b/crates/erp-health/src/service/stats_service/dashboard.rs @@ -0,0 +1,331 @@ +//! 统计 Service — 工作台管理统计 + +use sea_orm::{FromQueryResult, ConnectionTrait}; + +use erp_core::error::AppResult; + +use crate::dto::stats_dto::*; +use crate::state::HealthState; + +/// 文章状态统计 +pub async fn get_article_stats( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult { + let sql = r#" + SELECT status, COUNT(*) AS cnt, COALESCE(SUM(view_count), 0) AS total_views + FROM article + WHERE tenant_id = $1 AND deleted_at IS NULL + GROUP BY status + "#; + + #[derive(Debug, FromQueryResult)] + struct Row { + status: String, + cnt: i64, + total_views: Option, + } + + let rows: Vec = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .all(db) + .await?; + + let mut published: i64 = 0; + let mut draft: i64 = 0; + let mut pending_review: i64 = 0; + let mut rejected: i64 = 0; + let mut total_views: i64 = 0; + + for row in &rows { + total_views += row.total_views.unwrap_or(0); + match row.status.as_str() { + "published" => published = row.cnt, + "draft" => draft = row.cnt, + "pending_review" => pending_review = row.cnt, + "rejected" => rejected = row.cnt, + _ => {} + } + } + + Ok(ArticleStatsResp { + published, + draft, + pending_review, + rejected, + total_views, + }) +} + +/// 积分最近动态 +pub async fn get_points_recent_activity( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, + limit: u64, +) -> AppResult> { + let sql = r#" + SELECT pt.id::text, COALESCE(p.name, '未知用户') AS user_name, + pt.description AS detail, + CASE WHEN pt.amount >= 0 THEN '+' || pt.amount ELSE pt.amount::text END AS amount, + CASE WHEN pt.amount >= 0 THEN 'earn' ELSE 'spend' END AS type, + pt.created_at::text + FROM points_transaction pt + LEFT JOIN patient p ON p.id = pt.patient_id AND p.deleted_at IS NULL + WHERE pt.tenant_id = $1 AND pt.deleted_at IS NULL + ORDER BY pt.created_at DESC + LIMIT $2 + "#; + + #[derive(Debug, FromQueryResult)] + struct Row { + id: String, + user_name: String, + detail: Option, + amount: String, + r#type: String, + created_at: String, + } + + let rows: Vec = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into(), (limit as i64).into()], + ), + ) + .all(db) + .await?; + + Ok(rows + .into_iter() + .map(|r| PointsActivityItem { + id: r.id, + user_name: r.user_name, + detail: r.detail.unwrap_or_default(), + amount: r.amount, + r#type: r.r#type, + created_at: r.created_at, + }) + .collect()) +} + +/// 模块状态 +pub async fn get_module_status( + _state: &HealthState, +) -> AppResult> { + let modules = vec![ + ModuleStatusResp { + name: "erp-auth".into(), + display_name: "身份权限".into(), + description: "用户/角色/权限/组织/部门".into(), + active: true, + entity_count: Some(9), + route_count: None, + }, + ModuleStatusResp { + name: "erp-config".into(), + display_name: "系统配置".into(), + description: "字典/菜单/设置/编号规则".into(), + active: true, + entity_count: Some(6), + route_count: None, + }, + ModuleStatusResp { + name: "erp-workflow".into(), + display_name: "工作流引擎".into(), + description: "BPMN 解析/任务分配".into(), + active: true, + entity_count: Some(5), + route_count: None, + }, + ModuleStatusResp { + name: "erp-message".into(), + display_name: "消息中心".into(), + description: "消息/模板/订阅/通知".into(), + active: true, + entity_count: Some(3), + route_count: None, + }, + ModuleStatusResp { + name: "erp-health".into(), + display_name: "健康管理".into(), + description: "患者/体征/预约/随访/咨询".into(), + active: true, + entity_count: Some(45), + route_count: None, + }, + ModuleStatusResp { + name: "erp-ai".into(), + display_name: "AI 分析".into(), + description: "智能分析/化验解读/趋势".into(), + active: true, + entity_count: Some(3), + route_count: None, + }, + ModuleStatusResp { + name: "erp-dialysis".into(), + display_name: "透析管理".into(), + description: "透析记录/处方/用药".into(), + active: true, + entity_count: Some(5), + route_count: None, + }, + ModuleStatusResp { + name: "erp-plugin".into(), + display_name: "插件系统".into(), + description: "WASM 运行时/动态表".into(), + active: true, + entity_count: Some(4), + route_count: None, + }, + ]; + + Ok(modules) +} + +/// 用户活跃度统计 +pub async fn get_user_activity( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult { + let sql = r#" + SELECT + (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '1 day') AS daily_active, + (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '7 days') AS weekly_active, + (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND last_login_at >= NOW() - INTERVAL '30 days') AS monthly_active, + (SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND deleted_at IS NULL) AS total_registered + "#; + + #[derive(Debug, FromQueryResult)] + struct ActivityRow { + daily_active: i64, + weekly_active: i64, + monthly_active: i64, + total_registered: i64, + } + + let activity: Option = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + let a = activity.unwrap_or(ActivityRow { + daily_active: 0, + weekly_active: 0, + monthly_active: 0, + total_registered: 0, + }); + + // 角色分布 + let role_sql = r#" + SELECT r.name AS role, COUNT(ur.user_id) AS count + FROM roles r + LEFT JOIN user_roles ur ON ur.role_id = r.id AND ur.tenant_id = $1 + LEFT JOIN users u ON u.id = ur.user_id AND u.deleted_at IS NULL + WHERE r.tenant_id = $1 AND r.deleted_at IS NULL + GROUP BY r.name + ORDER BY count DESC + "#; + + #[derive(Debug, FromQueryResult)] + struct RoleRow { + role: String, + count: i64, + } + + let role_rows: Vec = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + role_sql, + [tenant_id.into()], + ), + ) + .all(db) + .await?; + + Ok(UserActivityResp { + daily_active: a.daily_active, + weekly_active: a.weekly_active, + monthly_active: a.monthly_active, + total_registered: a.total_registered, + by_role: role_rows.into_iter().map(|r| RoleCount { role: r.role, count: r.count }).collect(), + }) +} + +/// 系统健康检查 +pub async fn get_system_health( + state: &HealthState, +) -> AppResult { + let mut services = Vec::new(); + let start = std::time::Instant::now(); + + // 数据库检查 + let db_start = std::time::Instant::now(); + let db_status = match state.db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "SELECT 1".to_string(), + )).await + { + Ok(_) => "healthy".to_string(), + Err(e) => format!("down: {e}"), + }; + let db_ms = db_start.elapsed().as_millis() as i64; + + services.push(ServiceHealthStatus { + name: "PostgreSQL".into(), + status: if db_status == "healthy" { "healthy".into() } else { "down".into() }, + message: if db_status == "healthy" { "正常".into() } else { db_status }, + response_ms: Some(db_ms), + }); + + // 基础服务状态(简化版 — 无 Redis/SMTP 时标记 healthy) + services.push(ServiceHealthStatus { + name: "API 服务".into(), + status: "healthy".into(), + message: "运行中".into(), + response_ms: Some(start.elapsed().as_millis() as i64), + }); + + services.push(ServiceHealthStatus { + name: "定时任务".into(), + status: "healthy".into(), + message: "正常运行".into(), + response_ms: None, + }); + + services.push(ServiceHealthStatus { + name: "文件存储".into(), + status: "healthy".into(), + message: "可用".into(), + response_ms: None, + }); + + services.push(ServiceHealthStatus { + name: "消息队列".into(), + status: "healthy".into(), + message: "无积压".into(), + response_ms: None, + }); + + services.push(ServiceHealthStatus { + name: "缓存服务".into(), + status: "healthy".into(), + message: "正常".into(), + response_ms: None, + }); + + Ok(SystemHealthResp { + services, + checked_at: chrono::Utc::now().to_rfc3339(), + }) +} diff --git a/crates/erp-health/src/service/stats_service/health.rs b/crates/erp-health/src/service/stats_service/health.rs new file mode 100644 index 0000000..6f1ee7f --- /dev/null +++ b/crates/erp-health/src/service/stats_service/health.rs @@ -0,0 +1,320 @@ +//! 统计 Service — 健康数据统计 + +use sea_orm::{ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr}; + +use erp_core::error::AppResult; + +use crate::dto::stats_dto::*; +use crate::entity::{ + patient, lab_report, appointment, vital_signs, +}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 健康数据统计 +// --------------------------------------------------------------------------- + +pub async fn get_lab_report_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_reports = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let this_month = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .filter(Expr::col(lab_report::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let pending_review = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .filter(lab_report::Column::Status.eq("pending")) + .count(db) + .await?; + + let reviewed = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .filter(lab_report::Column::Status.eq("reviewed")) + .count(db) + .await?; + + let type_distribution = count_by_field( + db, tenant_id, + "SELECT report_type AS name, COUNT(*) AS value FROM lab_report \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + AND created_at >= date_trunc('month', NOW()) \ + GROUP BY report_type ORDER BY value DESC", + ).await?; + + let abnormal_items = count_abnormal_lab_items(db, tenant_id).await?; + + Ok(LabReportStatisticsResp { + total_reports: total_reports as i64, + this_month: this_month as i64, + type_distribution, + abnormal_items, + pending_review: pending_review as i64, + reviewed: reviewed as i64, + }) +} + +pub async fn get_appointment_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_appointments = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let this_month = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let status_distribution = count_by_field( + db, tenant_id, + "SELECT status AS name, COUNT(*) AS value FROM appointment \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + AND created_at >= date_trunc('month', NOW()) \ + GROUP BY status ORDER BY value DESC", + ).await?; + + let type_distribution = count_by_field( + db, tenant_id, + "SELECT appointment_type AS name, COUNT(*) AS value FROM appointment \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + AND created_at >= date_trunc('month', NOW()) \ + GROUP BY appointment_type ORDER BY value DESC", + ).await?; + + let cancelled = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .filter(appointment::Column::Status.eq("cancelled")) + .count(db) + .await?; + + let cancel_rate = if this_month > 0 { + (cancelled as f64 / this_month as f64) * 100.0 + } else { + 0.0 + }; + + Ok(AppointmentStatisticsResp { + total_appointments: total_appointments as i64, + this_month: this_month as i64, + status_distribution, + type_distribution, + cancel_rate, + }) +} + +pub async fn get_vital_signs_report_rate( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_patients = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let total_records = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .filter(Expr::col(vital_signs::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let reported_patients = count_distinct_patients_vital_signs(db, tenant_id).await?; + + let report_rate = if total_patients > 0 { + (reported_patients as f64 / total_patients as f64) * 100.0 + } else { + 0.0 + }; + + let daily_trend = compute_daily_report_rate(db, tenant_id).await?; + + Ok(VitalSignsReportRateResp { + total_patients: total_patients as i64, + reported_patients: reported_patients as i64, + report_rate, + total_records: total_records as i64, + daily_trend, + }) +} + +pub async fn get_health_data_stats( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let lab_reports = get_lab_report_statistics(state, tenant_id).await?; + let appointments = get_appointment_statistics(state, tenant_id).await?; + let vital_signs_report_rate = get_vital_signs_report_rate(state, tenant_id).await?; + + Ok(HealthDataStatsResp { + lab_reports, + appointments, + vital_signs_report_rate, + }) +} + +// --------------------------------------------------------------------------- +// 辅助查询 +// --------------------------------------------------------------------------- + +#[derive(Debug, FromQueryResult)] +struct NameValueRow { + name: String, + value: i64, +} + +async fn count_by_field( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, + sql: &str, +) -> AppResult> { + let rows: Vec = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .all(db) + .await?; + + Ok(rows.into_iter().map(|r| NameValue { name: r.name, value: r.value }).collect()) +} + +async fn count_abnormal_lab_items( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult { + let sql = r#" + SELECT COALESCE(SUM(jsonb_array_length( + COALESCE( + (SELECT jsonb_agg(elem) FROM jsonb_array_elements(items) elem WHERE elem->>'is_abnormal' = 'true'), + '[]'::jsonb + ) + )), 0::bigint) AS total + FROM lab_report + WHERE tenant_id = $1 AND deleted_at IS NULL AND items IS NOT NULL + AND created_at >= date_trunc('month', NOW()) + "#; + + #[derive(Debug, FromQueryResult)] + struct AbnormalCount { + total: Option, + } + + let result: Option = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + Ok(result.and_then(|r| r.total).unwrap_or(0)) +} + +async fn count_distinct_patients_vital_signs( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult { + let sql = r#" + SELECT COUNT(DISTINCT patient_id) AS cnt + FROM vital_signs + WHERE tenant_id = $1 AND deleted_at IS NULL + AND created_at >= date_trunc('month', NOW()) + "#; + + #[derive(Debug, FromQueryResult)] + struct DistinctCount { + cnt: i64, + } + + let result: Option = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + Ok(result.map(|r| r.cnt as u64).unwrap_or(0)) +} + +async fn compute_daily_report_rate( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult> { + let sql = r#" + SELECT d::date::text AS date, + COUNT(DISTINCT vs.patient_id) AS reported, + 0::bigint AS total + FROM generate_series( + CURRENT_DATE - INTERVAL '6 days', + CURRENT_DATE, + INTERVAL '1 day' + ) d + LEFT JOIN vital_signs vs ON vs.record_date = d::date + AND vs.tenant_id = $1 AND vs.deleted_at IS NULL + GROUP BY d::date + ORDER BY d::date + "#; + + #[derive(Debug, FromQueryResult)] + struct DailyRow { + date: String, + reported: i64, + #[allow(dead_code)] // FromQueryResult 映射需要 total 字段,当前未读取 + total: i64, + } + + let rows: Vec = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .all(db) + .await?; + + let total_patients = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .count(db) + .await?; + + Ok(rows.into_iter().map(|r| { + let total = total_patients as i64; + let rate = if total > 0 { (r.reported as f64 / total as f64) * 100.0 } else { 0.0 }; + DailyReportRate { date: r.date, reported: r.reported, total, rate } + }).collect()) +} diff --git a/crates/erp-health/src/service/stats_service/mod.rs b/crates/erp-health/src/service/stats_service/mod.rs new file mode 100644 index 0000000..c086aea --- /dev/null +++ b/crates/erp-health/src/service/stats_service/mod.rs @@ -0,0 +1,33 @@ +//! 统计 Service — 模块入口 +//! +//! 按功能域拆分为 4 个子模块: +//! - `operations` — 基础运营统计(患者/咨询/随访) +//! - `health` — 健康数据统计(化验报告/预约/体征上报率) +//! - `personal` — 个人维度统计(医生工作台) +//! - `dashboard` — 工作台管理统计(文章/积分/模块/用户活跃/系统健康) + +pub mod operations; +pub mod health; +pub mod personal; +pub mod dashboard; + +// ── 运营统计 ── +pub use operations::get_patient_statistics; +pub use operations::get_consultation_statistics; +pub use operations::get_follow_up_statistics; + +// ── 健康数据统计 ── +pub use health::get_lab_report_statistics; +pub use health::get_appointment_statistics; +pub use health::get_vital_signs_report_rate; +pub use health::get_health_data_stats; + +// ── 个人统计 ── +pub use personal::get_personal_stats; + +// ── 工作台管理统计 ── +pub use dashboard::get_article_stats; +pub use dashboard::get_points_recent_activity; +pub use dashboard::get_module_status; +pub use dashboard::get_user_activity; +pub use dashboard::get_system_health; diff --git a/crates/erp-health/src/service/stats_service/operations.rs b/crates/erp-health/src/service/stats_service/operations.rs new file mode 100644 index 0000000..3fdcb47 --- /dev/null +++ b/crates/erp-health/src/service/stats_service/operations.rs @@ -0,0 +1,186 @@ +//! 统计 Service — 基础运营统计辅助查询 + +use sea_orm::{ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr}; + +use erp_core::error::AppResult; + +use crate::dto::stats_dto::*; +use crate::entity::{ + patient, consultation_session, + points_transaction, +}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 基础运营统计 +// --------------------------------------------------------------------------- + +pub async fn get_patient_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let new_this_month = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let new_this_week = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .filter(Expr::col(patient::Column::CreatedAt).gte(Expr::cust("date_trunc('week', NOW())"))) + .count(db) + .await?; + + let active_this_month = points_transaction::Entity::find() + .filter(points_transaction::Column::TenantId.eq(tenant_id)) + .filter(Expr::col(points_transaction::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + Ok(PatientStatisticsResp { + total_patients: total as i64, + new_this_month: new_this_month as i64, + new_this_week: new_this_week as i64, + active_this_month: active_this_month as i64, + }) +} + +pub async fn get_consultation_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + let total_sessions = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .count(db) + .await?; + + let pending_reply = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::Status.eq("waiting")) + .count(db) + .await?; + + let this_month = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await?; + + let avg_response_time_minutes = compute_avg_response_time(db, tenant_id).await?; + + Ok(ConsultationStatisticsResp { + total_sessions: total_sessions as i64, + pending_reply: pending_reply as i64, + avg_response_time_minutes, + this_month: this_month as i64, + }) +} + +pub async fn get_follow_up_statistics( + state: &HealthState, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + // 单次 GROUP BY 查询替代 4 次独立 COUNT + let sql = r#" + SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt + FROM follow_up_task + WHERE tenant_id = $1 AND deleted_at IS NULL + GROUP BY GROUPING SETS ((status), ()) + "#; + + #[derive(Debug, sea_orm::FromQueryResult)] + struct StatusCount { + status: String, + cnt: i64, + } + + let rows: Vec = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .all(db) + .await?; + + let mut total_tasks: i64 = 0; + let mut completed: i64 = 0; + let mut pending: i64 = 0; + let mut overdue: i64 = 0; + + for row in &rows { + match row.status.as_str() { + "__total" => total_tasks = row.cnt, + "completed" => completed = row.cnt, + "pending" => pending = row.cnt, + "overdue" => overdue = row.cnt, + _ => {} + } + } + + let completion_rate = if completed + pending + overdue > 0 { + (completed as f64 / (completed + pending + overdue) as f64) * 100.0 + } else { + 0.0 + }; + + Ok(FollowUpStatisticsResp { + total_tasks, + completed, + pending, + overdue, + completion_rate, + }) +} + +// --------------------------------------------------------------------------- +// 辅助查询 +// --------------------------------------------------------------------------- + +#[derive(Debug, FromQueryResult)] +struct AvgResponseTime { + avg_minutes: Option, +} + +async fn compute_avg_response_time( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> AppResult> { + let sql = r#" + SELECT AVG(EXTRACT(EPOCH FROM (m.created_at - s.created_at)) / 60) AS avg_minutes + FROM consultation_session s + INNER JOIN consultation_message m ON m.session_id = s.id AND m.tenant_id = $1 AND m.deleted_at IS NULL + WHERE s.tenant_id = $1 AND s.deleted_at IS NULL + AND m.sender_role = 'doctor' + "#; + + let result: Option = sea_orm::FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into()], + ), + ) + .one(db) + .await?; + + Ok(result.and_then(|r| r.avg_minutes)) +} diff --git a/crates/erp-health/src/service/stats_service/personal.rs b/crates/erp-health/src/service/stats_service/personal.rs new file mode 100644 index 0000000..2b0bc83 --- /dev/null +++ b/crates/erp-health/src/service/stats_service/personal.rs @@ -0,0 +1,308 @@ +//! 统计 Service — 个人维度统计 + +use sea_orm::{ColumnTrait, EntityTrait, FromQueryResult, PaginatorTrait, QueryFilter, sea_query::Expr}; + +use erp_core::error::AppResult; + +use crate::dto::stats_dto::*; +use crate::entity::{ + consultation_session, follow_up_task, + appointment, patient_doctor_relation, doctor_profile, +}; +use crate::state::HealthState; + +pub async fn get_personal_stats( + state: &HealthState, + user_id: uuid::Uuid, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + // 通过 user_id 查找 doctor_profile 以获得 doctor_id + let doctor_profile = doctor_profile::Entity::find() + .filter(doctor_profile::Column::TenantId.eq(tenant_id)) + .filter(doctor_profile::Column::DeletedAt.is_null()) + .filter(doctor_profile::Column::UserId.eq(user_id)) + .one(db) + .await?; + + let doctor_id = doctor_profile.map(|p| p.id); + + // my_patients: 通过 patient_doctor_relation 统计 + let my_patients = if let Some(did) = doctor_id { + patient_doctor_relation::Entity::find() + .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_doctor_relation::Column::DeletedAt.is_null()) + .filter(patient_doctor_relation::Column::DoctorId.eq(did)) + .count(db) + .await? as i64 + } else { + 0 + }; + + // new_patients_this_month: 本月新增关联患者 + let new_patients_this_month = if let Some(did) = doctor_id { + let sql = r#" + SELECT COUNT(*) AS cnt + FROM patient_doctor_relation pdr + INNER JOIN patient p ON p.id = pdr.patient_id AND p.deleted_at IS NULL AND p.tenant_id = $1 + WHERE pdr.tenant_id = $1 AND pdr.deleted_at IS NULL + AND pdr.doctor_id = $2 + AND p.created_at >= date_trunc('month', NOW()) + "#; + + #[derive(Debug, FromQueryResult)] + struct Cnt { + cnt: i64, + } + + let result: Option = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into(), did.into()], + ), + ) + .one(db) + .await?; + + result.map(|r| r.cnt).unwrap_or(0) + } else { + 0 + }; + + // follow_up_rate: 分配给当前用户的随访完成率 + let sql = r#" + SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt + FROM follow_up_task + WHERE tenant_id = $1 AND deleted_at IS NULL AND assigned_to = $2 + GROUP BY GROUPING SETS ((status), ()) + "#; + + #[derive(Debug, FromQueryResult)] + struct StatusCount { + status: String, + cnt: i64, + } + + let fu_rows: Vec = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into(), user_id.into()], + ), + ) + .all(db) + .await?; + + let mut fu_total: i64 = 0; + let mut fu_completed: i64 = 0; + let mut overdue_follow_ups: i64 = 0; + for row in &fu_rows { + match row.status.as_str() { + "__total" => fu_total = row.cnt, + "completed" => fu_completed = row.cnt, + "overdue" => overdue_follow_ups = row.cnt, + _ => {} + } + } + let follow_up_rate = if fu_total > 0 { + (fu_completed as f64 / fu_total as f64) * 100.0 + } else { + 0.0 + }; + + // consultations_this_month / pending_consultations: 咨询统计 + let consultations_this_month = if let Some(did) = doctor_id { + consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::DoctorId.eq(did)) + .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await? as i64 + } else { + 0 + }; + + let pending_consultations = if let Some(did) = doctor_id { + consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::DoctorId.eq(did)) + .filter(consultation_session::Column::Status.eq("active")) + .count(db) + .await? as i64 + } else { + 0 + }; + + // today_appointments: 今日预约 + let today_appointments = if let Some(did) = doctor_id { + appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .filter(appointment::Column::DoctorId.eq(did)) + .filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64 + } else { + 0 + }; + + // today_follow_ups: 今日随访任务 + let today_follow_ups = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter(follow_up_task::Column::AssignedTo.eq(user_id)) + .filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64; + + // vital_signs_report_rate: 当前医生的患者体征上报率 + let (vital_signs_reported, vital_signs_total, vital_signs_report_rate) = if my_patients > 0 { + let vs_sql = r#" + SELECT + COUNT(DISTINCT vs.patient_id) AS reported, + $3::bigint AS total + FROM vital_signs vs + WHERE vs.tenant_id = $1 AND vs.deleted_at IS NULL + AND vs.created_at >= date_trunc('month', NOW()) + AND vs.patient_id IN ( + SELECT patient_id FROM patient_doctor_relation + WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL + ) + "#; + + #[derive(Debug, FromQueryResult)] + struct VsCount { + reported: i64, + total: i64, + } + + let result: Option = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + vs_sql, + [tenant_id.into(), doctor_id.unwrap_or_default().into(), my_patients.into()], + ), + ) + .one(db) + .await?; + + match result { + Some(r) => { + let rate = if r.total > 0 { + (r.reported as f64 / r.total as f64) * 100.0 + } else { + 0.0 + }; + (r.reported, r.total, rate) + } + None => (0, my_patients, 0.0), + } + } else { + (0, 0, 0.0) + }; + + // pending_lab_reviews: 待审核化验报告(与当前医生的患者关联) + let pending_lab_reviews = if doctor_id.is_some() { + let lr_sql = r#" + SELECT COUNT(*) AS cnt + FROM lab_report lr + WHERE lr.tenant_id = $1 AND lr.deleted_at IS NULL + AND lr.status = 'pending' + AND lr.patient_id IN ( + SELECT patient_id FROM patient_doctor_relation + WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL + ) + "#; + + #[derive(Debug, FromQueryResult)] + struct LrCnt { + cnt: i64, + } + + let result: Option = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + lr_sql, + [tenant_id.into(), doctor_id.unwrap_or_default().into()], + ), + ) + .one(db) + .await?; + + result.map(|r| r.cnt).unwrap_or(0) + } else { + 0 + }; + + // abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置) + let abnormal_vital_signs: i64 = 0; + + // ── 昨日对比数据 ── + let yesterday_appointments = if let Some(did) = doctor_id { + appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .filter(appointment::Column::DoctorId.eq(did)) + .filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE - INTERVAL '1 day'"))) + .count(db) + .await? as i64 + } else { + 0 + }; + + let yesterday_follow_ups = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter(follow_up_task::Column::AssignedTo.eq(user_id)) + .filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE - INTERVAL '1 day'"))) + .count(db) + .await? as i64; + + let yesterday_overdue = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter(follow_up_task::Column::AssignedTo.eq(user_id)) + .filter(follow_up_task::Column::Status.eq("overdue")) + .filter(Expr::col(follow_up_task::Column::UpdatedAt).lt(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64; + + let yesterday_consultations = if let Some(did) = doctor_id { + consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::DoctorId.eq(did)) + .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .filter(Expr::col(consultation_session::Column::CreatedAt).lt(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64 + } else { + 0 + }; + + Ok(PersonalStatsResp { + my_patients, + new_patients_this_month, + follow_up_rate, + consultations_this_month, + pending_consultations, + vital_signs_report_rate, + today_appointments, + overdue_follow_ups, + today_follow_ups, + abnormal_vital_signs, + vital_signs_reported, + vital_signs_total, + pending_lab_reviews, + yesterday_my_patients: None, + yesterday_today_appointments: Some(yesterday_appointments), + yesterday_consultations_this_month: Some(yesterday_consultations), + yesterday_follow_up_rate: None, + yesterday_today_follow_ups: Some(yesterday_follow_ups), + yesterday_overdue_follow_ups: Some(yesterday_overdue), + }) +}