diff --git a/Cargo.toml b/Cargo.toml index 1e630c9..bb0a224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ members = [ "crates/erp-plugin-itops", "crates/erp-health", "crates/erp-ai", - "crates/erp-points", "crates/erp-plugin-assessment", "crates/erp-dialysis", ] @@ -106,7 +105,6 @@ erp-config = { path = "crates/erp-config" } erp-plugin = { path = "crates/erp-plugin" } erp-health = { path = "crates/erp-health" } erp-ai = { path = "crates/erp-ai" } -erp-points = { path = "crates/erp-points" } erp-dialysis = { path = "crates/erp-dialysis" } # Async streaming diff --git a/crates/erp-dialysis/src/event.rs b/crates/erp-dialysis/src/event.rs index 0faa924..ed689b1 100644 --- a/crates/erp-dialysis/src/event.rs +++ b/crates/erp-dialysis/src/event.rs @@ -2,5 +2,6 @@ use erp_core::events::EventBus; /// 预留事件处理器注册 pub fn register_handlers_with_state(_state: crate::state::DialysisState) { - // 透析模块事件消费者待后续迭代 + // 透析业务事件由 erp-health 统一消费(见 erp-health/src/event.rs:425 dialysis_notifier) + // 透析模块自身暂无独立的事件处理需求 } diff --git a/crates/erp-health/src/event.rs b/crates/erp-health/src/event.rs index 505ac7c..7baebca 100644 --- a/crates/erp-health/src/event.rs +++ b/crates/erp-health/src/event.rs @@ -47,6 +47,7 @@ pub const HEALTH_DATA_CRITICAL_ALERT: &str = "health_data.critical_alert"; // 患者 pub const PATIENT_CREATED: &str = "patient.created"; pub const PATIENT_UPDATED: &str = "patient.updated"; +// TODO: 以下常量对应的患者认证和死亡记录流程尚未实现,待后续迭代 pub const PATIENT_VERIFIED: &str = "patient.verified"; pub const PATIENT_DECEASED: &str = "patient.deceased"; diff --git a/crates/erp-points/Cargo.toml b/crates/erp-points/Cargo.toml deleted file mode 100644 index cb1ea3d..0000000 --- a/crates/erp-points/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "erp-points" -version.workspace = true -edition.workspace = true - -[dependencies] -erp-core.workspace = true -tokio.workspace = true -serde.workspace = true -serde_json.workspace = true -uuid.workspace = true -chrono.workspace = true -axum.workspace = true -sea-orm.workspace = true -tracing.workspace = true -thiserror.workspace = true -validator.workspace = true -utoipa.workspace = true -async-trait.workspace = true diff --git a/crates/erp-points/src/dto/mod.rs b/crates/erp-points/src/dto/mod.rs deleted file mode 100644 index c29ed33..0000000 --- a/crates/erp-points/src/dto/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod points_dto; - -#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] -pub struct DeleteWithVersion { - pub version: i32, -} diff --git a/crates/erp-points/src/dto/points_dto.rs b/crates/erp-points/src/dto/points_dto.rs deleted file mode 100644 index 056a553..0000000 --- a/crates/erp-points/src/dto/points_dto.rs +++ /dev/null @@ -1,314 +0,0 @@ -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, - pub updated_at: chrono::DateTime, - pub version: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct CreateAccountReq { - pub patient_id: Uuid, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct CheckinStatusResp { - pub checked_in_today: bool, - pub consecutive_days: i32, - pub next_streak_milestone: Option, -} - -// --------------------------------------------------------------------------- -// 积分流水 -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct PointsTransactionResp { - pub id: Uuid, - pub account_id: Uuid, - pub transaction_type: String, - pub amount: i32, - pub remaining_amount: i32, - pub status: String, - pub expires_at: Option>, - pub balance_after: i32, - pub description: Option, - pub created_at: chrono::DateTime, -} - -// --------------------------------------------------------------------------- -// 积分规则 -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct CreatePointsRuleReq { - pub event_type: String, - pub name: String, - pub description: Option, - 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, - pub description: Option, - pub points_value: Option, - pub daily_cap: Option, - pub streak_7d_bonus: Option, - pub streak_14d_bonus: Option, - pub streak_30d_bonus: Option, - pub is_active: Option, -} - -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, - 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, - pub updated_at: chrono::DateTime, - pub version: i32, -} - -// --------------------------------------------------------------------------- -// 兑换商品 -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct CreatePointsProductReq { - pub name: String, - pub product_type: Option, - pub points_cost: i32, - pub stock: Option, - pub image_url: Option, - pub description: Option, - pub service_config: Option, - pub sort_order: Option, -} - -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, - pub product_type: Option, - pub points_cost: Option, - pub stock: Option, - pub image_url: Option, - pub description: Option, - pub service_config: Option, - pub is_active: Option, - pub sort_order: Option, -} - -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, - pub description: Option, - pub is_active: bool, - pub sort_order: i32, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, - pub version: i32, -} - -// --------------------------------------------------------------------------- -// 兑换订单 -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct CreateOrderReq { - pub product_id: Uuid, - pub patient_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, - pub points_cost: i32, - pub status: String, - pub qr_code: Option, - pub verified_by: Option, - pub verified_at: Option>, - pub expires_at: Option>, - pub notes: Option, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, - 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, - pub event_date: NaiveDate, - pub start_time: Option, - pub end_time: Option, - pub location: Option, - pub points_reward: Option, - pub max_participants: Option, - pub image_url: Option, -} - -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, - pub description: Option, - pub event_date: Option, - pub start_time: Option, - pub end_time: Option, - pub location: Option, - pub points_reward: Option, - pub max_participants: Option, - pub status: Option, - pub image_url: Option, -} - -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, - pub event_date: NaiveDate, - pub start_time: Option, - pub end_time: Option, - pub location: Option, - pub points_reward: i32, - pub max_participants: i32, - pub current_participants: i32, - pub status: String, - pub image_url: Option, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, - pub version: i32, -} - -// --------------------------------------------------------------------------- -// 管理端:带版本号的更新/删除包装 -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UpdateRuleWithVersion { - pub data: UpdatePointsRuleReq, - pub version: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UpdateProductWithVersion { - pub data: UpdatePointsProductReq, - pub version: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct UpdateOfflineEventWithVersion { - pub data: UpdateOfflineEventReq, - pub version: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct AdminCheckinReq { - pub patient_id: Uuid, -} - -// --------------------------------------------------------------------------- -// 积分统计 -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct PointsStatisticsResp { - pub total_issued: i64, - pub total_spent: i64, - pub total_expired: i64, - pub active_accounts: i64, - pub top_earners: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct TopEarner { - pub account_id: Uuid, - pub patient_id: Uuid, - pub total_earned: i32, -} diff --git a/crates/erp-points/src/entity/mod.rs b/crates/erp-points/src/entity/mod.rs deleted file mode 100644 index a250793..0000000 --- a/crates/erp-points/src/entity/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod offline_event; -pub mod offline_event_registration; -pub mod points_account; -pub mod points_checkin; -pub mod points_order; -pub mod points_product; -pub mod points_rule; -pub mod points_transaction; diff --git a/crates/erp-points/src/entity/offline_event.rs b/crates/erp-points/src/entity/offline_event.rs deleted file mode 100644 index 3982552..0000000 --- a/crates/erp-points/src/entity/offline_event.rs +++ /dev/null @@ -1,40 +0,0 @@ -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, - pub event_date: chrono::NaiveDate, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub start_time: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub end_time: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub location: Option, - 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, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub created_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub updated_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, - pub version: i32, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/offline_event_registration.rs b/crates/erp-points/src/entity/offline_event_registration.rs deleted file mode 100644 index 1497fce..0000000 --- a/crates/erp-points/src/entity/offline_event_registration.rs +++ /dev/null @@ -1,32 +0,0 @@ -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, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub checked_in_by: Option, - pub points_granted: bool, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub created_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub updated_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, - pub version: i32, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_account.rs b/crates/erp-points/src/entity/points_account.rs deleted file mode 100644 index 8501902..0000000 --- a/crates/erp-points/src/entity/points_account.rs +++ /dev/null @@ -1,29 +0,0 @@ -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, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub updated_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_checkin.rs b/crates/erp-points/src/entity/points_checkin.rs deleted file mode 100644 index 8564c85..0000000 --- a/crates/erp-points/src/entity/points_checkin.rs +++ /dev/null @@ -1,24 +0,0 @@ -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, - pub updated_at: DateTimeUtc, - pub created_by: Option, - pub updated_by: Option, - pub deleted_at: Option, - pub version: i32, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_order.rs b/crates/erp-points/src/entity/points_order.rs deleted file mode 100644 index d8c9c80..0000000 --- a/crates/erp-points/src/entity/points_order.rs +++ /dev/null @@ -1,38 +0,0 @@ -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, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub verified_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub verified_at: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub notes: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub created_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub updated_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, - pub version: i32, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_product.rs b/crates/erp-points/src/entity/points_product.rs deleted file mode 100644 index 317ab08..0000000 --- a/crates/erp-points/src/entity/points_product.rs +++ /dev/null @@ -1,36 +0,0 @@ -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, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub service_config: Option, - 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, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub updated_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, - pub version: i32, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_rule.rs b/crates/erp-points/src/entity/points_rule.rs deleted file mode 100644 index b414526..0000000 --- a/crates/erp-points/src/entity/points_rule.rs +++ /dev/null @@ -1,34 +0,0 @@ -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, - 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, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub updated_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, - pub version: i32, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_transaction.rs b/crates/erp-points/src/entity/points_transaction.rs deleted file mode 100644 index 894f29f..0000000 --- a/crates/erp-points/src/entity/points_transaction.rs +++ /dev/null @@ -1,39 +0,0 @@ -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, - #[sea_orm(column_name = "transaction_type")] - pub transaction_type: String, - pub amount: i32, - pub remaining_amount: i32, - pub status: String, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - pub balance_after: i32, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub rule_id: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub order_id: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub created_at: DateTimeUtc, - pub updated_at: DateTimeUtc, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub created_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub updated_by: Option, - #[sea_orm(skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, - pub version: i32, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/error.rs b/crates/erp-points/src/error.rs deleted file mode 100644 index 4061fc0..0000000 --- a/crates/erp-points/src/error.rs +++ /dev/null @@ -1,61 +0,0 @@ -use erp_core::error::AppError; - -#[derive(Debug, thiserror::Error)] -pub enum PointsError { - #[error("{0}")] - Validation(String), - - #[error("积分规则不存在")] - PointsRuleNotFound, - - #[error("兑换商品不存在")] - PointsProductNotFound, - - #[error("兑换订单不存在")] - PointsOrderNotFound, - - #[error("积分不足")] - InsufficientPoints, - - #[error("线下活动不存在")] - OfflineEventNotFound, - - #[error("版本冲突")] - VersionMismatch, - - #[error("数据库操作失败: {0}")] - DbError(String), -} - -impl From for AppError { - fn from(err: PointsError) -> Self { - match err { - PointsError::Validation(s) => AppError::Validation(s), - PointsError::PointsRuleNotFound - | PointsError::PointsProductNotFound - | PointsError::PointsOrderNotFound - | PointsError::OfflineEventNotFound => AppError::NotFound(err.to_string()), - PointsError::InsufficientPoints => AppError::Validation(err.to_string()), - PointsError::VersionMismatch => AppError::VersionMismatch, - PointsError::DbError(_) => AppError::Internal(err.to_string()), - } - } -} - -impl From for PointsError { - fn from(err: sea_orm::DbErr) -> Self { - PointsError::DbError(err.to_string()) - } -} - -impl From for PointsError { - fn from(err: AppError) -> Self { - match err { - AppError::VersionMismatch => PointsError::VersionMismatch, - AppError::Validation(s) => PointsError::Validation(s), - other => PointsError::DbError(other.to_string()), - } - } -} - -pub type PointsResult = Result; diff --git a/crates/erp-points/src/event.rs b/crates/erp-points/src/event.rs deleted file mode 100644 index ac5d8c5..0000000 --- a/crates/erp-points/src/event.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::state::PointsState; - -pub const POINTS_EARNED: &str = "points.earned"; -pub const POINTS_EXCHANGED: &str = "points.exchanged"; -pub const POINTS_EXPIRED: &str = "points.expired"; -pub const POINTS_BALANCE_CHANGED: &str = "points.balance.changed"; - -pub fn register_handlers(_state: PointsState) { - // Phase 1: 订阅已有事件(lab_report.uploaded, patient.verified, daily_monitoring.created) - // 待 erp-health 发布这些事件后启用消费者 -} - -pub fn register_handlers_with_state(state: PointsState) { - register_handlers(state); -} diff --git a/crates/erp-points/src/handler/mod.rs b/crates/erp-points/src/handler/mod.rs deleted file mode 100644 index 03dc437..0000000 --- a/crates/erp-points/src/handler/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod points_handler; diff --git a/crates/erp-points/src/handler/points_handler.rs b/crates/erp-points/src/handler/points_handler.rs deleted file mode 100644 index 1c29798..0000000 --- a/crates/erp-points/src/handler/points_handler.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! 积分模块 Handler — 管理端 CRUD + 简化端点 - -use axum::Extension; -use axum::extract::{FromRef, Json, Path, Query, State}; -use axum::http::StatusCode; -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::{account_service, product_service}; -use crate::state::PointsState; - -#[derive(Debug, Deserialize, IntoParams)] -pub struct PaginationParams { - pub page: Option, - pub page_size: Option, -} - -#[derive(Debug, Deserialize, IntoParams)] -pub struct ProductTypeParam { - pub product_type: Option, -} - -// --------------------------------------------------------------------------- -// 积分账户 — 管理端 -// --------------------------------------------------------------------------- - -/// 创建积分账户(管理端) -pub async fn create_account( - State(state): State, - Extension(ctx): Extension, - Json(req): Json, -) -> Result>, AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.account.manage")?; - let result = account_service::get_account(&state, ctx.tenant_id, req.patient_id).await?; - Ok(Json(ApiResponse::ok(result))) -} - -/// 列出积分账户(管理端 — 501 待实现) -pub async fn list_accounts( - State(_state): State, - Extension(ctx): Extension, - Query(_params): Query, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.account.list")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分账户列表接口待实现")))) -} - -/// 获取单个积分账户 -pub async fn get_account( - State(state): State, - Extension(ctx): Extension, - Path(patient_id): Path, -) -> Result>, AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.account.list")?; - let result = account_service::get_account(&state, ctx.tenant_id, patient_id).await?; - Ok(Json(ApiResponse::ok(result))) -} - -// --------------------------------------------------------------------------- -// 积分商品 -// --------------------------------------------------------------------------- - -/// 列出积分商品 -pub async fn list_products( - State(state): State, - Extension(ctx): Extension, - Query(pt): Query, - Query(page): Query, -) -> Result>>, AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.product.list")?; - let p = page.page.unwrap_or(1); - let ps = page.page_size.unwrap_or(20); - let result = product_service::list_products( - &state, ctx.tenant_id, pt.product_type, p, ps, - ).await?; - Ok(Json(ApiResponse::ok(result))) -} - -/// 获取单个商品 -pub async fn get_product( - State(state): State, - Extension(ctx): Extension, - Path(product_id): Path, -) -> Result>, AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.product.list")?; - let result = product_service::get_product(&state, ctx.tenant_id, product_id).await?; - Ok(Json(ApiResponse::ok(result))) -} - -/// 创建积分商品 -pub async fn create_product( - State(state): State, - Extension(ctx): Extension, - Json(req): Json, -) -> Result>, AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.product.manage")?; - let mut req = req; - req.sanitize(); - let result = product_service::create_product( - &state, ctx.tenant_id, Some(ctx.user_id), req, - ).await?; - Ok(Json(ApiResponse::ok(result))) -} - -/// 更新积分商品(乐观锁) -pub async fn update_product( - State(state): State, - Extension(ctx): Extension, - Path(product_id): Path, - Json(wrapper): Json, -) -> Result>, AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.product.manage")?; - let mut data = wrapper.data; - data.sanitize(); - let result = product_service::update_product( - &state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version, - ).await?; - Ok(Json(ApiResponse::ok(result))) -} - -/// 删除积分商品(软删除 + 乐观锁) -pub async fn delete_product( - State(state): State, - Extension(ctx): Extension, - Path(product_id): Path, - Json(wrapper): Json, -) -> Result>, AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.product.manage")?; - product_service::delete_product( - &state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version, - ).await?; - Ok(Json(ApiResponse::ok(()))) -} - -// --------------------------------------------------------------------------- -// 积分订单 — 501 待迁移 -// --------------------------------------------------------------------------- - -/// 列出积分订单(501 待实现) -pub async fn list_orders( - State(_state): State, - Extension(ctx): Extension, - Query(_params): Query, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.order.list")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分订单列表接口待实现")))) -} - -/// 创建积分订单/兑换商品(501 待实现) -pub async fn create_order( - State(_state): State, - Extension(ctx): Extension, - Json(_req): Json, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.order.manage")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分订单创建接口待实现")))) -} - -/// 获取积分订单详情(501 待实现) -pub async fn get_order( - State(_state): State, - Extension(ctx): Extension, - Path(_id): Path, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.order.list")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分订单详情接口待实现")))) -} - -// --------------------------------------------------------------------------- -// 积分规则 — 501 待迁移 -// --------------------------------------------------------------------------- - -/// 列出积分规则(501 待实现) -pub async fn list_rules( - State(_state): State, - Extension(ctx): Extension, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.rule.list")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则列表接口待实现")))) -} - -/// 创建积分规则(501 待实现) -pub async fn create_rule( - State(_state): State, - Extension(ctx): Extension, - Json(_req): Json, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.rule.manage")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则创建接口待实现")))) -} - -/// 获取积分规则详情(501 待实现) -pub async fn get_rule( - State(_state): State, - Extension(ctx): Extension, - Path(_id): Path, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.rule.list")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则详情接口待实现")))) -} - -/// 更新积分规则(501 待实现) -pub async fn update_rule( - State(_state): State, - Extension(ctx): Extension, - Path(_id): Path, - Json(_wrapper): Json, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.rule.manage")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则更新接口待实现")))) -} - -/// 删除积分规则(501 待实现) -pub async fn delete_rule( - State(_state): State, - Extension(ctx): Extension, - Path(_id): Path, - Json(_wrapper): Json, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.rule.manage")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则删除接口待实现")))) -} - -// --------------------------------------------------------------------------- -// 每日打卡 -// --------------------------------------------------------------------------- - -/// 每日打卡签到(501 待实现) -pub async fn check_in( - State(_state): State, - Extension(ctx): Extension, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.account.manage")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("每日打卡接口待实现")))) -} - -// --------------------------------------------------------------------------- -// 积分流水 -// --------------------------------------------------------------------------- - -/// 列出积分流水(501 待实现 — 需 order_service 提供 patient_id 参数) -pub async fn list_transactions( - State(_state): State, - Extension(ctx): Extension, - Query(_params): Query, -) -> Result<(StatusCode, Json>), AppError> -where PointsState: FromRef, S: Clone + Send + Sync + 'static, -{ - require_permission(&ctx, "points.account.list")?; - Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分流水列表接口待实现")))) -} diff --git a/crates/erp-points/src/lib.rs b/crates/erp-points/src/lib.rs deleted file mode 100644 index 034ac5b..0000000 --- a/crates/erp-points/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod dto; -pub mod entity; -pub mod error; -pub mod event; -pub mod handler; -pub mod module; -pub mod service; -pub mod state; - -pub use module::PointsModule; -pub use state::PointsState; diff --git a/crates/erp-points/src/module.rs b/crates/erp-points/src/module.rs deleted file mode 100644 index cecb893..0000000 --- a/crates/erp-points/src/module.rs +++ /dev/null @@ -1,147 +0,0 @@ -use async_trait::async_trait; -use axum::Router; -use erp_core::error::AppResult; -use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor}; - -use crate::handler::points_handler; -use crate::state::PointsState; - -pub struct PointsModule; - -impl PointsModule { - pub fn new() -> Self { - Self - } - - pub fn protected_routes() -> Router - where - PointsState: axum::extract::FromRef, - S: Clone + Send + Sync + 'static, - { - Router::new() - .route( - "/points/accounts", - axum::routing::get(points_handler::list_accounts) - .post(points_handler::create_account), - ) - .route( - "/points/accounts/{id}", - axum::routing::get(points_handler::get_account), - ) - .route( - "/points/products", - axum::routing::get(points_handler::list_products) - .post(points_handler::create_product), - ) - .route( - "/points/products/{id}", - axum::routing::get(points_handler::get_product) - .put(points_handler::update_product) - .delete(points_handler::delete_product), - ) - .route( - "/points/orders", - axum::routing::get(points_handler::list_orders) - .post(points_handler::create_order), - ) - .route( - "/points/orders/{id}", - axum::routing::get(points_handler::get_order), - ) - .route( - "/points/rules", - axum::routing::get(points_handler::list_rules) - .post(points_handler::create_rule), - ) - .route( - "/points/rules/{id}", - axum::routing::get(points_handler::get_rule) - .put(points_handler::update_rule) - .delete(points_handler::delete_rule), - ) - .route( - "/points/checkin", - axum::routing::post(points_handler::check_in), - ) - .route( - "/points/transactions", - axum::routing::get(points_handler::list_transactions), - ) - } -} - -#[async_trait] -impl ErpModule for PointsModule { - fn name(&self) -> &str { - "积分商城" - } - - fn id(&self) -> &str { - "erp-points" - } - - fn module_type(&self) -> ModuleType { - ModuleType::Builtin - } - - fn permissions(&self) -> Vec { - vec![ - PermissionDescriptor { - code: "points.account.list".into(), - name: "积分账户列表".into(), - description: "查看积分账户列表和详情".into(), - module: "erp-points".into(), - }, - PermissionDescriptor { - code: "points.account.manage".into(), - name: "积分账户管理".into(), - description: "管理积分账户".into(), - module: "erp-points".into(), - }, - PermissionDescriptor { - code: "points.product.list".into(), - name: "积分商品列表".into(), - description: "查看积分商品列表和详情".into(), - module: "erp-points".into(), - }, - PermissionDescriptor { - code: "points.product.manage".into(), - name: "积分商品管理".into(), - description: "创建、编辑、删除积分商品".into(), - module: "erp-points".into(), - }, - PermissionDescriptor { - code: "points.order.list".into(), - name: "积分订单列表".into(), - description: "查看积分订单列表和详情".into(), - module: "erp-points".into(), - }, - PermissionDescriptor { - code: "points.order.manage".into(), - name: "积分订单管理".into(), - description: "创建、核销积分订单".into(), - module: "erp-points".into(), - }, - PermissionDescriptor { - code: "points.rule.list".into(), - name: "积分规则列表".into(), - description: "查看积分规则列表和详情".into(), - module: "erp-points".into(), - }, - PermissionDescriptor { - code: "points.rule.manage".into(), - name: "积分规则管理".into(), - description: "创建、编辑、删除积分规则".into(), - module: "erp-points".into(), - }, - ] - } - - async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { - Ok(()) - } - - fn as_any(&self) -> &dyn std::any::Any { - self - } -} diff --git a/crates/erp-points/src/service/account_service.rs b/crates/erp-points/src/service/account_service.rs deleted file mode 100644 index 2fea63f..0000000 --- a/crates/erp-points/src/service/account_service.rs +++ /dev/null @@ -1,437 +0,0 @@ -//! 积分账户 Service — 获取/创建账户、积分获取、流水查询、积分统计 - -use chrono::{Duration, Utc}; -use sea_orm::entity::prelude::*; -use sea_orm::sea_query::Expr; -use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait}; -use uuid::Uuid; - -use erp_core::audit::AuditLog; -use erp_core::audit_service; -use erp_core::events::DomainEvent; -use erp_core::types::PaginatedResponse; - -use crate::dto::points_dto::*; -use crate::entity::{points_account, points_rule, points_transaction}; -use crate::error::{PointsError, PointsResult}; -use crate::state::PointsState; - -// --------------------------------------------------------------------------- -// 内部辅助:获取或创建账户 -// --------------------------------------------------------------------------- - -/// 获取或创建患者的积分账户(支持事务和非事务连接) -pub(crate) async fn get_or_create_account( - db: &C, - tenant_id: Uuid, - patient_id: Uuid, -) -> PointsResult { - 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: &PointsState, - tenant_id: Uuid, - patient_id: Uuid, -) -> PointsResult { - 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: &PointsState, - tenant_id: Uuid, - patient_id: Uuid, - event_type: &str, - operator_id: Option, -) -> PointsResult { - // 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(|| PointsError::Validation(format!("无匹配的积分规则: {}", event_type)))?; - - // 2. 先获取/创建账户(需要 account_id 来做日上限查询) - let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; - - // 3. 检查每日上限(用 account.id 而非 patient_id) - if rule.daily_cap > 0 { - let today = Utc::now().date_naive(); - let today_start = today.and_hms_opt(0, 0, 0).unwrap().and_utc(); - let earned_today: i32 = points_transaction::Entity::find() - .filter(points_transaction::Column::TenantId.eq(tenant_id)) - .filter(points_transaction::Column::AccountId.eq(acc.id)) - .filter(points_transaction::Column::TransactionType.eq("earn")) - .filter(points_transaction::Column::RuleId.eq(rule.id)) - .filter(points_transaction::Column::CreatedAt.gte(today_start)) - .all(&state.db) - .await? - .iter() - .map(|t| t.amount) - .sum(); - - if earned_today + rule.points_value > rule.daily_cap { - return Err(PointsError::Validation("今日该渠道积分已达上限".into())); - } - } - - // 4. 在事务中执行积分获取 - let txn = state.db.begin().await?; - // 重新读取账户以获取最新 version(事务内) - let acc = points_account::Entity::find_by_id(acc.id) - .one(&txn) - .await? - .ok_or(PointsError::Validation("积分账户不存在".into()))?; - - // 使用数据库级 CAS 防止并发赚取导致余额丢失 - let now = Utc::now(); - let expires_at = now + Duration::days(365); // 12 个月过期 - - // 写入流水 - let txn_record = points_transaction::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - account_id: Set(acc.id), - transaction_type: Set("earn".to_string()), - amount: Set(rule.points_value), - remaining_amount: Set(rule.points_value), - status: Set("active".to_string()), - expires_at: Set(Some(expires_at)), - balance_after: Set(acc.balance + rule.points_value), - rule_id: Set(Some(rule.id)), - order_id: Set(None), - description: Set(Some(format!("{}: +{}", rule.name, rule.points_value))), - created_at: Set(now), - updated_at: Set(now), - created_by: Set(operator_id), - updated_by: Set(operator_id), - deleted_at: Set(None), - version: Set(1), - }; - let inserted = txn_record.insert(&txn).await?; - - // CAS 更新账户余额:基于 version 字段防止并发覆盖 - let cas_result = points_account::Entity::update_many() - .col_expr( - points_account::Column::Balance, - Expr::col(points_account::Column::Balance).add(rule.points_value), - ) - .col_expr( - points_account::Column::TotalEarned, - Expr::col(points_account::Column::TotalEarned).add(rule.points_value), - ) - .col_expr(points_account::Column::UpdatedAt, Expr::value(now)) - .col_expr(points_account::Column::UpdatedBy, Expr::value(operator_id)) - .col_expr( - points_account::Column::Version, - Expr::col(points_account::Column::Version).add(1), - ) - .filter(points_account::Column::Id.eq(acc.id)) - .filter(points_account::Column::Version.eq(acc.version)) - .exec(&txn) - .await?; - if cas_result.rows_affected == 0 { - txn.rollback().await?; - return Err(PointsError::VersionMismatch); - } - - txn.commit().await?; - - audit_service::record( - AuditLog::new(tenant_id, operator_id, "points.earned", "points_transaction") - .with_resource_id(inserted.id), - &state.db, - ).await; - - state.event_bus.publish( - DomainEvent::new(crate::event::POINTS_EARNED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({ - "transaction_id": inserted.id, "account_id": inserted.account_id, - "amount": inserted.amount, "balance_after": inserted.balance_after, - }))), - &state.db, - ).await; - - Ok(PointsTransactionResp { - id: inserted.id, - account_id: inserted.account_id, - transaction_type: inserted.transaction_type, - amount: inserted.amount, - remaining_amount: inserted.remaining_amount, - status: inserted.status, - expires_at: inserted.expires_at, - balance_after: inserted.balance_after, - description: inserted.description, - created_at: inserted.created_at, - }) -} - -// --------------------------------------------------------------------------- -// 积分流水查询 -// --------------------------------------------------------------------------- - -/// 查询积分流水(分页) -pub async fn list_transactions( - state: &PointsState, - tenant_id: Uuid, - patient_id: Uuid, - page: u64, - page_size: u64, -) -> PointsResult> { - let acc = get_or_create_account(&state.db, tenant_id, patient_id).await?; - let limit = page_size.min(100); - let offset = page.saturating_sub(1) * limit; - - let query = points_transaction::Entity::find() - .filter(points_transaction::Column::TenantId.eq(tenant_id)) - .filter(points_transaction::Column::AccountId.eq(acc.id)); - - let total = query.clone().count(&state.db).await?; - let models = query - .order_by_desc(points_transaction::Column::CreatedAt) - .offset(offset) - .limit(limit) - .all(&state.db) - .await?; - - let total_pages = total.div_ceil(limit.max(1)); - let data = models.into_iter().map(|m| PointsTransactionResp { - id: m.id, account_id: m.account_id, transaction_type: m.transaction_type, - amount: m.amount, remaining_amount: m.remaining_amount, - status: m.status, expires_at: m.expires_at, - balance_after: m.balance_after, description: m.description, - created_at: m.created_at, - }).collect(); - - Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) -} - -// --------------------------------------------------------------------------- -// 积分统计 — 管理端 -// --------------------------------------------------------------------------- - -/// 管理端:积分统计汇总 -pub async fn get_points_statistics( - state: &PointsState, - tenant_id: Uuid, -) -> PointsResult { - use sea_orm::FromQueryResult; - - #[derive(Debug, FromQueryResult)] - struct AggRow { - total_issued: Option, - total_spent: Option, - total_expired: Option, - active_accounts: Option, - } - - #[derive(Debug, FromQueryResult)] - struct TopEarnerRow { - id: Uuid, - patient_id: Uuid, - total_earned: Option, - } - - // 聚合查询:总发放/总消费/总过期/活跃账户数 - let agg_sql = r#" - SELECT - COALESCE(SUM(total_earned), 0) AS total_issued, - COALESCE(SUM(total_spent), 0) AS total_spent, - COALESCE(SUM(total_expired), 0) AS total_expired, - COUNT(*) AS active_accounts - FROM points_account - WHERE tenant_id = $1 AND deleted_at IS NULL - "#; - let agg = AggRow::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - agg_sql, - [tenant_id.into()], - ), - ) - .one(&state.db) - .await? - .unwrap_or(AggRow { - total_issued: Some(0), - total_spent: Some(0), - total_expired: Some(0), - active_accounts: Some(0), - }); - - // Top 10 积分获取者 - let top_sql = r#" - SELECT id, patient_id, total_earned - FROM points_account - WHERE tenant_id = $1 AND deleted_at IS NULL - ORDER BY total_earned DESC - LIMIT 10 - "#; - let top_rows = TopEarnerRow::find_by_statement( - sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - top_sql, - [tenant_id.into()], - ), - ) - .all(&state.db) - .await?; - - let top_earners = top_rows.into_iter().map(|r| TopEarner { - account_id: r.id, - patient_id: r.patient_id, - total_earned: r.total_earned.unwrap_or(0), - }).collect(); - - Ok(PointsStatisticsResp { - total_issued: agg.total_issued.unwrap_or(0), - total_spent: agg.total_spent.unwrap_or(0), - total_expired: agg.total_expired.unwrap_or(0), - active_accounts: agg.active_accounts.unwrap_or(0), - top_earners, - }) -} - -// --------------------------------------------------------------------------- -// 积分过期清理 -// --------------------------------------------------------------------------- - -/// 扫描已过期的 earn 交易,扣减账户余额,更新 total_expired。 -/// 返回处理的过期交易数量。 -pub async fn expire_points( - db: &sea_orm::DatabaseConnection, - event_bus: &erp_core::events::EventBus, -) -> PointsResult { - let now = Utc::now(); - - // 查找所有已过期但未标记 expired 的 earn 交易 - let expired_txns: Vec = points_transaction::Entity::find() - .filter(points_transaction::Column::TransactionType.eq("earn")) - .filter(points_transaction::Column::Status.eq("active")) - .filter(points_transaction::Column::ExpiresAt.is_not_null()) - .filter(points_transaction::Column::ExpiresAt.lt(now)) - .filter(points_transaction::Column::DeletedAt.is_null()) - .filter(points_transaction::Column::RemainingAmount.gt(0)) - .all(db) - .await?; - - if expired_txns.is_empty() { - return Ok(0); - } - - let tenant_id = expired_txns.first().map(|t| t.tenant_id).unwrap_or_default(); - - let mut processed: u64 = 0; - - for txn in expired_txns { - let txn_id = txn.id; - let account_id = txn.account_id; - let remaining = txn.remaining_amount; - - let txn_result = db - .transaction::<_, (), PointsError>(|txn_db| { - Box::pin(async move { - // 标记交易为 expired - let mut active_txn: points_transaction::ActiveModel = txn.into(); - active_txn.status = Set("expired".to_string()); - active_txn.remaining_amount = Set(0); - active_txn.version = Set(active_txn.version.unwrap() + 1); - active_txn.updated_at = Set(Utc::now()); - active_txn.update(txn_db).await?; - - // 扣减账户余额,更新 total_expired - let account = points_account::Entity::find_by_id(account_id) - .one(txn_db) - .await? - .ok_or_else(|| PointsError::Validation("积分账户不存在".to_string()))?; - - let new_balance = (account.balance - remaining).max(0); - let new_expired = account.total_expired + remaining; - - let mut active_account: points_account::ActiveModel = account.into(); - active_account.balance = Set(new_balance); - active_account.total_expired = Set(new_expired); - active_account.version = Set(active_account.version.unwrap() + 1); - active_account.updated_at = Set(Utc::now()); - let expected_ver: i32 = match &active_account.version { - sea_orm::ActiveValue::Unchanged(v) | sea_orm::ActiveValue::Set(v) => *v, - _ => 0, - }; - let _next_ver = erp_core::error::check_version(expected_ver, expected_ver)?; - active_account.update(txn_db).await?; - - Ok(()) - }) - }) - .await; - - match txn_result { - Ok(()) => { - processed += 1; - tracing::debug!(txn_id = %txn_id, remaining = remaining, "积分过期处理完成"); - } - Err(e) => { - tracing::warn!(txn_id = %txn_id, error = %e, "积分过期处理失败,跳过"); - } - } - } - - if processed > 0 { - tracing::info!(count = processed, "积分过期清理完成"); - let event = erp_core::events::DomainEvent::new( - crate::event::POINTS_EXPIRED, - tenant_id, - erp_core::events::build_event_payload(serde_json::json!({ "expired_count": processed })), - ); - event_bus.publish(event, db).await; - } - - Ok(processed) -} diff --git a/crates/erp-points/src/service/mod.rs b/crates/erp-points/src/service/mod.rs deleted file mode 100644 index 6ddaaa3..0000000 --- a/crates/erp-points/src/service/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod account_service; -pub mod product_service; diff --git a/crates/erp-points/src/service/product_service.rs b/crates/erp-points/src/service/product_service.rs deleted file mode 100644 index 426c50e..0000000 --- a/crates/erp-points/src/service/product_service.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! 积分商品管理 Service — CRUD + 乐观锁 - -use chrono::Utc; -use sea_orm::entity::prelude::*; -use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; -use uuid::Uuid; - -use erp_core::audit::AuditLog; -use erp_core::audit_service; -use erp_core::error::check_version; -use erp_core::types::PaginatedResponse; - -use crate::dto::points_dto::*; -use crate::entity::points_product; -use crate::error::{PointsError, PointsResult}; -use crate::state::PointsState; - -/// 分页列出商品(可按类型筛选) -pub async fn list_products( - state: &PointsState, - tenant_id: Uuid, - product_type: Option, - page: u64, - page_size: u64, -) -> PointsResult> { - 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: &PointsState, - tenant_id: Uuid, - product_id: Uuid, -) -> PointsResult { - 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(PointsError::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: &PointsState, - tenant_id: Uuid, - operator_id: Option, - req: CreatePointsProductReq, -) -> PointsResult { - let now = Utc::now(); - let active = points_product::ActiveModel { - id: Set(Uuid::now_v7()), - tenant_id: Set(tenant_id), - name: Set(req.name), - product_type: Set(req.product_type.unwrap_or_else(|| "physical".into())), - points_cost: Set(req.points_cost), - stock: Set(req.stock.unwrap_or(-1)), - image_url: Set(req.image_url), - description: Set(req.description), - service_config: Set(req.service_config), - is_active: Set(true), - sort_order: Set(req.sort_order.unwrap_or(0)), - created_at: Set(now), - updated_at: Set(now), - created_by: Set(operator_id), - updated_by: Set(operator_id), - deleted_at: Set(None), - version: Set(1), - }; - let m = active.insert(&state.db).await?; - - audit_service::record( - AuditLog::new( - tenant_id, - operator_id, - "points_product.created", - "points_product", - ) - .with_resource_id(m.id), - &state.db, - ) - .await; - - Ok(PointsProductResp { - id: m.id, - name: m.name, - product_type: m.product_type, - points_cost: m.points_cost, - stock: m.stock, - image_url: m.image_url, - description: m.description, - is_active: m.is_active, - sort_order: m.sort_order, - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, - }) -} - -/// 更新商品(乐观锁) -pub async fn update_product( - state: &PointsState, - tenant_id: Uuid, - product_id: Uuid, - operator_id: Option, - req: UpdatePointsProductReq, - expected_version: i32, -) -> PointsResult { - let model = points_product::Entity::find() - .filter(points_product::Column::Id.eq(product_id)) - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(PointsError::PointsProductNotFound)?; - - let next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: points_product::ActiveModel = model.into(); - if let Some(name) = req.name { - active.name = Set(name); - } - if let Some(product_type) = req.product_type { - active.product_type = Set(product_type); - } - if let Some(points_cost) = req.points_cost { - active.points_cost = Set(points_cost); - } - if let Some(stock) = req.stock { - active.stock = Set(stock); - } - if let Some(image_url) = req.image_url { - active.image_url = Set(Some(image_url)); - } - if let Some(description) = req.description { - active.description = Set(Some(description)); - } - if let Some(service_config) = req.service_config { - active.service_config = Set(Some(service_config)); - } - if let Some(is_active) = req.is_active { - active.is_active = Set(is_active); - } - if let Some(sort_order) = req.sort_order { - active.sort_order = Set(sort_order); - } - active.updated_at = Set(now); - active.updated_by = Set(operator_id); - active.version = Set(next_ver); - let m = active.update(&state.db).await?; - - audit_service::record( - AuditLog::new( - tenant_id, - operator_id, - "points_product.updated", - "points_product", - ) - .with_resource_id(m.id), - &state.db, - ) - .await; - - Ok(PointsProductResp { - id: m.id, - name: m.name, - product_type: m.product_type, - points_cost: m.points_cost, - stock: m.stock, - image_url: m.image_url, - description: m.description, - is_active: m.is_active, - sort_order: m.sort_order, - created_at: m.created_at, - updated_at: m.updated_at, - version: m.version, - }) -} - -/// 软删除商品 -pub async fn delete_product( - state: &PointsState, - tenant_id: Uuid, - product_id: Uuid, - operator_id: Option, - expected_version: i32, -) -> PointsResult<()> { - let model = points_product::Entity::find() - .filter(points_product::Column::Id.eq(product_id)) - .filter(points_product::Column::TenantId.eq(tenant_id)) - .filter(points_product::Column::DeletedAt.is_null()) - .one(&state.db) - .await? - .ok_or(PointsError::PointsProductNotFound)?; - - let _next_ver = check_version(expected_version, model.version)?; - - let now = Utc::now(); - let mut active: points_product::ActiveModel = model.into(); - active.deleted_at = Set(Some(now)); - active.updated_at = Set(now); - active.updated_by = Set(operator_id); - active.version = Set(active.version.unwrap() + 1); - let m = active.update(&state.db).await?; - - audit_service::record( - AuditLog::new( - tenant_id, - operator_id, - "points_product.deleted", - "points_product", - ) - .with_resource_id(m.id), - &state.db, - ) - .await; - - Ok(()) -} diff --git a/crates/erp-points/src/state.rs b/crates/erp-points/src/state.rs deleted file mode 100644 index 13005cf..0000000 --- a/crates/erp-points/src/state.rs +++ /dev/null @@ -1,8 +0,0 @@ -use erp_core::events::EventBus; -use sea_orm::DatabaseConnection; - -#[derive(Clone)] -pub struct PointsState { - pub db: DatabaseConnection, - pub event_bus: EventBus, -} diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index 6f60618..fb80581 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -31,7 +31,6 @@ erp-plugin.workspace = true erp-health.workspace = true erp-ai.workspace = true erp-dialysis.workspace = true -# erp-points 已禁用,积分功能统一由 erp-health 提供 anyhow.workspace = true uuid.workspace = true chrono.workspace = true diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 81e288b..572f9da 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -350,7 +350,6 @@ async fn main() -> anyhow::Result<()> { ); // Points module 已统一到 erp-health(/health/points/* 路由) - // erp-points 的 /points/* 路由为重复实现(大部分 501),已禁用 // Initialize dialysis module @@ -369,7 +368,6 @@ async fn main() -> anyhow::Result<()> { .register(message_module) .register(health_module) .register(ai_module) - // erp-points 已禁用,积分功能统一由 erp-health 提供 .register(dialysis_module); tracing::info!( module_count = registry.modules().len(), @@ -550,7 +548,6 @@ async fn main() -> anyhow::Result<()> { .merge(erp_plugin::module::PluginModule::protected_routes()) .merge(erp_health::HealthModule::protected_routes()) .merge(erp_ai::AiModule::protected_routes()) - // erp-points 已禁用,积分路由统一由 erp-health /health/points/* 提供 .merge(erp_dialysis::DialysisModule::protected_routes()) .merge(handlers::audit_log::audit_log_router()) .route(