- erp-core 添加 build_event_payload(),自动注入 schema_version + occurred_at - erp-health 12 个 service(25 处)、erp-auth(1 处)、erp-workflow(2 处) 全部迁移到统一信封格式
1806 lines
66 KiB
Rust
1806 lines
66 KiB
Rust
//! 积分商城 Service — 积分获取、FIFO 消费、兑换核销、线下活动
|
||
|
||
use chrono::{Duration, Utc};
|
||
use sea_orm::entity::prelude::*;
|
||
use sea_orm::sea_query::Expr;
|
||
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait};
|
||
use uuid::Uuid;
|
||
|
||
use erp_core::audit::AuditLog;
|
||
use erp_core::audit_service;
|
||
use erp_core::error::check_version;
|
||
use erp_core::events::DomainEvent;
|
||
use erp_core::types::PaginatedResponse;
|
||
|
||
use crate::dto::points_dto::*;
|
||
use crate::entity::{
|
||
offline_event, offline_event_registration, points_account, points_checkin,
|
||
points_order, points_product, points_rule, points_transaction,
|
||
};
|
||
use crate::error::{HealthError, HealthResult};
|
||
use crate::state::HealthState;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 积分账户
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 获取或创建患者的积分账户(支持事务和非事务连接)
|
||
async fn get_or_create_account<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?)
|
||
}
|
||
|
||
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).unwrap().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,
|
||
}))),
|
||
&state.db,
|
||
).await;
|
||
|
||
Ok(PointsTransactionResp {
|
||
id: inserted.id,
|
||
account_id: inserted.account_id,
|
||
transaction_type: inserted.transaction_type,
|
||
amount: inserted.amount,
|
||
remaining_amount: inserted.remaining_amount,
|
||
status: inserted.status,
|
||
expires_at: inserted.expires_at,
|
||
balance_after: inserted.balance_after,
|
||
description: inserted.description,
|
||
created_at: inserted.created_at,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 每日打卡
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub async fn daily_checkin(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
patient_id: Uuid,
|
||
operator_id: Option<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),
|
||
})
|
||
}
|
||
|
||
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).unwrap().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()
|
||
}
|
||
|
||
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),
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 积分流水查询
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 })
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 商品管理
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 })
|
||
}
|
||
|
||
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(active.version.unwrap() + 1);
|
||
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,
|
||
}))),
|
||
&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,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 积分规则管理
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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(active.version.unwrap() + 1);
|
||
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(active.version.unwrap() + 1);
|
||
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(reg_active.version.unwrap() + 1);
|
||
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(reg_active2.version.unwrap() + 1);
|
||
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(active_txn.version.unwrap() + 1);
|
||
active_txn.updated_at = Set(Utc::now());
|
||
active_txn.update(txn_db).await?;
|
||
|
||
// 扣减账户余额,更新 total_expired
|
||
let account = points_account::Entity::find_by_id(account_id)
|
||
.one(txn_db)
|
||
.await?
|
||
.ok_or_else(|| HealthError::Validation("积分账户不存在".to_string()))?;
|
||
|
||
let new_balance = (account.balance - remaining).max(0);
|
||
let new_expired = account.total_expired + remaining;
|
||
|
||
let mut active_account: points_account::ActiveModel = account.into();
|
||
active_account.balance = Set(new_balance);
|
||
active_account.total_expired = Set(new_expired);
|
||
active_account.version = Set(active_account.version.unwrap() + 1);
|
||
active_account.updated_at = Set(Utc::now());
|
||
let expected_ver: i32 = match &active_account.version {
|
||
sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v,
|
||
_ => 0,
|
||
};
|
||
let _next_ver = check_version(expected_ver, expected_ver)?;
|
||
active_account.update(txn_db).await?;
|
||
|
||
Ok(())
|
||
})
|
||
})
|
||
.await;
|
||
|
||
match txn_result {
|
||
Ok(()) => {
|
||
processed += 1;
|
||
tracing::debug!(txn_id = %txn_id, remaining = remaining, "积分过期处理完成");
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(txn_id = %txn_id, error = %e, "积分过期处理失败,跳过");
|
||
}
|
||
}
|
||
}
|
||
|
||
if processed > 0 {
|
||
tracing::info!(count = processed, "积分过期清理完成");
|
||
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)
|
||
}
|