From 4ab67ba559b90675ce79c562bcf567375762f1db Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 16:51:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E7=A7=AF=E5=88=86=E5=95=86?= =?UTF-8?q?=E5=9F=8E=E5=90=8E=E7=AB=AF=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=20(Chunk=202=20V2=20=E8=BF=AD=E4=BB=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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个月滚动过期机制 - 审计日志全量覆盖 --- Cargo.lock | 104 ++ crates/erp-health/src/dto/mod.rs | 1 + crates/erp-health/src/dto/points_dto.rs | 261 +++++ crates/erp-health/src/entity/mod.rs | 8 + crates/erp-health/src/entity/offline_event.rs | 40 + .../src/entity/offline_event_registration.rs | 32 + .../erp-health/src/entity/points_account.rs | 42 + .../erp-health/src/entity/points_checkin.rs | 32 + crates/erp-health/src/entity/points_order.rs | 51 + .../erp-health/src/entity/points_product.rs | 36 + crates/erp-health/src/entity/points_rule.rs | 34 + .../src/entity/points_transaction.rs | 38 + crates/erp-health/src/error.rs | 14 +- crates/erp-health/src/handler/mod.rs | 1 + .../erp-health/src/handler/points_handler.rs | 286 ++++++ crates/erp-health/src/health_provider_impl.rs | 31 +- crates/erp-health/src/module.rs | 74 +- crates/erp-health/src/service/mod.rs | 1 + .../erp-health/src/service/points_service.rs | 908 ++++++++++++++++++ crates/erp-server/migration/src/lib.rs | 2 + .../m20260425_000053_create_points_tables.rs | 259 +++++ 21 files changed, 2248 insertions(+), 7 deletions(-) create mode 100644 crates/erp-health/src/dto/points_dto.rs create mode 100644 crates/erp-health/src/entity/offline_event.rs create mode 100644 crates/erp-health/src/entity/offline_event_registration.rs create mode 100644 crates/erp-health/src/entity/points_account.rs create mode 100644 crates/erp-health/src/entity/points_checkin.rs create mode 100644 crates/erp-health/src/entity/points_order.rs create mode 100644 crates/erp-health/src/entity/points_product.rs create mode 100644 crates/erp-health/src/entity/points_rule.rs create mode 100644 crates/erp-health/src/entity/points_transaction.rs create mode 100644 crates/erp-health/src/handler/points_handler.rs create mode 100644 crates/erp-health/src/service/points_service.rs create mode 100644 crates/erp-server/migration/src/m20260425_000053_create_points_tables.rs diff --git a/Cargo.lock b/Cargo.lock index edab778..1cbde1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 1a25faa..561778e 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -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 { diff --git a/crates/erp-health/src/dto/points_dto.rs b/crates/erp-health/src/dto/points_dto.rs new file mode 100644 index 0000000..0bafa8b --- /dev/null +++ b/crates/erp-health/src/dto/points_dto.rs @@ -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, + pub updated_at: chrono::DateTime, + 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, +} + +// --------------------------------------------------------------------------- +// 积分流水 +// --------------------------------------------------------------------------- + +#[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>, + 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 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, + 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, +} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index 6c3629b..1802fde 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -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; diff --git a/crates/erp-health/src/entity/offline_event.rs b/crates/erp-health/src/entity/offline_event.rs new file mode 100644 index 0000000..3982552 --- /dev/null +++ b/crates/erp-health/src/entity/offline_event.rs @@ -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, + 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-health/src/entity/offline_event_registration.rs b/crates/erp-health/src/entity/offline_event_registration.rs new file mode 100644 index 0000000..1497fce --- /dev/null +++ b/crates/erp-health/src/entity/offline_event_registration.rs @@ -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, + #[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-health/src/entity/points_account.rs b/crates/erp-health/src/entity/points_account.rs new file mode 100644 index 0000000..5fd0a14 --- /dev/null +++ b/crates/erp-health/src/entity/points_account.rs @@ -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, + #[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 { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/points_checkin.rs b/crates/erp-health/src/entity/points_checkin.rs new file mode 100644 index 0000000..734f8ac --- /dev/null +++ b/crates/erp-health/src/entity/points_checkin.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/points_order.rs b/crates/erp-health/src/entity/points_order.rs new file mode 100644 index 0000000..4a8573a --- /dev/null +++ b/crates/erp-health/src/entity/points_order.rs @@ -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, + #[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 { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/points_product.rs b/crates/erp-health/src/entity/points_product.rs new file mode 100644 index 0000000..317ab08 --- /dev/null +++ b/crates/erp-health/src/entity/points_product.rs @@ -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, + #[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-health/src/entity/points_rule.rs b/crates/erp-health/src/entity/points_rule.rs new file mode 100644 index 0000000..b414526 --- /dev/null +++ b/crates/erp-health/src/entity/points_rule.rs @@ -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, + 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-health/src/entity/points_transaction.rs b/crates/erp-health/src/entity/points_transaction.rs new file mode 100644 index 0000000..0f0b19b --- /dev/null +++ b/crates/erp-health/src/entity/points_transaction.rs @@ -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, + 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-health/src/error.rs b/crates/erp-health/src/error.rs index 5b2138e..b1d4957 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -26,6 +26,15 @@ pub enum HealthError { #[error("透析记录不存在")] DialysisRecordNotFound, + #[error("兑换商品不存在")] + PointsProductNotFound, + + #[error("兑换订单不存在")] + PointsOrderNotFound, + + #[error("线下活动不存在")] + OfflineEventNotFound, + #[error("健康档案不存在")] HealthRecordNotFound, @@ -73,7 +82,10 @@ impl From 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, diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 32c1e72..5cd336c 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -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; diff --git a/crates/erp-health/src/handler/points_handler.rs b/crates/erp-health/src/handler/points_handler.rs new file mode 100644 index 0000000..974fe7b --- /dev/null +++ b/crates/erp-health/src/handler/points_handler.rs @@ -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, + pub page_size: Option, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ProductTypeParam { + pub product_type: Option, +} + +// --------------------------------------------------------------------------- +// 患者端:积分账户 + 打卡 +// --------------------------------------------------------------------------- + +pub async fn get_my_account( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, + Query(page): Query, +) -> Result>>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Path(product_id): Path, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Path(event_id): Path, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where HealthState: FromRef, 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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where HealthState: FromRef, 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 { + use crate::entity::patient; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let result: Option = 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())) +} diff --git a/crates/erp-health/src/health_provider_impl.rs b/crates/erp-health/src/health_provider_impl.rs index faedc5f..91bfaa4 100644 --- a/crates/erp-health/src/health_provider_impl.rs +++ b/crates/erp-health/src/health_provider_impl.rs @@ -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 { - 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> { - 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 { - 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 { - todo!("实现完整报告查询") + Err(AppError::Internal( + "HealthDataProvider::get_full_report 尚未实现 (Phase 1 stub)".into(), + )) } } diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 5e4b582..20b2f0c 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -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(), + }, ] } diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index d6ac376..a1ff03c 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -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; diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs new file mode 100644 index 0000000..9bf62fb --- /dev/null +++ b/crates/erp-health/src/service/points_service.rs @@ -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( + db: &C, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + 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 { + 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, +) -> HealthResult { + // 1. 查找匹配规则 + let rule = points_rule::Entity::find() + .filter(points_rule::Column::TenantId.eq(tenant_id)) + .filter(points_rule::Column::EventType.eq(event_type)) + .filter(points_rule::Column::IsActive.eq(true)) + .filter(points_rule::Column::DeletedAt.is_null()) + .one(&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, +) -> HealthResult { + 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 { + 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, +) -> HealthResult { + 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 { + 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 { + [7, 14, 30].iter().find(|&&m| m > consecutive).copied() +} + +pub async fn get_checkin_status( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + 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> { + 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, + page: u64, + page_size: u64, +) -> HealthResult> { + 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 { + 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, + req: CreatePointsProductReq, +) -> HealthResult { + 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, +) -> HealthResult { + // 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 = 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> { + 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 { + 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> { + 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, + req: CreatePointsRuleReq, +) -> HealthResult { + 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> { + 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, +) -> 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, + } +} diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 63f6d52..68c394f 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -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), ] } } diff --git a/crates/erp-server/migration/src/m20260425_000053_create_points_tables.rs b/crates/erp-server/migration/src/m20260425_000053_create_points_tables.rs new file mode 100644 index 0000000..d3e26c4 --- /dev/null +++ b/crates/erp-server/migration/src/m20260425_000053_create_points_tables.rs @@ -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(()) + } +}