refactor(health): 拆分 4 个千行 service 文件为子模块
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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
157
crates/erp-health/src/service/health_data_service/alert.rs
Normal file
157
crates/erp-health/src/service/health_data_service/alert.rs
Normal file
@@ -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<Uuid>,
|
||||
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<serde_json::Value> = 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<Uuid> = 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<serde_json::Value>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PaginatedResponse<HealthRecordResp>> {
|
||||
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<Uuid>,
|
||||
req: CreateHealthRecordReq,
|
||||
) -> HealthResult<HealthRecordResp> {
|
||||
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<Uuid>,
|
||||
req: UpdateHealthRecordReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<HealthRecordResp> {
|
||||
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<Uuid>,
|
||||
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(())
|
||||
}
|
||||
407
crates/erp-health/src/service/health_data_service/lab_report.rs
Normal file
407
crates/erp-health/src/service/health_data_service/lab_report.rs
Normal file
@@ -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<PaginatedResponse<LabReportResp>> {
|
||||
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<Uuid>,
|
||||
req: CreateLabReportReq,
|
||||
) -> HealthResult<LabReportResp> {
|
||||
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<serde_json::Value> {
|
||||
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<Uuid>,
|
||||
req: UpdateLabReportReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<LabReportResp> {
|
||||
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<Uuid>,
|
||||
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<LabReportResp> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
21
crates/erp-health/src/service/health_data_service/mod.rs
Normal file
21
crates/erp-health/src/service/health_data_service/mod.rs
Normal file
@@ -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,
|
||||
};
|
||||
310
crates/erp-health/src/service/health_data_service/vital_signs.rs
Normal file
310
crates/erp-health/src/service/health_data_service/vital_signs.rs
Normal file
@@ -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<PaginatedResponse<VitalSignsResp>> {
|
||||
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<VitalSignsResp> = 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<Uuid>,
|
||||
req: CreateVitalSignsReq,
|
||||
) -> HealthResult<VitalSignsResp> {
|
||||
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<Uuid>,
|
||||
req: UpdateVitalSignsReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<VitalSignsResp> {
|
||||
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<Uuid>,
|
||||
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(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
395
crates/erp-health/src/service/patient_service/crud.rs
Normal file
395
crates/erp-health/src/service/patient_service/crud.rs
Normal file
@@ -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<String>,
|
||||
tag_id: Option<Uuid>,
|
||||
) -> HealthResult<PaginatedResponse<PatientResp>> {
|
||||
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<Vec<Uuid>> = if let Some(tid) = tag_id {
|
||||
let rows: Vec<patient_tag_relation::Model> = 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<Uuid>,
|
||||
req: CreatePatientReq,
|
||||
) -> HealthResult<PatientResp> {
|
||||
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<PatientResp> {
|
||||
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<Uuid>,
|
||||
req: UpdatePatientReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<PatientResp> {
|
||||
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<Uuid>,
|
||||
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(())
|
||||
}
|
||||
91
crates/erp-health/src/service/patient_service/helper.rs
Normal file
91
crates/erp-health/src/service/patient_service/helper.rs
Normal file
@@ -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::Model> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
22
crates/erp-health/src/service/patient_service/mod.rs
Normal file
22
crates/erp-health/src/service/patient_service/mod.rs
Normal file
@@ -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};
|
||||
488
crates/erp-health/src/service/patient_service/relation.rs
Normal file
488
crates/erp-health/src/service/patient_service/relation.rs
Normal file
@@ -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<Uuid>,
|
||||
) -> 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<serde_json::Value> {
|
||||
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<Vec<FamilyMemberResp>> {
|
||||
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<Uuid>,
|
||||
req: FamilyMemberReq,
|
||||
) -> HealthResult<FamilyMemberResp> {
|
||||
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<Uuid>,
|
||||
req: FamilyMemberReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<FamilyMemberResp> {
|
||||
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<Uuid>,
|
||||
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<Uuid>,
|
||||
) -> 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<Uuid>,
|
||||
) -> 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(())
|
||||
}
|
||||
195
crates/erp-health/src/service/patient_service/tag.rs
Normal file
195
crates/erp-health/src/service/patient_service/tag.rs
Normal file
@@ -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<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct TagResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct UpdateTagReq {
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 标签列表
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_tags(
|
||||
state: &crate::state::HealthState,
|
||||
tenant_id: Uuid,
|
||||
) -> HealthResult<Vec<crate::dto::patient_dto::TagResp>> {
|
||||
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<Uuid>,
|
||||
req: CreateTagReq,
|
||||
) -> HealthResult<TagResp> {
|
||||
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<Uuid>,
|
||||
req: UpdateTagReq,
|
||||
) -> HealthResult<TagResp> {
|
||||
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<Uuid>,
|
||||
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(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
250
crates/erp-health/src/service/points_service/account.rs
Normal file
250
crates/erp-health/src/service/points_service/account.rs
Normal file
@@ -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<C: sea_orm::ConnectionTrait>(
|
||||
db: &C,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> HealthResult<points_account::Model> {
|
||||
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<PointsAccountResp> {
|
||||
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<Uuid>,
|
||||
) -> HealthResult<PointsTransactionResp> {
|
||||
// 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<PaginatedResponse<PointsTransactionResp>> {
|
||||
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 })
|
||||
}
|
||||
315
crates/erp-health/src/service/points_service/checkin.rs
Normal file
315
crates/erp-health/src/service/points_service/checkin.rs
Normal file
@@ -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<Uuid>,
|
||||
) -> HealthResult<CheckinStatusResp> {
|
||||
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<CheckinStatusResp> {
|
||||
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<C: sea_orm::ConnectionTrait>(
|
||||
db: &C,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
today: chrono::NaiveDate,
|
||||
) -> HealthResult<i32> {
|
||||
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<C: sea_orm::ConnectionTrait>(
|
||||
db: &C,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
event_type: &str,
|
||||
operator_id: Option<Uuid>,
|
||||
) -> 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<C: sea_orm::ConnectionTrait>(
|
||||
db: &C,
|
||||
tenant_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
consecutive: i32,
|
||||
operator_id: Option<Uuid>,
|
||||
) -> HealthResult<i32> {
|
||||
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<C: sea_orm::ConnectionTrait>(
|
||||
db: &C,
|
||||
tenant_id: Uuid,
|
||||
field: &str,
|
||||
) -> HealthResult<i32> {
|
||||
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<i32> {
|
||||
[7, 14, 30].iter().find(|&&m| m > consecutive).copied()
|
||||
}
|
||||
745
crates/erp-health/src/service/points_service/event.rs
Normal file
745
crates/erp-health/src/service/points_service/event.rs
Normal file
@@ -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<Vec<PointsRuleResp>> {
|
||||
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<Uuid>,
|
||||
req: CreatePointsRuleReq,
|
||||
) -> HealthResult<PointsRuleResp> {
|
||||
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<Uuid>,
|
||||
req: UpdatePointsRuleReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<PointsRuleResp> {
|
||||
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<Uuid>,
|
||||
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<PaginatedResponse<OfflineEventResp>> {
|
||||
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<Uuid>,
|
||||
) -> 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<Uuid>,
|
||||
req: CreateOfflineEventReq,
|
||||
) -> HealthResult<OfflineEventResp> {
|
||||
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<Uuid>,
|
||||
req: UpdateOfflineEventReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<OfflineEventResp> {
|
||||
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<Uuid>,
|
||||
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<String>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<OfflineEventResp>> {
|
||||
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<Uuid>,
|
||||
) -> 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<PointsStatisticsResp> {
|
||||
use sea_orm::FromQueryResult;
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct AggRow {
|
||||
total_issued: Option<i64>,
|
||||
total_spent: Option<i64>,
|
||||
total_expired: Option<i64>,
|
||||
active_accounts: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct TopEarnerRow {
|
||||
id: Uuid,
|
||||
patient_id: Uuid,
|
||||
total_earned: Option<i32>,
|
||||
}
|
||||
|
||||
// 聚合查询:总发放/总消费/总过期/活跃账户数
|
||||
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<u64> {
|
||||
let now = Utc::now();
|
||||
|
||||
// 查找所有已过期但未标记 expired 的 earn 交易
|
||||
let expired_txns: Vec<points_transaction::Model> = 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)
|
||||
}
|
||||
27
crates/erp-health/src/service/points_service/mod.rs
Normal file
27
crates/erp-health/src/service/points_service/mod.rs
Normal file
@@ -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,
|
||||
};
|
||||
616
crates/erp-health/src/service/points_service/product.rs
Normal file
616
crates/erp-health/src/service/points_service/product.rs
Normal file
@@ -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<String>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<PointsProductResp>> {
|
||||
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<String>,
|
||||
is_active: Option<bool>,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> HealthResult<PaginatedResponse<PointsProductResp>> {
|
||||
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<PointsProductResp> {
|
||||
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<Uuid>,
|
||||
req: CreatePointsProductReq,
|
||||
) -> HealthResult<PointsProductResp> {
|
||||
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<Uuid>,
|
||||
req: UpdatePointsProductReq,
|
||||
expected_version: i32,
|
||||
) -> HealthResult<PointsProductResp> {
|
||||
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<Uuid>,
|
||||
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<Uuid>,
|
||||
) -> HealthResult<PointsOrderResp> {
|
||||
// 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<Uuid> = 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<PaginatedResponse<PointsOrderResp>> {
|
||||
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<PaginatedResponse<PointsOrderResp>> {
|
||||
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<PointsOrderResp> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
331
crates/erp-health/src/service/stats_service/dashboard.rs
Normal file
331
crates/erp-health/src/service/stats_service/dashboard.rs
Normal file
@@ -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<ArticleStatsResp> {
|
||||
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<i64>,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = 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<Vec<PointsActivityItem>> {
|
||||
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<String>,
|
||||
amount: String,
|
||||
r#type: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
let rows: Vec<Row> = 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<Vec<ModuleStatusResp>> {
|
||||
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<UserActivityResp> {
|
||||
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<ActivityRow> = 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<RoleRow> = 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<SystemHealthResp> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
320
crates/erp-health/src/service/stats_service/health.rs
Normal file
320
crates/erp-health/src/service/stats_service/health.rs
Normal file
@@ -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<LabReportStatisticsResp> {
|
||||
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<AppointmentStatisticsResp> {
|
||||
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<VitalSignsReportRateResp> {
|
||||
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<HealthDataStatsResp> {
|
||||
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<Vec<NameValue>> {
|
||||
let rows: Vec<NameValueRow> = 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<i64> {
|
||||
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<i64>,
|
||||
}
|
||||
|
||||
let result: Option<AbnormalCount> = 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<u64> {
|
||||
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<DistinctCount> = 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<Vec<DailyReportRate>> {
|
||||
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<DailyRow> = 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())
|
||||
}
|
||||
33
crates/erp-health/src/service/stats_service/mod.rs
Normal file
33
crates/erp-health/src/service/stats_service/mod.rs
Normal file
@@ -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;
|
||||
186
crates/erp-health/src/service/stats_service/operations.rs
Normal file
186
crates/erp-health/src/service/stats_service/operations.rs
Normal file
@@ -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<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;
|
||||
|
||||
// 单次 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<StatusCount> = 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<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))
|
||||
}
|
||||
308
crates/erp-health/src/service/stats_service/personal.rs
Normal file
308
crates/erp-health/src/service/stats_service/personal.rs
Normal file
@@ -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<PersonalStatsResp> {
|
||||
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<Cnt> = 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<StatusCount> = 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<VsCount> = 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<LrCnt> = 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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user