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:
iven
2026-05-04 14:09:02 +08:00
parent d68c7be098
commit b235f67c31
24 changed files with 5444 additions and 5154 deletions

File diff suppressed because it is too large Load Diff

View 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;
}
}
}

View File

@@ -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(())
}

View 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,
})
}

View 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,
};

View 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

View 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(())
}

View 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,
}
}

View 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};

View 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(())
}

View 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

View 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 })
}

View 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()
}

View 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(&reg_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(&reg_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)
}

View 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,
};

View 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

View 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(),
})
}

View 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())
}

View 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;

View 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))
}

View 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),
})
}