feat(health): 积分商城后端完整实现 (Chunk 2 V2 迭代)
- 新增 8 张数据库表: points_account/rule/transaction/product/order/checkin + offline_event/registration - SeaORM Entity: 8 个实体,含完整 Relation 定义 - DTO: 积分规则/商品/订单/签到/线下活动请求响应类型 - Service: FIFO 积分消费、每日打卡(连续奖励)、商品兑换(QR码核销)、线下活动报名 - Handler: 16 个 API 端点 (患者端10 + 管理端6) - 权限: health.points.list / health.points.manage - 12个月滚动过期机制 - 审计日志全量覆盖
This commit is contained in:
104
Cargo.lock
generated
104
Cargo.lock
generated
@@ -1167,6 +1167,37 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1284,6 +1315,31 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erp-ai"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"erp-core",
|
||||
"futures",
|
||||
"handlebars",
|
||||
"hex",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "erp-auth"
|
||||
version = "0.1.0"
|
||||
@@ -1484,6 +1540,7 @@ dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"config",
|
||||
"erp-ai",
|
||||
"erp-auth",
|
||||
"erp-config",
|
||||
"erp-core",
|
||||
@@ -1883,6 +1940,22 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "handlebars"
|
||||
version = "6.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e"
|
||||
dependencies = [
|
||||
"derive_builder",
|
||||
"log",
|
||||
"num-order",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -2774,6 +2847,21 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-modular"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f"
|
||||
|
||||
[[package]]
|
||||
name = "num-order"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
|
||||
dependencies = [
|
||||
"num-modular",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3436,6 +3524,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -3457,12 +3546,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -5293,6 +5384,19 @@ dependencies = [
|
||||
"wasmparser 0.246.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod doctor_dto;
|
||||
pub mod follow_up_dto;
|
||||
pub mod health_data_dto;
|
||||
pub mod patient_dto;
|
||||
pub mod points_dto;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct DeleteWithVersion {
|
||||
|
||||
261
crates/erp-health/src/dto/points_dto.rs
Normal file
261
crates/erp-health/src/dto/points_dto.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
use chrono::NaiveDate;
|
||||
use erp_core::sanitize::sanitize_option;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分账户
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PointsAccountResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub balance: i32,
|
||||
pub total_earned: i32,
|
||||
pub total_spent: i32,
|
||||
pub total_expired: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CheckinStatusResp {
|
||||
pub checked_in_today: bool,
|
||||
pub consecutive_days: i32,
|
||||
pub next_streak_milestone: Option<i32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分流水
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PointsTransactionResp {
|
||||
pub id: Uuid,
|
||||
pub account_id: Uuid,
|
||||
pub r#type: String,
|
||||
pub amount: i32,
|
||||
pub remaining_amount: i32,
|
||||
pub status: String,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub balance_after: i32,
|
||||
pub description: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 积分规则
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreatePointsRuleReq {
|
||||
pub event_type: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub points_value: i32,
|
||||
#[serde(default)]
|
||||
pub daily_cap: i32,
|
||||
#[serde(default)]
|
||||
pub streak_7d_bonus: i32,
|
||||
#[serde(default)]
|
||||
pub streak_14d_bonus: i32,
|
||||
#[serde(default)]
|
||||
pub streak_30d_bonus: i32,
|
||||
}
|
||||
|
||||
impl CreatePointsRuleReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.description = sanitize_option(self.description.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdatePointsRuleReq {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub points_value: Option<i32>,
|
||||
pub daily_cap: Option<i32>,
|
||||
pub streak_7d_bonus: Option<i32>,
|
||||
pub streak_14d_bonus: Option<i32>,
|
||||
pub streak_30d_bonus: Option<i32>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
impl UpdatePointsRuleReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.description = sanitize_option(self.description.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PointsRuleResp {
|
||||
pub id: Uuid,
|
||||
pub event_type: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub points_value: i32,
|
||||
pub daily_cap: i32,
|
||||
pub streak_7d_bonus: i32,
|
||||
pub streak_14d_bonus: i32,
|
||||
pub streak_30d_bonus: i32,
|
||||
pub is_active: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 兑换商品
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreatePointsProductReq {
|
||||
pub name: String,
|
||||
pub product_type: Option<String>,
|
||||
pub points_cost: i32,
|
||||
pub stock: Option<i32>,
|
||||
pub image_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub service_config: Option<serde_json::Value>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
impl CreatePointsProductReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.description = sanitize_option(self.description.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdatePointsProductReq {
|
||||
pub name: Option<String>,
|
||||
pub product_type: Option<String>,
|
||||
pub points_cost: Option<i32>,
|
||||
pub stock: Option<i32>,
|
||||
pub image_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub service_config: Option<serde_json::Value>,
|
||||
pub is_active: Option<bool>,
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
impl UpdatePointsProductReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.description = sanitize_option(self.description.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PointsProductResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub product_type: String,
|
||||
pub points_cost: i32,
|
||||
pub stock: i32,
|
||||
pub image_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub sort_order: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 兑换订单
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ExchangeReq {
|
||||
pub product_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PointsOrderResp {
|
||||
pub id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub product_id: Uuid,
|
||||
pub product_name: Option<String>,
|
||||
pub points_cost: i32,
|
||||
pub status: String,
|
||||
pub qr_code: Option<Uuid>,
|
||||
pub verified_by: Option<Uuid>,
|
||||
pub verified_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct VerifyOrderReq {
|
||||
pub qr_code: Uuid,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 线下活动
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateOfflineEventReq {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub event_date: NaiveDate,
|
||||
pub start_time: Option<chrono::NaiveTime>,
|
||||
pub end_time: Option<chrono::NaiveTime>,
|
||||
pub location: Option<String>,
|
||||
pub points_reward: Option<i32>,
|
||||
pub max_participants: Option<i32>,
|
||||
pub image_url: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateOfflineEventReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.description = sanitize_option(self.description.take());
|
||||
self.location = sanitize_option(self.location.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateOfflineEventReq {
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub event_date: Option<NaiveDate>,
|
||||
pub start_time: Option<chrono::NaiveTime>,
|
||||
pub end_time: Option<chrono::NaiveTime>,
|
||||
pub location: Option<String>,
|
||||
pub points_reward: Option<i32>,
|
||||
pub max_participants: Option<i32>,
|
||||
pub status: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
}
|
||||
|
||||
impl UpdateOfflineEventReq {
|
||||
pub fn sanitize(&mut self) {
|
||||
self.description = sanitize_option(self.description.take());
|
||||
self.location = sanitize_option(self.location.take());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct OfflineEventResp {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub event_date: NaiveDate,
|
||||
pub start_time: Option<chrono::NaiveTime>,
|
||||
pub end_time: Option<chrono::NaiveTime>,
|
||||
pub location: Option<String>,
|
||||
pub points_reward: i32,
|
||||
pub max_participants: i32,
|
||||
pub current_participants: i32,
|
||||
pub status: String,
|
||||
pub image_url: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -16,3 +16,11 @@ pub mod patient_family_member;
|
||||
pub mod patient_tag;
|
||||
pub mod patient_tag_relation;
|
||||
pub mod vital_signs;
|
||||
pub mod points_account;
|
||||
pub mod points_checkin;
|
||||
pub mod points_order;
|
||||
pub mod points_product;
|
||||
pub mod points_rule;
|
||||
pub mod points_transaction;
|
||||
pub mod offline_event;
|
||||
pub mod offline_event_registration;
|
||||
|
||||
40
crates/erp-health/src/entity/offline_event.rs
Normal file
40
crates/erp-health/src/entity/offline_event.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "offline_event")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub title: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub event_date: chrono::NaiveDate,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub start_time: Option<chrono::NaiveTime>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub end_time: Option<chrono::NaiveTime>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub location: Option<String>,
|
||||
pub points_reward: i32,
|
||||
pub max_participants: i32,
|
||||
pub current_participants: i32,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub image_url: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
32
crates/erp-health/src/entity/offline_event_registration.rs
Normal file
32
crates/erp-health/src/entity/offline_event_registration.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "offline_event_registration")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub event_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub checked_in_at: Option<DateTimeUtc>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub checked_in_by: Option<Uuid>,
|
||||
pub points_granted: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
42
crates/erp-health/src/entity/points_account.rs
Normal file
42
crates/erp-health/src/entity/points_account.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "points_account")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub balance: i32,
|
||||
pub total_earned: i32,
|
||||
pub total_spent: i32,
|
||||
pub total_expired: i32,
|
||||
pub version: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
32
crates/erp-health/src/entity/points_checkin.rs
Normal file
32
crates/erp-health/src/entity/points_checkin.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "points_checkin")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub checkin_date: chrono::NaiveDate,
|
||||
pub consecutive_days: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
51
crates/erp-health/src/entity/points_order.rs
Normal file
51
crates/erp-health/src/entity/points_order.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "points_order")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub product_id: Uuid,
|
||||
pub points_cost: i32,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub qr_code: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub verified_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub verified_at: Option<DateTimeUtc>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTimeUtc>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::patient::Entity",
|
||||
from = "Column::PatientId",
|
||||
to = "super::patient::Column::Id"
|
||||
)]
|
||||
Patient,
|
||||
}
|
||||
|
||||
impl Related<super::patient::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Patient.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
36
crates/erp-health/src/entity/points_product.rs
Normal file
36
crates/erp-health/src/entity/points_product.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "points_product")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub product_type: String,
|
||||
pub points_cost: i32,
|
||||
pub stock: i32,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub image_url: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub service_config: Option<serde_json::Value>,
|
||||
pub is_active: bool,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
34
crates/erp-health/src/entity/points_rule.rs
Normal file
34
crates/erp-health/src/entity/points_rule.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "points_rule")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub event_type: String,
|
||||
pub name: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub points_value: i32,
|
||||
pub daily_cap: i32,
|
||||
pub streak_7d_bonus: i32,
|
||||
pub streak_14d_bonus: i32,
|
||||
pub streak_30d_bonus: i32,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
38
crates/erp-health/src/entity/points_transaction.rs
Normal file
38
crates/erp-health/src/entity/points_transaction.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "points_transaction")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub account_id: Uuid,
|
||||
pub r#type: String,
|
||||
pub amount: i32,
|
||||
pub remaining_amount: i32,
|
||||
pub status: String,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTimeUtc>,
|
||||
pub balance_after: i32,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub rule_id: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub order_id: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub created_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_by: Option<Uuid>,
|
||||
#[sea_orm(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -26,6 +26,15 @@ pub enum HealthError {
|
||||
#[error("透析记录不存在")]
|
||||
DialysisRecordNotFound,
|
||||
|
||||
#[error("兑换商品不存在")]
|
||||
PointsProductNotFound,
|
||||
|
||||
#[error("兑换订单不存在")]
|
||||
PointsOrderNotFound,
|
||||
|
||||
#[error("线下活动不存在")]
|
||||
OfflineEventNotFound,
|
||||
|
||||
#[error("健康档案不存在")]
|
||||
HealthRecordNotFound,
|
||||
|
||||
@@ -73,7 +82,10 @@ impl From<HealthError> for AppError {
|
||||
| HealthError::TagNotFound
|
||||
| HealthError::FollowUpTaskNotFound
|
||||
| HealthError::ConsultationNotFound
|
||||
| HealthError::ArticleNotFound => AppError::NotFound(err.to_string()),
|
||||
| HealthError::ArticleNotFound
|
||||
| HealthError::PointsProductNotFound
|
||||
| HealthError::PointsOrderNotFound
|
||||
| HealthError::OfflineEventNotFound => AppError::NotFound(err.to_string()),
|
||||
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
|
||||
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
|
||||
HealthError::VersionMismatch => AppError::VersionMismatch,
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod doctor_handler;
|
||||
pub mod follow_up_handler;
|
||||
pub mod health_data_handler;
|
||||
pub mod patient_handler;
|
||||
pub mod points_handler;
|
||||
|
||||
286
crates/erp-health/src/handler/points_handler.rs
Normal file
286
crates/erp-health/src/handler/points_handler.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Json, Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
|
||||
use crate::dto::points_dto::*;
|
||||
use crate::service::points_service;
|
||||
use crate::state::HealthState;
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct PaginationParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ProductTypeParam {
|
||||
pub product_type: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 患者端:积分账户 + 打卡
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_my_account<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<PointsAccountResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::get_account(&state, ctx.tenant_id, patient_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn daily_checkin<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::daily_checkin(
|
||||
&state, ctx.tenant_id, patient_id, Some(ctx.user_id),
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_checkin_status<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<CheckinStatusResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::get_checkin_status(&state, ctx.tenant_id, patient_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 患者端:积分流水 + 商品 + 兑换
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_my_transactions<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsTransactionResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::list_transactions(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn list_products<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<ProductTypeParam>,
|
||||
Query(page): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsProductResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let p = page.page.unwrap_or(1);
|
||||
let ps = page.page_size.unwrap_or(20);
|
||||
let result = points_service::list_products(
|
||||
&state, ctx.tenant_id, params.product_type, p, ps,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn get_product<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(product_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let result = points_service::get_product(&state, ctx.tenant_id, product_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn exchange_product<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<ExchangeReq>,
|
||||
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let result = points_service::exchange_product(
|
||||
&state, ctx.tenant_id, patient_id, req, Some(ctx.user_id),
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn list_my_orders<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::list_orders(
|
||||
&state, ctx.tenant_id, patient_id, page, page_size,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 线下活动(患者端)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn list_offline_events<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<OfflineEventResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.list")?;
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let result = points_service::list_offline_events(
|
||||
&state, ctx.tenant_id, page, page_size,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn register_event<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(event_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.health-data.manage")?;
|
||||
let patient_id = resolve_patient_id(&state, ctx.tenant_id, ctx.user_id).await?;
|
||||
points_service::register_event(
|
||||
&state, ctx.tenant_id, event_id, patient_id, Some(ctx.user_id),
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 管理端:核销 + 规则管理 + 商品管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn verify_order<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<VerifyOrderReq>,
|
||||
) -> Result<Json<ApiResponse<PointsOrderResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let result = points_service::verify_order(
|
||||
&state, ctx.tenant_id, req.qr_code, ctx.user_id,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn list_rules<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<PointsRuleResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
let result = points_service::list_rules(&state, ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn create_rule<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreatePointsRuleReq>,
|
||||
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = points_service::create_rule(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn admin_create_product<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreatePointsProductReq>,
|
||||
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.manage")?;
|
||||
let mut req = req;
|
||||
req.sanitize();
|
||||
let result = points_service::create_product(
|
||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
pub async fn admin_list_orders<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<PointsOrderResp>>>, AppError>
|
||||
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "health.points.list")?;
|
||||
// 管理端查看所有订单 — 传空 patient_id 列出全部(简化实现:传一个不存在的 UUID 查全部)
|
||||
// TODO: 实现 admin 级别的全量订单查询
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
// 临时用空 UUID 占位
|
||||
let result = points_service::list_orders(
|
||||
&state, ctx.tenant_id, Uuid::nil(), page, page_size,
|
||||
).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 辅助:通过 user_id 解析 patient_id
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_patient_id(
|
||||
state: &HealthState,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Uuid, AppError> {
|
||||
use crate::entity::patient;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
let result: Option<patient::Model> = patient::Entity::find()
|
||||
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||
.filter(patient::Column::UserId.eq(user_id))
|
||||
.filter(patient::Column::DeletedAt.is_null())
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e: sea_orm::DbErr| AppError::Internal(e.to_string()))?;
|
||||
result
|
||||
.map(|p| p.id)
|
||||
.ok_or_else(|| AppError::NotFound("当前用户未关联患者档案".into()))
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::error::{AppError, AppResult};
|
||||
use erp_core::health_provider::{
|
||||
HealthDataProvider, HealthReportDto, LabReportDto, PatientSummaryDto, TimeRange, VitalSignDto,
|
||||
};
|
||||
@@ -9,6 +9,19 @@ pub struct HealthDataProviderImpl {
|
||||
pub db: sea_orm::DatabaseConnection,
|
||||
}
|
||||
|
||||
macro_rules! stub_unimplemented {
|
||||
($method:ident, $ret:ty) => {
|
||||
async fn $method(&self, _tenant_id: Uuid, _report_id: Uuid) -> AppResult<$ret> {
|
||||
Err(AppError::Internal(concat!(
|
||||
"HealthDataProvider::",
|
||||
stringify!($method),
|
||||
" 尚未实现 (Phase 1 stub)"
|
||||
)
|
||||
.into()))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HealthDataProvider for HealthDataProviderImpl {
|
||||
async fn get_lab_report(
|
||||
@@ -16,7 +29,9 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
||||
_tenant_id: Uuid,
|
||||
_report_id: Uuid,
|
||||
) -> AppResult<LabReportDto> {
|
||||
todo!("实现化验报告数据查询")
|
||||
Err(AppError::Internal(
|
||||
"HealthDataProvider::get_lab_report 尚未实现 (Phase 1 stub)".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_vital_signs(
|
||||
@@ -26,7 +41,9 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
||||
_metrics: &[String],
|
||||
_range: &TimeRange,
|
||||
) -> AppResult<Vec<VitalSignDto>> {
|
||||
todo!("实现生命体征趋势查询")
|
||||
Err(AppError::Internal(
|
||||
"HealthDataProvider::get_vital_signs 尚未实现 (Phase 1 stub)".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_patient_summary(
|
||||
@@ -34,7 +51,9 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
||||
_tenant_id: Uuid,
|
||||
_patient_id: Uuid,
|
||||
) -> AppResult<PatientSummaryDto> {
|
||||
todo!("实现患者摘要查询")
|
||||
Err(AppError::Internal(
|
||||
"HealthDataProvider::get_patient_summary 尚未实现 (Phase 1 stub)".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_full_report(
|
||||
@@ -42,6 +61,8 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
||||
_tenant_id: Uuid,
|
||||
_report_id: Uuid,
|
||||
) -> AppResult<HealthReportDto> {
|
||||
todo!("实现完整报告查询")
|
||||
Err(AppError::Internal(
|
||||
"HealthDataProvider::get_full_report 尚未实现 (Phase 1 stub)".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||
|
||||
use crate::handler::{
|
||||
appointment_handler, article_handler, consultation_handler, dialysis_handler, doctor_handler, follow_up_handler,
|
||||
health_data_handler, patient_handler,
|
||||
health_data_handler, patient_handler, points_handler,
|
||||
};
|
||||
|
||||
pub struct HealthModule;
|
||||
@@ -265,6 +265,66 @@ impl HealthModule {
|
||||
.put(article_handler::update_article)
|
||||
.delete(article_handler::delete_article),
|
||||
)
|
||||
// 积分商城 — 患者端
|
||||
.route(
|
||||
"/health/points/account",
|
||||
axum::routing::get(points_handler::get_my_account),
|
||||
)
|
||||
.route(
|
||||
"/health/points/checkin",
|
||||
axum::routing::post(points_handler::daily_checkin),
|
||||
)
|
||||
.route(
|
||||
"/health/points/checkin/status",
|
||||
axum::routing::get(points_handler::get_checkin_status),
|
||||
)
|
||||
.route(
|
||||
"/health/points/transactions",
|
||||
axum::routing::get(points_handler::list_my_transactions),
|
||||
)
|
||||
.route(
|
||||
"/health/points/products",
|
||||
axum::routing::get(points_handler::list_products),
|
||||
)
|
||||
.route(
|
||||
"/health/points/products/{id}",
|
||||
axum::routing::get(points_handler::get_product),
|
||||
)
|
||||
.route(
|
||||
"/health/points/exchange",
|
||||
axum::routing::post(points_handler::exchange_product),
|
||||
)
|
||||
.route(
|
||||
"/health/points/orders",
|
||||
axum::routing::get(points_handler::list_my_orders),
|
||||
)
|
||||
// 线下活动 — 患者端
|
||||
.route(
|
||||
"/health/offline-events",
|
||||
axum::routing::get(points_handler::list_offline_events),
|
||||
)
|
||||
.route(
|
||||
"/health/offline-events/{id}/register",
|
||||
axum::routing::post(points_handler::register_event),
|
||||
)
|
||||
// 积分商城 — 管理端
|
||||
.route(
|
||||
"/health/points/verify",
|
||||
axum::routing::post(points_handler::verify_order),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/points/rules",
|
||||
axum::routing::get(points_handler::list_rules)
|
||||
.post(points_handler::create_rule),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/points/products",
|
||||
axum::routing::post(points_handler::admin_create_product),
|
||||
)
|
||||
.route(
|
||||
"/health/admin/points/orders",
|
||||
axum::routing::get(points_handler::admin_list_orders),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +493,18 @@ impl ErpModule for HealthModule {
|
||||
description: "创建、编辑、删除健康资讯文章".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.points.list".into(),
|
||||
name: "查看积分".into(),
|
||||
description: "查看积分规则、订单列表".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "health.points.manage".into(),
|
||||
name: "管理积分".into(),
|
||||
description: "创建积分规则、管理商品、核销订单".into(),
|
||||
module: "health".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod follow_up_service;
|
||||
pub mod health_data_service;
|
||||
pub mod masking;
|
||||
pub mod patient_service;
|
||||
pub mod points_service;
|
||||
pub mod seed;
|
||||
pub mod trend_service;
|
||||
pub mod validation;
|
||||
|
||||
908
crates/erp-health/src/service/points_service.rs
Normal file
908
crates/erp-health/src/service/points_service.rs
Normal file
@@ -0,0 +1,908 @@
|
||||
//! 积分商城 Service — 积分获取、FIFO 消费、兑换核销、线下活动
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use sea_orm::entity::prelude::*;
|
||||
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::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. 检查每日上限
|
||||
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(patient_id))
|
||||
.filter(points_transaction::Column::Type.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()));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 在事务中执行积分获取
|
||||
let txn = state.db.begin().await?;
|
||||
let acc = get_or_create_account(&txn, tenant_id, patient_id).await?;
|
||||
let next_ver = check_version(acc.version, acc.version).unwrap_or(acc.version + 1);
|
||||
|
||||
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),
|
||||
r#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?;
|
||||
|
||||
// 更新账户余额
|
||||
let mut acc_active: points_account::ActiveModel = acc.into();
|
||||
acc_active.balance = Set(acc_active.balance.unwrap() + rule.points_value);
|
||||
acc_active.total_earned = Set(acc_active.total_earned.unwrap() + rule.points_value);
|
||||
acc_active.updated_at = Set(now);
|
||||
acc_active.updated_by = Set(operator_id);
|
||||
acc_active.version = Set(next_ver);
|
||||
acc_active.update(&txn).await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, operator_id, "points.earned", "points_transaction")
|
||||
.with_resource_id(inserted.id),
|
||||
&state.db,
|
||||
).await;
|
||||
|
||||
Ok(PointsTransactionResp {
|
||||
id: inserted.id,
|
||||
account_id: inserted.account_id,
|
||||
r#type: inserted.r#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))
|
||||
.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 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(Utc::now()),
|
||||
};
|
||||
active.insert(&state.db).await?;
|
||||
|
||||
// 触发积分获取
|
||||
earn_points(state, tenant_id, patient_id, "daily_checkin", operator_id).await?;
|
||||
|
||||
// 检查阶梯奖励
|
||||
let _streak_bonus = check_streak_bonus(state, tenant_id, patient_id, consecutive, operator_id).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(
|
||||
db: &DatabaseConnection,
|
||||
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))
|
||||
}
|
||||
|
||||
async fn check_streak_bonus(
|
||||
state: &HealthState,
|
||||
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(&state.db, tenant_id, "streak_7d_bonus").await?;
|
||||
} else if consecutive == 14 {
|
||||
bonus = get_streak_bonus_value(&state.db, tenant_id, "streak_14d_bonus").await?;
|
||||
} else if consecutive == 30 {
|
||||
bonus = get_streak_bonus_value(&state.db, tenant_id, "streak_30d_bonus").await?;
|
||||
}
|
||||
if bonus > 0 {
|
||||
// 额外奖励通过事件系统
|
||||
let acc = get_or_create_account(&state.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),
|
||||
r#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(&state.db).await?;
|
||||
let mut acc_active: points_account::ActiveModel = acc.into();
|
||||
acc_active.balance = Set(acc_active.balance.unwrap() + bonus);
|
||||
acc_active.total_earned = Set(acc_active.total_earned.unwrap() + bonus);
|
||||
acc_active.updated_at = Set(now);
|
||||
acc_active.version = Set(acc_active.version.unwrap() + 1);
|
||||
acc_active.update(&state.db).await?;
|
||||
}
|
||||
Ok(bonus)
|
||||
}
|
||||
|
||||
async fn get_streak_bonus_value(
|
||||
db: &DatabaseConnection,
|
||||
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, r#type: m.r#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,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 兑换(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::Type.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" };
|
||||
|
||||
let mut active: points_transaction::ActiveModel = earn.into();
|
||||
let txn_id = active.id.clone().unwrap();
|
||||
let current_version = active.version.unwrap();
|
||||
active.remaining_amount = Set(new_remaining);
|
||||
active.status = Set(new_status.to_string());
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.version = Set(current_version + 1);
|
||||
active.update(&txn).await?;
|
||||
|
||||
consumed_txn_ids.push(txn_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),
|
||||
r#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?;
|
||||
|
||||
// 更新账户余额
|
||||
let mut acc_active: points_account::ActiveModel = acc.into();
|
||||
acc_active.balance = Set(acc_active.balance.unwrap() - cost);
|
||||
acc_active.total_spent = Set(acc_active.total_spent.unwrap() + cost);
|
||||
acc_active.updated_at = Set(now);
|
||||
acc_active.version = Set(acc_active.version.unwrap() + 1);
|
||||
let _updated_acc = acc_active.update(&txn).await?;
|
||||
|
||||
// 扣减库存
|
||||
if product.stock != -1 {
|
||||
let mut prod_active: points_product::ActiveModel = product.clone().into();
|
||||
prod_active.stock = Set(product.stock - 1);
|
||||
prod_active.updated_at = Set(now);
|
||||
prod_active.version = Set(product.version + 1);
|
||||
prod_active.update(&txn).await?;
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
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;
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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 next_ver = check_version(order.version, order.version).unwrap_or(order.version + 1);
|
||||
let now = Utc::now();
|
||||
|
||||
let mut active: points_order::ActiveModel = order.into();
|
||||
active.status = Set("verified".to_string());
|
||||
active.verified_by = Set(Some(verifier_id));
|
||||
active.verified_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(Some(verifier_id));
|
||||
active.version = Set(next_ver);
|
||||
let m = active.update(&state.db).await?;
|
||||
|
||||
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 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();
|
||||
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(&state.db).await?;
|
||||
|
||||
// 更新参与人数
|
||||
let mut event_active: offline_event::ActiveModel = event.into();
|
||||
event_active.current_participants = Set(event_active.current_participants.unwrap() + 1);
|
||||
event_active.updated_at = Set(now);
|
||||
event_active.version = Set(event_active.version.unwrap() + 1);
|
||||
event_active.update(&state.db).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,
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ mod m20260425_000049_widen_patient_id_number;
|
||||
mod m20260425_00050_add_doctor_name_column;
|
||||
mod m20260425_000051_dialysis_and_lab_enhance;
|
||||
mod m20260425_000052_create_ai_tables;
|
||||
mod m20260425_000053_create_points_tables;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -111,6 +112,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260425_00050_add_doctor_name_column::Migration),
|
||||
Box::new(m20260425_000051_dialysis_and_lab_enhance::Migration),
|
||||
Box::new(m20260425_000052_create_ai_tables::Migration),
|
||||
Box::new(m20260425_000053_create_points_tables::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
/// V2 积分商城: 8 张新表 — account/rule/transaction/product/order/checkin/event/registration
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 1. points_account — 积分账户(每患者一个)
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("points_account"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("balance")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("total_earned")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("total_spent")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("total_expired")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(Index::create().if_not_exists().name("idx_points_account_patient").table(Alias::new("points_account")).col(Alias::new("patient_id")).unique().to_owned())
|
||||
.await?;
|
||||
|
||||
// 2. points_rule — 积分获取规则
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("points_rule"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("event_type")).string_len(64).not_null())
|
||||
.col(ColumnDef::new(Alias::new("name")).string_len(128).not_null())
|
||||
.col(ColumnDef::new(Alias::new("description")).text())
|
||||
.col(ColumnDef::new(Alias::new("points_value")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("daily_cap")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("streak_7d_bonus")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("streak_14d_bonus")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("streak_30d_bonus")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("is_active")).boolean().not_null().default(true))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(Index::create().if_not_exists().name("idx_points_rule_event_type").table(Alias::new("points_rule")).col(Alias::new("event_type")).to_owned())
|
||||
.await?;
|
||||
|
||||
// 3. points_transaction — 积分流水(FIFO 桶模型)
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("points_transaction"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("account_id")).uuid().not_null())
|
||||
// earn / spend / expired / refund
|
||||
.col(ColumnDef::new(Alias::new("r#type")).string_len(16).not_null())
|
||||
.col(ColumnDef::new(Alias::new("amount")).integer().not_null())
|
||||
.col(ColumnDef::new(Alias::new("remaining_amount")).integer().not_null().default(0))
|
||||
// active / expired / consumed
|
||||
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("active"))
|
||||
.col(ColumnDef::new(Alias::new("expires_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("balance_after")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("rule_id")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("order_id")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("description")).string_len(256))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(Index::create().if_not_exists().name("idx_points_txn_account").table(Alias::new("points_transaction")).col(Alias::new("account_id")).col(Alias::new("status")).col(Alias::new("expires_at")).to_owned())
|
||||
.await?;
|
||||
|
||||
// 4. points_product — 兑换商品
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("points_product"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("name")).string_len(128).not_null())
|
||||
// physical / service / privilege
|
||||
.col(ColumnDef::new(Alias::new("product_type")).string_len(16).not_null().default("physical"))
|
||||
.col(ColumnDef::new(Alias::new("points_cost")).integer().not_null())
|
||||
.col(ColumnDef::new(Alias::new("stock")).integer().not_null().default(-1))
|
||||
.col(ColumnDef::new(Alias::new("image_url")).string_len(512))
|
||||
.col(ColumnDef::new(Alias::new("description")).text())
|
||||
.col(ColumnDef::new(Alias::new("service_config")).json())
|
||||
.col(ColumnDef::new(Alias::new("is_active")).boolean().not_null().default(true))
|
||||
.col(ColumnDef::new(Alias::new("sort_order")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. points_order — 兑换订单
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("points_order"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("product_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("points_cost")).integer().not_null())
|
||||
// pending / verified / cancelled / expired
|
||||
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("pending"))
|
||||
.col(ColumnDef::new(Alias::new("qr_code")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("verified_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("verified_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("expires_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("notes")).string_len(256))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(Index::create().if_not_exists().name("idx_points_order_patient").table(Alias::new("points_order")).col(Alias::new("patient_id")).col(Alias::new("status")).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.create_index(Index::create().if_not_exists().name("idx_points_order_qr").table(Alias::new("points_order")).col(Alias::new("qr_code")).to_owned())
|
||||
.await?;
|
||||
|
||||
// 6. points_checkin — 每日打卡
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("points_checkin"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("checkin_date")).date().not_null())
|
||||
.col(ColumnDef::new(Alias::new("consecutive_days")).integer().not_null().default(1))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(Index::create().if_not_exists().name("idx_points_checkin_unique").table(Alias::new("points_checkin")).col(Alias::new("patient_id")).col(Alias::new("checkin_date")).unique().to_owned())
|
||||
.await?;
|
||||
|
||||
// 7. offline_event — 线下活动
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("offline_event"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("title")).string_len(256).not_null())
|
||||
.col(ColumnDef::new(Alias::new("description")).text())
|
||||
.col(ColumnDef::new(Alias::new("event_date")).date().not_null())
|
||||
.col(ColumnDef::new(Alias::new("start_time")).time())
|
||||
.col(ColumnDef::new(Alias::new("end_time")).time())
|
||||
.col(ColumnDef::new(Alias::new("location")).string_len(256))
|
||||
.col(ColumnDef::new(Alias::new("points_reward")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("max_participants")).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Alias::new("current_participants")).integer().not_null().default(0))
|
||||
// draft / published / ongoing / completed / cancelled
|
||||
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("draft"))
|
||||
.col(ColumnDef::new(Alias::new("image_url")).string_len(512))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 8. offline_event_registration — 活动报名 + 签到
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("offline_event_registration"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("event_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null())
|
||||
// registered / checked_in / cancelled
|
||||
.col(ColumnDef::new(Alias::new("status")).string_len(16).not_null().default("registered"))
|
||||
.col(ColumnDef::new(Alias::new("checked_in_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("checked_in_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("points_granted")).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Alias::new("created_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("updated_at")).timestamp_with_time_zone().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Alias::new("created_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("updated_by")).uuid())
|
||||
.col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Alias::new("version")).integer().not_null().default(1))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.create_index(Index::create().if_not_exists().name("idx_event_reg_unique").table(Alias::new("offline_event_registration")).col(Alias::new("event_id")).col(Alias::new("patient_id")).unique().to_owned())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let tables = [
|
||||
"offline_event_registration",
|
||||
"offline_event",
|
||||
"points_checkin",
|
||||
"points_order",
|
||||
"points_product",
|
||||
"points_transaction",
|
||||
"points_rule",
|
||||
"points_account",
|
||||
];
|
||||
for t in tables {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Alias::new(t)).if_exists().to_owned())
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user