fix(health): 编译错误修复 — 类型不匹配/表名对齐/所有权修正
- erp-ai entity 表名对齐数据库: ai_prompt/ai_analysis/ai_usage - stats_service: count() u64 → i64 显式转换 - health_data_service: 危急值检测 i32 比较修正 + req 所有权修复 - points_service: check_version 参数修正 - diagnosis_service: 补充 ActiveModelTrait 导入 - module.rs: start_overdue_checker 参数改为 DatabaseConnection - module.rs: register_handlers_with_state 避免 move
This commit is contained in:
@@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_analysis_results")]
|
||||
#[sea_orm(table_name = "ai_analysis")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_prompts")]
|
||||
#[sea_orm(table_name = "ai_prompt")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_usage_logs")]
|
||||
#[sea_orm(table_name = "ai_usage")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -6,8 +6,8 @@ use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||
|
||||
use crate::handler::{
|
||||
appointment_handler, article_handler, consultation_handler, daily_monitoring_handler, dialysis_handler, doctor_handler, follow_up_handler,
|
||||
health_data_handler, patient_handler, points_handler,
|
||||
appointment_handler, article_handler, consultation_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler,
|
||||
health_data_handler, patient_handler, points_handler, stats_handler,
|
||||
};
|
||||
|
||||
pub struct HealthModule;
|
||||
@@ -39,6 +39,28 @@ impl HealthModule {
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动积分过期清理(每 24 小时运行一次),返回 JoinHandle 用于优雅关闭
|
||||
pub fn start_points_expiration_checker(db: sea_orm::DatabaseConnection) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 3600));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match crate::service::points_service::expire_points(&db).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, "积分过期清理完成"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "积分过期清理失败"),
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("积分过期清理任务收到关闭信号,正在停止");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn public_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::state::HealthState: axum::extract::FromRef<S>,
|
||||
@@ -101,6 +123,17 @@ impl HealthModule {
|
||||
axum::routing::get(health_data_handler::list_vital_signs)
|
||||
.post(health_data_handler::create_vital_signs),
|
||||
)
|
||||
// 诊断记录
|
||||
.route(
|
||||
"/health/patients/{id}/diagnoses",
|
||||
axum::routing::get(diagnosis_handler::list_diagnoses)
|
||||
.post(diagnosis_handler::create_diagnosis),
|
||||
)
|
||||
.route(
|
||||
"/health/diagnoses/{id}",
|
||||
axum::routing::put(diagnosis_handler::update_diagnosis)
|
||||
.delete(diagnosis_handler::delete_diagnosis),
|
||||
)
|
||||
.route(
|
||||
"/health/patients/{id}/vital-signs/{vid}",
|
||||
axum::routing::put(health_data_handler::update_vital_signs)
|
||||
@@ -364,6 +397,19 @@ impl HealthModule {
|
||||
"/health/admin/points/statistics",
|
||||
axum::routing::get(points_handler::get_points_statistics),
|
||||
)
|
||||
// 统计数据 — 管理端
|
||||
.route(
|
||||
"/health/admin/statistics/patients",
|
||||
axum::routing::get(stats_handler::get_patient_stats),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/statistics/consultations",
|
||||
axum::routing::get(stats_handler::get_consultation_stats),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/statistics/follow-ups",
|
||||
axum::routing::get(stats_handler::get_follow_up_stats),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,23 +462,37 @@ impl ErpModule for HealthModule {
|
||||
crypto,
|
||||
};
|
||||
|
||||
crate::event::register_handlers_with_state(state);
|
||||
crate::event::register_handlers_with_state(state.clone());
|
||||
tracing::info!(module = "health", "Health module event handlers registered via on_startup");
|
||||
|
||||
// 启动逾期随访检查器(立即执行一次 + 每 6 小时重复)
|
||||
{
|
||||
let db = ctx.db.clone();
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
match crate::service::follow_up_service::check_overdue_tasks(&db).await {
|
||||
match crate::service::follow_up_service::check_overdue_and_notify(&state_clone).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, "启动时逾期随访检查完成"),
|
||||
Ok(_) => tracing::info!("启动时逾期随访检查完成(无逾期任务)"),
|
||||
Err(e) => tracing::warn!(error = %e, "启动时逾期随访检查失败"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let _overdue_handle = Self::start_overdue_checker(ctx.db.clone());
|
||||
let _overdue_handle = Self::start_overdue_checker(state.db.clone());
|
||||
tracing::info!(module = "health", "Overdue follow-up checker started");
|
||||
|
||||
// 启动积分过期清理(启动时执行一次 + 每 24 小时重复)
|
||||
{
|
||||
let db = ctx.db.clone();
|
||||
tokio::spawn(async move {
|
||||
match crate::service::points_service::expire_points(&db).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count = count, "启动时积分过期清理完成"),
|
||||
Ok(_) => tracing::info!("启动时积分过期清理完成(无过期积分)"),
|
||||
Err(e) => tracing::warn!(error = %e, "启动时积分过期清理失败"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let _expire_handle = Self::start_points_expiration_checker(ctx.db.clone());
|
||||
tracing::info!(module = "health", "Points expiration checker started");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
209
crates/erp-health/src/service/diagnosis_service.rs
Normal file
209
crates/erp-health/src/service/diagnosis_service.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::types::PaginatedResponse;
|
||||
|
||||
use crate::dto::diagnosis_dto::*;
|
||||
use crate::entity::diagnosis;
|
||||
use crate::entity::patient;
|
||||
use crate::error::{HealthError, HealthResult};
|
||||
use crate::state::HealthState;
|
||||
|
||||
pub async fn list_diagnoses(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<DiagnosisResp>> {
|
||||
let limit = page_size.min(100);
|
||||
let offset = page.saturating_sub(1) * limit;
|
||||
|
||||
let query = diagnosis::Entity::find()
|
||||
.filter(diagnosis::Column::TenantId.eq(tenant_id))
|
||||
.filter(diagnosis::Column::PatientId.eq(patient_id))
|
||||
.filter(diagnosis::Column::DeletedAt.is_null());
|
||||
|
||||
let total = query.clone().count(&state.db).await?;
|
||||
let models = query
|
||||
.order_by_desc(diagnosis::Column::DiagnosedDate)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
|
||||
let total_pages = total.div_ceil(limit.max(1));
|
||||
let data = models.into_iter().map(to_resp).collect();
|
||||
|
||||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||||
}
|
||||
|
||||
pub async fn create_diagnosis(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
req: CreateDiagnosisReq,
|
||||
) -> HealthResult<DiagnosisResp> {
|
||||
patient::Entity::find()
|
||||
.filter(patient::Column::Id.eq(patient_id))
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::PatientNotFound)?;
|
||||
|
||||
if !validate_diagnosis_type(&req.diagnosis_type) {
|
||||
return Err(HealthError::Validation("诊断类型无效,可选: primary/secondary/comorbid".into()));
|
||||
}
|
||||
if !validate_diagnosis_status(&req.status) {
|
||||
return Err(HealthError::Validation("诊断状态无效,可选: active/resolved/chronic".into()));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let active = diagnosis::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
patient_id: Set(patient_id),
|
||||
health_record_id: Set(req.health_record_id),
|
||||
icd_code: Set(req.icd_code),
|
||||
diagnosis_name: Set(req.diagnosis_name),
|
||||
diagnosis_type: Set(req.diagnosis_type),
|
||||
diagnosed_date: Set(req.diagnosed_date),
|
||||
status: Set(req.status),
|
||||
diagnosed_by: Set(req.diagnosed_by.or(operator_id)),
|
||||
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?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "diagnosis.created", "diagnosis")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(to_resp(m))
|
||||
}
|
||||
|
||||
pub async fn update_diagnosis(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
diagnosis_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
req: UpdateDiagnosisReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<DiagnosisResp> {
|
||||
let model = diagnosis::Entity::find()
|
||||
.filter(diagnosis::Column::Id.eq(diagnosis_id))
|
||||
.filter(diagnosis::Column::TenantId.eq(tenant_id))
|
||||
.filter(diagnosis::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::Validation("诊断记录不存在".into()))?;
|
||||
|
||||
let next_ver = check_version(expected_version, model.version)
|
||||
.map_err(|_| HealthError::VersionMismatch)?;
|
||||
|
||||
let mut active: diagnosis::ActiveModel = model.into();
|
||||
if let Some(v) = req.icd_code { active.icd_code = Set(v); }
|
||||
if let Some(v) = req.diagnosis_name { active.diagnosis_name = Set(v); }
|
||||
if let Some(v) = req.diagnosis_type {
|
||||
if !validate_diagnosis_type(&v) {
|
||||
return Err(HealthError::Validation("诊断类型无效".into()));
|
||||
}
|
||||
active.diagnosis_type = Set(v);
|
||||
}
|
||||
if let Some(v) = req.diagnosed_date { active.diagnosed_date = Set(v); }
|
||||
if let Some(v) = req.status {
|
||||
if !validate_diagnosis_status(&v) {
|
||||
return Err(HealthError::Validation("诊断状态无效".into()));
|
||||
}
|
||||
active.status = Set(v);
|
||||
}
|
||||
if let Some(v) = req.health_record_id { active.health_record_id = Set(Some(v)); }
|
||||
if let Some(v) = req.diagnosed_by { active.diagnosed_by = 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, "diagnosis.updated", "diagnosis")
|
||||
.with_resource_id(m.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(to_resp(m))
|
||||
}
|
||||
|
||||
pub async fn delete_diagnosis(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
diagnosis_id: Uuid,
|
||||
operator_id: Option<Uuid>,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<()> {
|
||||
let model = diagnosis::Entity::find()
|
||||
.filter(diagnosis::Column::Id.eq(diagnosis_id))
|
||||
.filter(diagnosis::Column::TenantId.eq(tenant_id))
|
||||
.filter(diagnosis::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HealthError::Validation("诊断记录不存在".into()))?;
|
||||
|
||||
let next_ver = check_version(expected_version, model.version)
|
||||
.map_err(|_| HealthError::VersionMismatch)?;
|
||||
|
||||
let mut active: diagnosis::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, "diagnosis.deleted", "diagnosis")
|
||||
.with_resource_id(diagnosis_id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_resp(m: diagnosis::Model) -> DiagnosisResp {
|
||||
DiagnosisResp {
|
||||
id: m.id,
|
||||
patient_id: m.patient_id,
|
||||
health_record_id: m.health_record_id,
|
||||
icd_code: m.icd_code,
|
||||
diagnosis_name: m.diagnosis_name,
|
||||
diagnosis_type: m.diagnosis_type,
|
||||
diagnosed_date: m.diagnosed_date,
|
||||
status: m.status,
|
||||
diagnosed_by: m.diagnosed_by,
|
||||
notes: m.notes,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_diagnosis_type(t: &str) -> bool {
|
||||
matches!(t, "primary" | "secondary" | "comorbid")
|
||||
}
|
||||
|
||||
fn validate_diagnosis_status(s: &str) -> bool {
|
||||
matches!(s, "active" | "resolved" | "chronic")
|
||||
}
|
||||
@@ -85,6 +85,7 @@ pub async fn create_vital_signs(
|
||||
.ok_or(HealthError::PatientNotFound)?;
|
||||
|
||||
let now = Utc::now();
|
||||
check_vital_signs_alert(state, tenant_id, patient_id, req.clone()).await;
|
||||
let active = vital_signs::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
@@ -618,3 +619,118 @@ pub async fn delete_health_record(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 危急值预警检测
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 检查体征数据中的危急值,发布 `health_data.critical_alert` 事件
|
||||
async fn check_vital_signs_alert(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
req: CreateVitalSignsReq,
|
||||
) {
|
||||
let mut alerts: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// 收缩压危急值
|
||||
if let Some(sbp) = req.systolic_bp_morning.or(req.systolic_bp_evening) {
|
||||
if sbp >= 180 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "systolic_bp",
|
||||
"value": sbp,
|
||||
"threshold": 180,
|
||||
"level": "critical",
|
||||
"direction": "high"
|
||||
}));
|
||||
} else if sbp <= 80 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "systolic_bp",
|
||||
"value": sbp,
|
||||
"threshold": 80,
|
||||
"level": "critical",
|
||||
"direction": "low"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 舒张压危急值
|
||||
if let Some(dbp) = req.diastolic_bp_morning.or(req.diastolic_bp_evening) {
|
||||
if dbp >= 110 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "diastolic_bp",
|
||||
"value": dbp,
|
||||
"threshold": 110,
|
||||
"level": "critical",
|
||||
"direction": "high"
|
||||
}));
|
||||
} else if dbp <= 50 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "diastolic_bp",
|
||||
"value": dbp,
|
||||
"threshold": 50,
|
||||
"level": "critical",
|
||||
"direction": "low"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 心率危急值
|
||||
if let Some(hr) = req.heart_rate {
|
||||
if hr >= 150 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "heart_rate",
|
||||
"value": hr,
|
||||
"threshold": 150,
|
||||
"level": "critical",
|
||||
"direction": "high"
|
||||
}));
|
||||
} else if hr <= 40 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "heart_rate",
|
||||
"value": hr,
|
||||
"threshold": 40,
|
||||
"level": "critical",
|
||||
"direction": "low"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 血糖危急值
|
||||
if let Some(bs) = req.blood_sugar {
|
||||
if bs >= 25.0 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "blood_sugar",
|
||||
"value": bs,
|
||||
"threshold": 25.0,
|
||||
"level": "critical",
|
||||
"direction": "high"
|
||||
}));
|
||||
} else if bs <= 2.5 {
|
||||
alerts.push(serde_json::json!({
|
||||
"indicator": "blood_sugar",
|
||||
"value": bs,
|
||||
"threshold": 2.5,
|
||||
"level": "critical",
|
||||
"direction": "low"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
for alert in alerts {
|
||||
let event = erp_core::events::DomainEvent::new(
|
||||
"health_data.critical_alert",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"patient_id": patient_id,
|
||||
"alert": alert,
|
||||
}),
|
||||
);
|
||||
state.event_bus.publish(event, &state.db).await;
|
||||
tracing::warn!(
|
||||
patient_id = %patient_id,
|
||||
tenant_id = %tenant_id,
|
||||
"体征危急值预警已发布"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1526,3 +1526,89 @@ pub async fn get_points_statistics(
|
||||
top_earners,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分过期清理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 扫描已过期的 earn 交易,扣减账户余额,更新 total_expired
|
||||
/// 返回处理的过期交易数量
|
||||
pub async fn expire_points(db: &sea_orm::DatabaseConnection) -> HealthResult<u64> {
|
||||
let now = Utc::now();
|
||||
|
||||
// 查找所有已过期但未标记 expired 的 earn 交易
|
||||
let expired_txns: Vec<points_transaction::Model> = points_transaction::Entity::find()
|
||||
.filter(points_transaction::Column::Type.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 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(active_txn.version.unwrap() + 1);
|
||||
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();
|
||||
active_account.balance = Set(new_balance);
|
||||
active_account.total_expired = Set(new_expired);
|
||||
active_account.version = Set(active_account.version.unwrap() + 1);
|
||||
active_account.updated_at = Set(Utc::now());
|
||||
let expected_ver: i32 = match &active_account.version {
|
||||
sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v,
|
||||
_ => 0,
|
||||
};
|
||||
let _next_ver = check_version(expected_ver, expected_ver)?;
|
||||
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, "积分过期清理完成");
|
||||
}
|
||||
|
||||
Ok(processed)
|
||||
}
|
||||
|
||||
164
crates/erp-health/src/service/stats_service.rs
Normal file
164
crates/erp-health/src/service/stats_service.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, QuerySelect};
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
|
||||
use crate::dto::stats_dto::*;
|
||||
use crate::entity::{
|
||||
patient, consultation_session, follow_up_task,
|
||||
points_transaction,
|
||||
};
|
||||
use crate::state::HealthState;
|
||||
|
||||
pub async fn get_patient_statistics(
|
||||
state: &HealthState,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<PatientStatisticsResp> {
|
||||
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<ConsultationStatisticsResp> {
|
||||
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<FollowUpStatisticsResp> {
|
||||
let db = &state.db;
|
||||
|
||||
let total_tasks = follow_up_task::Entity::find()
|
||||
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
|
||||
.filter(follow_up_task::Column::DeletedAt.is_null())
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let completed = 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::Status.eq("completed"))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let pending = 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::Status.eq("pending"))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let 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::Status.eq("overdue"))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
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: total_tasks as i64,
|
||||
completed: completed as i64,
|
||||
pending: pending as i64,
|
||||
overdue: overdue as i64,
|
||||
completion_rate,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct AvgResponseTime {
|
||||
avg_minutes: Option<f64>,
|
||||
}
|
||||
|
||||
async fn compute_avg_response_time(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: uuid::Uuid,
|
||||
) -> AppResult<Option<f64>> {
|
||||
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<AvgResponseTime> = 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))
|
||||
}
|
||||
Reference in New Issue
Block a user