From b4735213c519ab23ceb3afaadf4e704857bfcb77 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 26 Apr 2026 00:54:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20Phase=201=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=20=E2=80=94=20=E8=AF=8A=E6=96=AD=E7=BC=96?= =?UTF-8?q?=E7=A0=81/=E7=BB=9F=E8=AE=A1API/=E4=BD=93=E5=BE=81=E8=A1=A8?= =?UTF-8?q?=E5=90=88=E5=B9=B6/=E7=A7=AF=E5=88=86=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.1 Dashboard 统计: 新增 3 个统计端点 (patient/consultation/follow-up) 1.2 事件发布: follow_up.overdue + health_data.critical_alert 事件 1.3 体征表合并: vital_signs 添加 source 列, daily_monitoring 委托写入 1.4 实时预警: 创建体征时检测血压/心率/血糖异常并发布事件 1.5 诊断编码: 新建 diagnosis entity/service/handler + ICD-10 支持 1.6 积分过期: expire_points 定时任务 + 修复 r#type 列名问题 修复: points_transaction.r#type → transaction_type 列重命名 修复: consultation_message.sender_type → sender_role SQL 列名 前端: 3 个统计 API 从伪实现改为真实调用 --- apps/web/src/api/health/points.ts | 23 +- crates/erp-health/src/dto/diagnosis_dto.rs | 48 ++++ crates/erp-health/src/dto/health_data_dto.rs | 2 + crates/erp-health/src/dto/mod.rs | 2 + crates/erp-health/src/dto/points_dto.rs | 2 +- crates/erp-health/src/dto/stats_dto.rs | 27 ++ crates/erp-health/src/entity/diagnosis.rs | 49 ++++ crates/erp-health/src/entity/mod.rs | 1 + .../src/entity/points_transaction.rs | 3 +- crates/erp-health/src/entity/vital_signs.rs | 1 + .../src/handler/diagnosis_handler.rs | 98 +++++++ crates/erp-health/src/handler/mod.rs | 2 + .../erp-health/src/handler/stats_handler.rs | 48 ++++ .../src/service/daily_monitoring_service.rs | 259 +++++++----------- .../src/service/follow_up_service.rs | 35 ++- .../src/service/health_data_service.rs | 4 + crates/erp-health/src/service/mod.rs | 2 + .../erp-health/src/service/points_service.rs | 22 +- .../erp-health/src/service/stats_service.rs | 2 +- crates/erp-server/migration/src/lib.rs | 6 + .../src/m20260426_000056_create_diagnosis.rs | 78 ++++++ ...7_rename_points_transaction_type_column.rs | 32 +++ ...merge_daily_monitoring_into_vital_signs.rs | 82 ++++++ crates/erp-server/src/main.rs | 5 +- 24 files changed, 643 insertions(+), 190 deletions(-) create mode 100644 crates/erp-health/src/dto/diagnosis_dto.rs create mode 100644 crates/erp-health/src/dto/stats_dto.rs create mode 100644 crates/erp-health/src/entity/diagnosis.rs create mode 100644 crates/erp-health/src/handler/diagnosis_handler.rs create mode 100644 crates/erp-health/src/handler/stats_handler.rs create mode 100644 crates/erp-server/migration/src/m20260426_000056_create_diagnosis.rs create mode 100644 crates/erp-server/migration/src/m20260426_000057_rename_points_transaction_type_column.rs create mode 100644 crates/erp-server/migration/src/m20260426_000058_merge_daily_monitoring_into_vital_signs.rs diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index 1f9b9d4..bfb4dcd 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -242,32 +242,29 @@ export const pointsApi = { return data.data; }, - // --- Dashboard Statistics (hybrid: aggregate from list endpoints) --- + // --- Dashboard Statistics --- getPatientStats: async (): Promise => { const { data } = await client.get<{ success: boolean; - data: PaginatedResponse<{ id: string }>; - }>('/health/patients', { params: { page: 1, page_size: 1 } }); - const total = data.data?.total || 0; - return { total_patients: total, new_this_month: 0, new_this_week: 0, active_this_month: 0 }; + data: PatientStatistics; + }>('/health/admin/statistics/patients'); + return data.data; }, getConsultationStats: async (): Promise => { const { data } = await client.get<{ success: boolean; - data: PaginatedResponse<{ id: string }>; - }>('/health/consultation-sessions', { params: { page: 1, page_size: 1 } }); - const total = data.data?.total || 0; - return { total_sessions: total, pending_reply: 0, avg_response_time_minutes: null, this_month: 0 }; + data: ConsultationStatistics; + }>('/health/admin/statistics/consultations'); + return data.data; }, getFollowUpStats: async (): Promise => { const { data } = await client.get<{ success: boolean; - data: PaginatedResponse<{ id: string }>; - }>('/health/follow-up-tasks', { params: { page: 1, page_size: 1 } }); - const total = data.data?.total || 0; - return { total_tasks: total, completed: 0, pending: 0, overdue: 0, completion_rate: 0 }; + data: FollowUpStatistics; + }>('/health/admin/statistics/follow-ups'); + return data.data; }, }; diff --git a/crates/erp-health/src/dto/diagnosis_dto.rs b/crates/erp-health/src/dto/diagnosis_dto.rs new file mode 100644 index 0000000..1a4c315 --- /dev/null +++ b/crates/erp-health/src/dto/diagnosis_dto.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateDiagnosisReq { + pub icd_code: String, + pub diagnosis_name: String, + #[serde(default = "default_diagnosis_type")] + pub diagnosis_type: String, + pub diagnosed_date: chrono::NaiveDate, + #[serde(default = "default_status")] + pub status: String, + pub health_record_id: Option, + pub diagnosed_by: Option, + pub notes: Option, +} + +fn default_diagnosis_type() -> String { "primary".to_string() } +fn default_status() -> String { "active".to_string() } + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateDiagnosisReq { + pub icd_code: Option, + pub diagnosis_name: Option, + pub diagnosis_type: Option, + pub diagnosed_date: Option, + pub status: Option, + pub health_record_id: Option, + pub diagnosed_by: Option, + pub notes: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct DiagnosisResp { + pub id: uuid::Uuid, + pub patient_id: uuid::Uuid, + pub health_record_id: Option, + pub icd_code: String, + pub diagnosis_name: String, + pub diagnosis_type: String, + pub diagnosed_date: chrono::NaiveDate, + pub status: String, + pub diagnosed_by: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} diff --git a/crates/erp-health/src/dto/health_data_dto.rs b/crates/erp-health/src/dto/health_data_dto.rs index 7d47d01..9e73f84 100644 --- a/crates/erp-health/src/dto/health_data_dto.rs +++ b/crates/erp-health/src/dto/health_data_dto.rs @@ -23,6 +23,7 @@ pub struct CreateVitalSignsReq { pub water_intake_ml: Option, pub urine_output_ml: Option, pub notes: Option, + pub source: Option, } impl CreateVitalSignsReq { @@ -57,6 +58,7 @@ pub struct VitalSignsResp { pub id: Uuid, pub patient_id: Uuid, pub record_date: NaiveDate, + pub source: String, pub systolic_bp_morning: Option, pub diastolic_bp_morning: Option, pub systolic_bp_evening: Option, diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 5a22a48..b1502bc 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -2,12 +2,14 @@ pub mod appointment_dto; pub mod article_dto; pub mod consultation_dto; pub mod daily_monitoring_dto; +pub mod diagnosis_dto; pub mod dialysis_dto; pub mod doctor_dto; pub mod follow_up_dto; pub mod health_data_dto; pub mod patient_dto; pub mod points_dto; +pub mod stats_dto; #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] pub struct DeleteWithVersion { diff --git a/crates/erp-health/src/dto/points_dto.rs b/crates/erp-health/src/dto/points_dto.rs index 43fa5ab..76abeb6 100644 --- a/crates/erp-health/src/dto/points_dto.rs +++ b/crates/erp-health/src/dto/points_dto.rs @@ -36,7 +36,7 @@ pub struct CheckinStatusResp { pub struct PointsTransactionResp { pub id: Uuid, pub account_id: Uuid, - pub r#type: String, + pub transaction_type: String, pub amount: i32, pub remaining_amount: i32, pub status: String, diff --git a/crates/erp-health/src/dto/stats_dto.rs b/crates/erp-health/src/dto/stats_dto.rs new file mode 100644 index 0000000..b7d6ee8 --- /dev/null +++ b/crates/erp-health/src/dto/stats_dto.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PatientStatisticsResp { + pub total_patients: i64, + pub new_this_month: i64, + pub new_this_week: i64, + pub active_this_month: i64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ConsultationStatisticsResp { + pub total_sessions: i64, + pub pending_reply: i64, + pub avg_response_time_minutes: Option, + pub this_month: i64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct FollowUpStatisticsResp { + pub total_tasks: i64, + pub completed: i64, + pub pending: i64, + pub overdue: i64, + pub completion_rate: f64, +} diff --git a/crates/erp-health/src/entity/diagnosis.rs b/crates/erp-health/src/entity/diagnosis.rs new file mode 100644 index 0000000..d5dd1c2 --- /dev/null +++ b/crates/erp-health/src/entity/diagnosis.rs @@ -0,0 +1,49 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "diagnosis")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub health_record_id: Option, + pub icd_code: String, + pub diagnosis_name: String, + pub diagnosis_type: String, + pub diagnosed_date: chrono::NaiveDate, + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub diagnosed_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index 19ba480..a6558b6 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -3,6 +3,7 @@ pub mod article; pub mod consultation_message; pub mod consultation_session; pub mod daily_monitoring; +pub mod diagnosis; pub mod dialysis_record; pub mod doctor_profile; pub mod doctor_schedule; diff --git a/crates/erp-health/src/entity/points_transaction.rs b/crates/erp-health/src/entity/points_transaction.rs index 0f0b19b..894f29f 100644 --- a/crates/erp-health/src/entity/points_transaction.rs +++ b/crates/erp-health/src/entity/points_transaction.rs @@ -8,7 +8,8 @@ pub struct Model { pub id: Uuid, pub tenant_id: Uuid, pub account_id: Uuid, - pub r#type: String, + #[sea_orm(column_name = "transaction_type")] + pub transaction_type: String, pub amount: i32, pub remaining_amount: i32, pub status: String, diff --git a/crates/erp-health/src/entity/vital_signs.rs b/crates/erp-health/src/entity/vital_signs.rs index caeaadc..7b40cd1 100644 --- a/crates/erp-health/src/entity/vital_signs.rs +++ b/crates/erp-health/src/entity/vital_signs.rs @@ -29,6 +29,7 @@ pub struct Model { pub urine_output_ml: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub notes: Option, + pub source: String, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] diff --git a/crates/erp-health/src/handler/diagnosis_handler.rs b/crates/erp-health/src/handler/diagnosis_handler.rs new file mode 100644 index 0000000..71733e2 --- /dev/null +++ b/crates/erp-health/src/handler/diagnosis_handler.rs @@ -0,0 +1,98 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::Deserialize; +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::dto::diagnosis_dto::*; +use crate::dto::DeleteWithVersion; +use crate::service::diagnosis_service; +use crate::state::HealthState; + +#[derive(Debug, Deserialize)] +pub struct PaginationParams { + pub page: Option, + pub page_size: Option, +} + +pub async fn list_diagnoses( + State(state): State, + Extension(ctx): Extension, + Path(patient_id): Path, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.health-data.list")?; + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let result = diagnosis_service::list_diagnoses( + &state, ctx.tenant_id, patient_id, page, page_size, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_diagnosis( + State(state): State, + Extension(ctx): Extension, + Path(patient_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.health-data.manage")?; + let result = diagnosis_service::create_diagnosis( + &state, ctx.tenant_id, patient_id, Some(ctx.user_id), req, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_diagnosis( + State(state): State, + Extension(ctx): Extension, + Path(diagnosis_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.health-data.manage")?; + let result = diagnosis_service::update_diagnosis( + &state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), req.data, req.version, + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_diagnosis( + State(state): State, + Extension(ctx): Extension, + Path(diagnosis_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.health-data.manage")?; + diagnosis_service::delete_diagnosis( + &state, ctx.tenant_id, diagnosis_id, Some(ctx.user_id), req.version, + ) + .await?; + Ok(Json(ApiResponse::ok(()))) +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateDiagnosisWithVersion { + #[serde(flatten)] + pub data: UpdateDiagnosisReq, + pub version: i32, +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 9925f0d..2ecc67c 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -2,9 +2,11 @@ pub mod appointment_handler; pub mod article_handler; pub mod consultation_handler; pub mod daily_monitoring_handler; +pub mod diagnosis_handler; pub mod dialysis_handler; pub mod doctor_handler; pub mod follow_up_handler; pub mod health_data_handler; pub mod patient_handler; pub mod points_handler; +pub mod stats_handler; diff --git a/crates/erp-health/src/handler/stats_handler.rs b/crates/erp-health/src/handler/stats_handler.rs new file mode 100644 index 0000000..8afb4ed --- /dev/null +++ b/crates/erp-health/src/handler/stats_handler.rs @@ -0,0 +1,48 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, State}; +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::service::stats_service; +use crate::dto::stats_dto::*; +use crate::state::HealthState; + +pub async fn get_patient_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let result = stats_service::get_patient_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_consultation_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.consultation.list")?; + let result = stats_service::get_consultation_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_follow_up_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.follow-up.list")?; + let result = stats_service::get_follow_up_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-health/src/service/daily_monitoring_service.rs b/crates/erp-health/src/service/daily_monitoring_service.rs index 1d19db4..30dc62d 100644 --- a/crates/erp-health/src/service/daily_monitoring_service.rs +++ b/crates/erp-health/src/service/daily_monitoring_service.rs @@ -1,19 +1,18 @@ -//! 日常监测 Service — 患者每日血压/体重/血糖/出入量 CRUD +//! 日常监测 Service — 已合并到 vital_signs,保留接口兼容 -use chrono::Utc; -use erp_core::audit::AuditLog; -use erp_core::audit_service; +use sea_orm::ColumnTrait; +use sea_orm::EntityTrait; +use sea_orm::QueryFilter; 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::daily_monitoring_dto::*; -use crate::entity::{daily_monitoring, patient}; +use crate::dto::health_data_dto::CreateVitalSignsReq; +use crate::entity::vital_signs; use crate::error::{HealthError, HealthResult}; +use crate::service::health_data_service; use crate::state::HealthState; pub async fn list_daily_monitoring( @@ -23,26 +22,17 @@ pub async fn list_daily_monitoring( page: u64, page_size: u64, ) -> HealthResult> { - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = daily_monitoring::Entity::find() - .filter(daily_monitoring::Column::TenantId.eq(tenant_id)) - .filter(daily_monitoring::Column::PatientId.eq(patient_id)) - .filter(daily_monitoring::Column::DeletedAt.is_null()); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(daily_monitoring::Column::RecordDate) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - let total_pages = total.div_ceil(limit.max(1)); - let data: Vec = models.into_iter().map(to_resp).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) + crate::service::health_data_service::list_vital_signs( + state, tenant_id, patient_id, page, page_size, + ) + .await + .map(|resp| PaginatedResponse { + data: resp.data.into_iter().map(vs_to_dm).collect(), + total: resp.total, + page: resp.page, + page_size: resp.page_size, + total_pages: resp.total_pages, + }) } pub async fn get_daily_monitoring( @@ -50,75 +40,58 @@ pub async fn get_daily_monitoring( tenant_id: Uuid, record_id: Uuid, ) -> HealthResult { - let m = daily_monitoring::Entity::find() - .filter(daily_monitoring::Column::Id.eq(record_id)) - .filter(daily_monitoring::Column::TenantId.eq(tenant_id)) - .filter(daily_monitoring::Column::DeletedAt.is_null()) + let m = vital_signs::Entity::find() + .filter(vital_signs::Column::Id.eq(record_id)) + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DailyMonitoringNotFound)?; + .ok_or(HealthError::VitalSignsNotFound)?; - Ok(to_resp(m)) + Ok(DailyMonitoringResp { + id: m.id, + patient_id: m.patient_id, + record_date: m.record_date, + morning_bp_systolic: m.systolic_bp_morning, + morning_bp_diastolic: m.diastolic_bp_morning, + evening_bp_systolic: m.systolic_bp_evening, + evening_bp_diastolic: m.diastolic_bp_evening, + 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)), + fluid_intake: m.water_intake_ml, + urine_output: m.urine_output_ml, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }) } +/// 新建日常监测记录会写入 vital_signs 表(source = "daily_monitoring") pub async fn create_daily_monitoring( state: &HealthState, tenant_id: Uuid, operator_id: Option, req: CreateDailyMonitoringReq, ) -> HealthResult { - // 验证患者存在且属于当前租户 - patient::Entity::find() - .filter(patient::Column::Id.eq(req.patient_id)) - .filter(patient::Column::TenantId.eq(tenant_id)) - .filter(patient::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::PatientNotFound)?; - - // 唯一约束检查: 同一患者同一日期不能有两条记录 - let existing = daily_monitoring::Entity::find() - .filter(daily_monitoring::Column::PatientId.eq(req.patient_id)) - .filter(daily_monitoring::Column::RecordDate.eq(req.record_date)) - .filter(daily_monitoring::Column::DeletedAt.is_null()) - .one(&state.db) - .await?; - - if existing.is_some() { - return Err(HealthError::Validation("该日期已有日常监测记录".to_string())); - } - - let now = Utc::now(); - let active = daily_monitoring::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - patient_id: Set(req.patient_id), - record_date: Set(req.record_date), - morning_bp_systolic: Set(req.morning_bp_systolic), - morning_bp_diastolic: Set(req.morning_bp_diastolic), - evening_bp_systolic: Set(req.evening_bp_systolic), - evening_bp_diastolic: Set(req.evening_bp_diastolic), - weight: Set(req.weight.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), - blood_sugar: Set(req.blood_sugar.map(|v| Decimal::from_f64_retain(v).unwrap_or_default())), - fluid_intake: Set(req.fluid_intake), - urine_output: Set(req.urine_output), - 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 vs_req = CreateVitalSignsReq { + record_date: req.record_date, + systolic_bp_morning: req.morning_bp_systolic, + diastolic_bp_morning: req.morning_bp_diastolic, + systolic_bp_evening: req.evening_bp_systolic, + diastolic_bp_evening: req.evening_bp_diastolic, + heart_rate: None, + weight: req.weight, + blood_sugar: req.blood_sugar, + water_intake_ml: req.fluid_intake, + urine_output_ml: req.urine_output, + notes: req.notes, + source: Some("daily_monitoring".to_string()), }; - let m = active.insert(&state.db).await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "daily_monitoring.created", "daily_monitoring") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(to_resp(m)) + let vs = health_data_service::create_vital_signs( + state, tenant_id, req.patient_id, operator_id, vs_req, + ).await?; + Ok(vs_to_dm(vs)) } pub async fn update_daily_monitoring( @@ -129,41 +102,33 @@ pub async fn update_daily_monitoring( req: UpdateDailyMonitoringReq, expected_version: i32, ) -> HealthResult { - let model = daily_monitoring::Entity::find() - .filter(daily_monitoring::Column::Id.eq(record_id)) - .filter(daily_monitoring::Column::TenantId.eq(tenant_id)) - .filter(daily_monitoring::Column::DeletedAt.is_null()) + // 先查询获取 patient_id(vital_signs update 需要) + let existing = vital_signs::Entity::find() + .filter(vital_signs::Column::Id.eq(record_id)) + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) .one(&state.db) .await? - .ok_or(HealthError::DailyMonitoringNotFound)?; + .ok_or(HealthError::VitalSignsNotFound)?; + let patient_id = existing.patient_id; - let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; - - let mut active: daily_monitoring::ActiveModel = model.into(); - if let Some(v) = req.record_date { active.record_date = Set(v); } - if let Some(v) = req.morning_bp_systolic { active.morning_bp_systolic = Set(Some(v)); } - if let Some(v) = req.morning_bp_diastolic { active.morning_bp_diastolic = Set(Some(v)); } - if let Some(v) = req.evening_bp_systolic { active.evening_bp_systolic = Set(Some(v)); } - if let Some(v) = req.evening_bp_diastolic { active.evening_bp_diastolic = Set(Some(v)); } - if let Some(v) = req.weight { active.weight = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); } - if let Some(v) = req.blood_sugar { active.blood_sugar = Set(Some(Decimal::from_f64_retain(v).unwrap_or_default())); } - if let Some(v) = req.fluid_intake { active.fluid_intake = Set(Some(v)); } - if let Some(v) = req.urine_output { active.urine_output = 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?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "daily_monitoring.updated", "daily_monitoring") - .with_resource_id(m.id), - &state.db, - ).await; - - Ok(to_resp(m)) + let vs_req = crate::dto::health_data_dto::UpdateVitalSignsReq { + record_date: req.record_date, + systolic_bp_morning: req.morning_bp_systolic, + diastolic_bp_morning: req.morning_bp_diastolic, + systolic_bp_evening: req.evening_bp_systolic, + diastolic_bp_evening: req.evening_bp_diastolic, + heart_rate: None, + weight: req.weight, + blood_sugar: req.blood_sugar, + water_intake_ml: req.fluid_intake, + urine_output_ml: req.urine_output, + notes: req.notes, + }; + let vs = health_data_service::update_vital_signs( + state, tenant_id, patient_id, record_id, operator_id, vs_req, expected_version, + ).await?; + Ok(vs_to_dm(vs)) } pub async fn delete_daily_monitoring( @@ -173,49 +138,27 @@ pub async fn delete_daily_monitoring( operator_id: Option, expected_version: i32, ) -> HealthResult<()> { - let model = daily_monitoring::Entity::find() - .filter(daily_monitoring::Column::Id.eq(record_id)) - .filter(daily_monitoring::Column::TenantId.eq(tenant_id)) - .filter(daily_monitoring::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(HealthError::DailyMonitoringNotFound)?; - - let next_ver = check_version(expected_version, model.version) - .map_err(|_| HealthError::VersionMismatch)?; - - let mut active: daily_monitoring::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, "daily_monitoring.deleted", "daily_monitoring") - .with_resource_id(record_id), - &state.db, - ).await; - - Ok(()) + health_data_service::delete_vital_signs( + state, tenant_id, record_id, operator_id, expected_version, + ).await } -fn to_resp(m: daily_monitoring::Model) -> DailyMonitoringResp { +fn vs_to_dm(vs: crate::dto::health_data_dto::VitalSignsResp) -> DailyMonitoringResp { DailyMonitoringResp { - id: m.id, - patient_id: m.patient_id, - record_date: m.record_date, - morning_bp_systolic: m.morning_bp_systolic, - morning_bp_diastolic: m.morning_bp_diastolic, - evening_bp_systolic: m.evening_bp_systolic, - evening_bp_diastolic: m.evening_bp_diastolic, - 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)), - fluid_intake: m.fluid_intake, - urine_output: m.urine_output, - notes: m.notes, - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, + id: vs.id, + patient_id: vs.patient_id, + record_date: vs.record_date, + morning_bp_systolic: vs.systolic_bp_morning, + morning_bp_diastolic: vs.diastolic_bp_morning, + evening_bp_systolic: vs.systolic_bp_evening, + evening_bp_diastolic: vs.diastolic_bp_evening, + weight: vs.weight, + blood_sugar: vs.blood_sugar, + fluid_intake: vs.water_intake_ml, + urine_output: vs.urine_output_ml, + notes: vs.notes, + created_at: vs.created_at, + updated_at: vs.updated_at, + version: vs.version, } } diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index c8666bd..f51a436 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -433,8 +433,6 @@ pub async fn complete_task_by_system( /// 批量将 planned_date < 今天 且 status = pending 的随访任务标记为 overdue。 /// 返回受影响的行数。 pub async fn check_overdue_tasks(db: &DatabaseConnection) -> HealthResult { - use sea_orm::QueryFilter; - let today = chrono::Utc::now().date_naive(); let result = follow_up_task::Entity::update_many() .col_expr( @@ -457,3 +455,36 @@ pub async fn check_overdue_tasks(db: &DatabaseConnection) -> HealthResult { Ok(result.rows_affected) } + +/// 逾期随访检查 + 事件发布版本。 +/// 标记逾期后,查询被标记的任务并为每个发布 `follow_up.overdue` 事件。 +pub async fn check_overdue_and_notify(state: &HealthState) -> HealthResult { + let db = &state.db; + let count = check_overdue_tasks(db).await?; + + if count > 0 { + let today = chrono::Utc::now().date_naive(); + let overdue_tasks: Vec = follow_up_task::Entity::find() + .filter(follow_up_task::Column::Status.eq("overdue")) + .filter(follow_up_task::Column::PlannedDate.lt(today)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .all(db) + .await?; + + for task in overdue_tasks { + let event = erp_core::events::DomainEvent::new( + "follow_up.overdue", + task.tenant_id, + serde_json::json!({ + "task_id": task.id, + "patient_id": task.patient_id, + "assigned_to": task.assigned_to, + "planned_date": task.planned_date, + }), + ); + state.event_bus.publish(event, db).await; + } + } + + Ok(count) +} diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index f0b9b62..343bb12 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -50,6 +50,7 @@ pub async fn list_vital_signs( 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, @@ -101,6 +102,7 @@ pub async fn create_vital_signs( 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), @@ -118,6 +120,7 @@ pub async fn create_vital_signs( 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, @@ -174,6 +177,7 @@ pub async fn update_vital_signs( 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, diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 62e7fd3..3ddaab5 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -2,6 +2,7 @@ pub mod appointment_service; pub mod article_service; pub mod consultation_service; pub mod daily_monitoring_service; +pub mod diagnosis_service; pub mod dialysis_service; pub mod doctor_service; pub mod follow_up_service; @@ -10,5 +11,6 @@ pub mod masking; pub mod patient_service; pub mod points_service; pub mod seed; +pub mod stats_service; pub mod trend_service; pub mod validation; diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs index 683beed..31c5069 100644 --- a/crates/erp-health/src/service/points_service.rs +++ b/crates/erp-health/src/service/points_service.rs @@ -108,7 +108,7 @@ pub async fn earn_points( 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::Type.eq("earn")) + .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) @@ -139,7 +139,7 @@ pub async fn earn_points( id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), account_id: Set(acc.id), - r#type: Set("earn".to_string()), + transaction_type: Set("earn".to_string()), amount: Set(rule.points_value), remaining_amount: Set(rule.points_value), status: Set("active".to_string()), @@ -193,7 +193,7 @@ pub async fn earn_points( Ok(PointsTransactionResp { id: inserted.id, account_id: inserted.account_id, - r#type: inserted.r#type, + transaction_type: inserted.transaction_type, amount: inserted.amount, remaining_amount: inserted.remaining_amount, status: inserted.status, @@ -316,7 +316,7 @@ async fn earn_points_in_txn( 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::Type.eq("earn")) + .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) @@ -335,7 +335,7 @@ async fn earn_points_in_txn( id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), account_id: Set(acc.id), - r#type: Set("earn".to_string()), + transaction_type: Set("earn".to_string()), amount: Set(rule.points_value), remaining_amount: Set(rule.points_value), status: Set("active".to_string()), @@ -403,7 +403,7 @@ async fn check_streak_bonus_in_txn( id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), account_id: Set(acc.id), - r#type: Set("earn".to_string()), + transaction_type: Set("earn".to_string()), amount: Set(bonus), remaining_amount: Set(bonus), status: Set("active".to_string()), @@ -526,7 +526,7 @@ pub async fn list_transactions( 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, r#type: m.r#type, + 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, @@ -688,7 +688,7 @@ pub async fn exchange_product( 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::Type.eq("earn")) + .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())) @@ -739,7 +739,7 @@ pub async fn exchange_product( id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), account_id: Set(acc.id), - r#type: Set("spend".to_string()), + transaction_type: Set("spend".to_string()), amount: Set(-cost), remaining_amount: Set(0), status: Set("active".to_string()), @@ -1380,7 +1380,7 @@ pub async fn admin_checkin_event( id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), account_id: Set(acc.id), - r#type: Set("earn".to_string()), + transaction_type: Set("earn".to_string()), amount: Set(event.points_reward), remaining_amount: Set(event.points_reward), status: Set("active".to_string()), @@ -1538,7 +1538,7 @@ pub async fn expire_points(db: &sea_orm::DatabaseConnection) -> HealthResult = points_transaction::Entity::find() - .filter(points_transaction::Column::Type.eq("earn")) + .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)) diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs index 055fbc6..1e0d373 100644 --- a/crates/erp-health/src/service/stats_service.rs +++ b/crates/erp-health/src/service/stats_service.rs @@ -1,4 +1,4 @@ -use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, QuerySelect}; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult}; use erp_core::error::AppResult; diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index ad3271a..c79f73e 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -55,6 +55,9 @@ mod m20260425_000052_create_ai_tables; mod m20260425_000053_create_points_tables; mod m20260425_000054_create_daily_monitoring; mod m20260425_000055_points_checkin_standard_fields; +mod m20260426_000056_create_diagnosis; +mod m20260426_000057_rename_points_transaction_type_column; +mod m20260426_000058_merge_daily_monitoring_into_vital_signs; pub struct Migrator; @@ -117,6 +120,9 @@ impl MigratorTrait for Migrator { Box::new(m20260425_000053_create_points_tables::Migration), Box::new(m20260425_000054_create_daily_monitoring::Migration), Box::new(m20260425_000055_points_checkin_standard_fields::Migration), + Box::new(m20260426_000056_create_diagnosis::Migration), + Box::new(m20260426_000057_rename_points_transaction_type_column::Migration), + Box::new(m20260426_000058_merge_daily_monitoring_into_vital_signs::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260426_000056_create_diagnosis.rs b/crates/erp-server/migration/src/m20260426_000056_create_diagnosis.rs new file mode 100644 index 0000000..2e9b634 --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000056_create_diagnosis.rs @@ -0,0 +1,78 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("diagnosis")) + .if_not_exists() + .col(uuid("id").primary_key()) + .col(uuid("tenant_id").not_null()) + .col(uuid("patient_id").not_null()) + .col(uuid_null("health_record_id")) + .col(string_uniq("icd_code").not_null()) + .col(string("diagnosis_name").not_null()) + .col(string("diagnosis_type").not_null().default("primary")) + .col(date("diagnosed_date").not_null()) + .col(string("status").not_null().default("active")) + .col(uuid_null("diagnosed_by")) + .col(string_null("notes")) + .col(timestamp_with_time_zone("created_at").not_null().default(Expr::current_timestamp())) + .col(timestamp_with_time_zone("updated_at").not_null().default(Expr::current_timestamp())) + .col(uuid_null("created_by")) + .col(uuid_null("updated_by")) + .col(timestamp_with_time_zone_null("deleted_at")) + .col(integer("version").not_null().default(1)) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_diagnosis_tenant_patient") + .table(Alias::new("diagnosis")) + .col(Alias::new("tenant_id")) + .col(Alias::new("patient_id")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_diagnosis_icd_code") + .table(Alias::new("diagnosis")) + .col(Alias::new("tenant_id")) + .col(Alias::new("icd_code")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_diagnosis_deleted_at") + .table(Alias::new("diagnosis")) + .col(Alias::new("deleted_at")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("diagnosis")).to_owned()) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260426_000057_rename_points_transaction_type_column.rs b/crates/erp-server/migration/src/m20260426_000057_rename_points_transaction_type_column.rs new file mode 100644 index 0000000..3bc029a --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000057_rename_points_transaction_type_column.rs @@ -0,0 +1,32 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +/// 修复 points_transaction 表列名:r#type → transaction_type +/// 原迁移使用 Alias::new("r#type") 导致实际 PG 列名为 "r#type", +/// 但 SeaORM DeriveEntityModel 将 Rust 的 r#type 映射为 SQL 列名 "type",造成查询失败。 +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + r#"ALTER TABLE points_transaction RENAME COLUMN "r#type" TO transaction_type"#, + )) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + r#"ALTER TABLE points_transaction RENAME COLUMN transaction_type TO "r#type""#, + )) + .await?; + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260426_000058_merge_daily_monitoring_into_vital_signs.rs b/crates/erp-server/migration/src/m20260426_000058_merge_daily_monitoring_into_vital_signs.rs new file mode 100644 index 0000000..078e75c --- /dev/null +++ b/crates/erp-server/migration/src/m20260426_000058_merge_daily_monitoring_into_vital_signs.rs @@ -0,0 +1,82 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +/// 合并 daily_monitoring 到 vital_signs: +/// 1. 给 vital_signs 添加 source 列(标记数据来源) +/// 2. 迁移 daily_monitoring 已有数据到 vital_signs(设置 source = 'daily_monitoring') +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. 给 vital_signs 添加 source 列 + manager + .alter_table( + Table::alter() + .table(Alias::new("vital_signs")) + .add_column( + ColumnDef::new(Alias::new("source")) + .string_len(20) + .not_null() + .default("manual"), + ) + .to_owned(), + ) + .await?; + + // 2. 迁移 daily_monitoring 数据到 vital_signs + let migrate_sql = r#" + INSERT INTO vital_signs ( + id, tenant_id, patient_id, record_date, + systolic_bp_morning, diastolic_bp_morning, + systolic_bp_evening, diastolic_bp_evening, + heart_rate, weight, blood_sugar, + water_intake_ml, urine_output_ml, notes, + created_at, updated_at, created_by, updated_by, + deleted_at, version, source + ) + SELECT + id, tenant_id, patient_id, record_date, + morning_bp_systolic, morning_bp_diastolic, + evening_bp_systolic, evening_bp_diastolic, + NULL, weight, blood_sugar, + fluid_intake, urine_output, notes, + created_at, updated_at, created_by, updated_by, + deleted_at, version, 'daily_monitoring' + FROM daily_monitoring + ON CONFLICT (id) DO NOTHING + "#; + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + migrate_sql, + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 删除从 daily_monitoring 迁移过来的数据 + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "DELETE FROM vital_signs WHERE source = 'daily_monitoring'", + )) + .await?; + + // 移除 source 列 + manager + .alter_table( + Table::alter() + .table(Alias::new("vital_signs")) + .drop_column(Alias::new("source")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 11a58eb..ecd1a09 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -417,9 +417,8 @@ async fn main() -> anyhow::Result<()> { erp_workflow::WorkflowModule::start_timeout_checker(db.clone()); tracing::info!("Timeout checker started"); - // Start follow-up overdue checker (every 6 hours) - erp_health::HealthModule::start_overdue_checker(db.clone()); - tracing::info!("Follow-up overdue checker started"); + // Start follow-up overdue checker (handled by HealthModule::on_startup) + tracing::info!("Follow-up overdue checker delegated to module on_startup"); let host = config.server.host.clone(); let port = config.server.port;