From 147fd886e391b21a32f79a35b210e740a83dc100 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 28 Apr 2026 12:13:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E8=AF=84=E4=BC=B0=E9=87=8F?= =?UTF-8?q?=E8=A1=A8=20WASM=20=E7=BC=96=E8=AF=91=E9=80=9A=E8=BF=87=20?= =?UTF-8?q?=E2=80=94=20170KB=20cdylib=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wasm32-unknown-unknown target 编译成功 - 插件通过 API upload/install 注册,无需手动配置 --- Cargo.lock | 24 + .../StatisticsDashboard/AdminDashboard.tsx | 8 +- .../StatisticsDashboard/HealthDataCenter.tsx | 196 ++++---- crates/erp-points/Cargo.toml | 19 + crates/erp-points/src/entity/mod.rs | 8 + crates/erp-points/src/entity/offline_event.rs | 40 ++ .../src/entity/offline_event_registration.rs | 32 ++ .../erp-points/src/entity/points_account.rs | 42 ++ .../erp-points/src/entity/points_checkin.rs | 37 ++ crates/erp-points/src/entity/points_order.rs | 51 ++ .../erp-points/src/entity/points_product.rs | 36 ++ crates/erp-points/src/entity/points_rule.rs | 34 ++ .../src/entity/points_transaction.rs | 39 ++ crates/erp-points/src/error.rs | 51 ++ crates/erp-points/src/event.rs | 15 + crates/erp-points/src/lib.rs | 8 + crates/erp-points/src/module.rs | 128 +++++ .../erp-points/src/service/account_service.rs | 437 ++++++++++++++++++ crates/erp-points/src/state.rs | 8 + 19 files changed, 1124 insertions(+), 89 deletions(-) create mode 100644 crates/erp-points/Cargo.toml create mode 100644 crates/erp-points/src/entity/mod.rs create mode 100644 crates/erp-points/src/entity/offline_event.rs create mode 100644 crates/erp-points/src/entity/offline_event_registration.rs create mode 100644 crates/erp-points/src/entity/points_account.rs create mode 100644 crates/erp-points/src/entity/points_checkin.rs create mode 100644 crates/erp-points/src/entity/points_order.rs create mode 100644 crates/erp-points/src/entity/points_product.rs create mode 100644 crates/erp-points/src/entity/points_rule.rs create mode 100644 crates/erp-points/src/entity/points_transaction.rs create mode 100644 crates/erp-points/src/error.rs create mode 100644 crates/erp-points/src/event.rs create mode 100644 crates/erp-points/src/lib.rs create mode 100644 crates/erp-points/src/module.rs create mode 100644 crates/erp-points/src/service/account_service.rs create mode 100644 crates/erp-points/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 544f6a3..a8bf9e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1488,6 +1488,7 @@ dependencies = [ "utoipa", "uuid", "validator", + "zeroize", ] [[package]] @@ -1541,6 +1542,15 @@ dependencies = [ "wasmtime-wasi", ] +[[package]] +name = "erp-plugin-assessment" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen 0.55.0", +] + [[package]] name = "erp-plugin-crm" version = "0.1.0" @@ -6733,6 +6743,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx index df1ffa1..fc3f919 100644 --- a/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx +++ b/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx @@ -72,10 +72,10 @@ export function AdminDashboard() { }, - { key: 'lab', label: '化验报告', children: }, - { key: 'appointments', label: '预约分析', children: }, - { key: 'vital-signs', label: '体征数据', children: }, + { key: 'dialysis', label: '透析管理', children: }, + { key: 'lab', label: '化验报告', children: }, + { key: 'appointments', label: '预约分析', children: }, + { key: 'vital-signs', label: '体征数据', children: }, ]} /> diff --git a/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx b/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx index a4f12ce..6c83d7f 100644 --- a/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx +++ b/apps/web/src/pages/health/StatisticsDashboard/HealthDataCenter.tsx @@ -1,98 +1,124 @@ -import { Row, Col, Card, Statistic, Tag, Typography } from 'antd'; +import { Row, Col, Card, Statistic, Tag, Typography, Empty } from 'antd'; import type { HealthDataStats } from '../../../api/health/points'; const { Text } = Typography; interface HealthDataCenterProps { data: HealthDataStats | null; + tab?: string; } -export default function HealthDataCenter({ data }: HealthDataCenterProps) { +function DialysisPanel({ data }: { data: HealthDataStats | null }) { + return ( + 透析记录} style={{ borderRadius: 8 }}> + + + + + + + + + + + {(data?.dialysis.type_distribution ?? []).length > 0 && ( +
+ 类型分布: + {data!.dialysis.type_distribution.map((item) => ( + {item.name}: {item.value} + ))} +
+ )} +
+ ); +} + +function LabPanel({ data }: { data: HealthDataStats | null }) { + return ( + 化验报告} style={{ borderRadius: 8 }}> + + + + + + + + + + {(data?.lab_reports.type_distribution ?? []).length > 0 && ( +
+ 类型分布: + {data!.lab_reports.type_distribution.map((item) => ( + {item.name}: {item.value} + ))} +
+ )} +
+ ); +} + +function AppointmentsPanel({ data }: { data: HealthDataStats | null }) { + return ( + 预约统计} style={{ borderRadius: 8 }}> + + + + + + {(data?.appointments.status_distribution ?? []).length > 0 && ( +
+ 状态: + {data!.appointments.status_distribution.map((item) => ( + {item.name}: {item.value} + ))} +
+ )} +
+ ); +} + +function VitalSignsPanel({ data }: { data: HealthDataStats | null }) { + return ( + 体征上报率} style={{ borderRadius: 8 }}> + + + + + + {(data?.vital_signs_report_rate.daily_trend ?? []).length > 0 && ( +
+ 近 7 天: +
+ {data!.vital_signs_report_rate.daily_trend.map((d) => ( + = 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}> + {d.date.slice(5)} {d.reported}/{d.total} + + ))} +
+
+ )} +
+ ); +} + +const TAB_PANELS: Record> = { + dialysis: DialysisPanel, + lab: LabPanel, + appointments: AppointmentsPanel, + 'vital-signs': VitalSignsPanel, +}; + +export default function HealthDataCenter({ data, tab = 'dialysis' }: HealthDataCenterProps) { + const Panel = TAB_PANELS[tab]; + + if (!Panel) { + return ; + } + return ( - - 透析记录} style={{ borderRadius: 8 }}> - - - - - - - - - - - {(data?.dialysis.type_distribution ?? []).length > 0 && ( -
- 类型分布: - {data!.dialysis.type_distribution.map((item) => ( - {item.name}: {item.value} - ))} -
- )} -
- - - - 化验报告} style={{ borderRadius: 8 }}> - - - - - - - - - - {(data?.lab_reports.type_distribution ?? []).length > 0 && ( -
- 类型分布: - {data!.lab_reports.type_distribution.map((item) => ( - {item.name}: {item.value} - ))} -
- )} -
- - - - 预约统计} style={{ borderRadius: 8 }}> - - - - - - {(data?.appointments.status_distribution ?? []).length > 0 && ( -
- 状态: - {data!.appointments.status_distribution.map((item) => ( - {item.name}: {item.value} - ))} -
- )} -
- - - - 体征上报率} style={{ borderRadius: 8 }}> - - - - - - {(data?.vital_signs_report_rate.daily_trend ?? []).length > 0 && ( -
- 近 7 天: -
- {data!.vital_signs_report_rate.daily_trend.map((d) => ( - = 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}> - {d.date.slice(5)} {d.reported}/{d.total} - - ))} -
-
- )} -
+ +
); diff --git a/crates/erp-points/Cargo.toml b/crates/erp-points/Cargo.toml new file mode 100644 index 0000000..cb1ea3d --- /dev/null +++ b/crates/erp-points/Cargo.toml @@ -0,0 +1,19 @@ +[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/entity/mod.rs b/crates/erp-points/src/entity/mod.rs new file mode 100644 index 0000000..a250793 --- /dev/null +++ b/crates/erp-points/src/entity/mod.rs @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..3982552 --- /dev/null +++ b/crates/erp-points/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-points/src/entity/offline_event_registration.rs b/crates/erp-points/src/entity/offline_event_registration.rs new file mode 100644 index 0000000..1497fce --- /dev/null +++ b/crates/erp-points/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-points/src/entity/points_account.rs b/crates/erp-points/src/entity/points_account.rs new file mode 100644 index 0000000..5fd0a14 --- /dev/null +++ b/crates/erp-points/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-points/src/entity/points_checkin.rs b/crates/erp-points/src/entity/points_checkin.rs new file mode 100644 index 0000000..bd1fa62 --- /dev/null +++ b/crates/erp-points/src/entity/points_checkin.rs @@ -0,0 +1,37 @@ +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 { + #[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-points/src/entity/points_order.rs b/crates/erp-points/src/entity/points_order.rs new file mode 100644 index 0000000..4a8573a --- /dev/null +++ b/crates/erp-points/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-points/src/entity/points_product.rs b/crates/erp-points/src/entity/points_product.rs new file mode 100644 index 0000000..317ab08 --- /dev/null +++ b/crates/erp-points/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-points/src/entity/points_rule.rs b/crates/erp-points/src/entity/points_rule.rs new file mode 100644 index 0000000..b414526 --- /dev/null +++ b/crates/erp-points/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-points/src/entity/points_transaction.rs b/crates/erp-points/src/entity/points_transaction.rs new file mode 100644 index 0000000..894f29f --- /dev/null +++ b/crates/erp-points/src/entity/points_transaction.rs @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000..b651850 --- /dev/null +++ b/crates/erp-points/src/error.rs @@ -0,0 +1,51 @@ +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()) + } +} + +pub type PointsResult = Result; diff --git a/crates/erp-points/src/event.rs b/crates/erp-points/src/event.rs new file mode 100644 index 0000000..ac5d8c5 --- /dev/null +++ b/crates/erp-points/src/event.rs @@ -0,0 +1,15 @@ +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/lib.rs b/crates/erp-points/src/lib.rs new file mode 100644 index 0000000..0ce1e14 --- /dev/null +++ b/crates/erp-points/src/lib.rs @@ -0,0 +1,8 @@ +pub mod dto; +pub mod entity; +pub mod error; +pub mod event; +pub mod handler; +pub mod module; +pub mod service; +pub mod state; diff --git a/crates/erp-points/src/module.rs b/crates/erp-points/src/module.rs new file mode 100644 index 0000000..08577a7 --- /dev/null +++ b/crates/erp-points/src/module.rs @@ -0,0 +1,128 @@ +use axum::Router; +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 + } +} + +impl ErpModule for PointsModule { + fn name(&self) -> &str { + "points" + } + + fn module_id(&self) -> &str { + "erp-points" + } + + fn module_type(&self) -> ModuleType { + ModuleType::Business + } + + fn on_startup(&self, ctx: ModuleContext) { + let state = PointsState { + db: ctx.db.clone(), + event_bus: ctx.event_bus.clone(), + }; + crate::event::register_handlers_with_state(state); + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "points.account.list".into(), + name: "积分账户列表".into(), + }, + PermissionDescriptor { + code: "points.account.manage".into(), + name: "积分账户管理".into(), + }, + PermissionDescriptor { + code: "points.product.list".into(), + name: "积分商品列表".into(), + }, + PermissionDescriptor { + code: "points.product.manage".into(), + name: "积分商品管理".into(), + }, + PermissionDescriptor { + code: "points.order.list".into(), + name: "积分订单列表".into(), + }, + PermissionDescriptor { + code: "points.order.manage".into(), + name: "积分订单管理".into(), + }, + PermissionDescriptor { + code: "points.rule.list".into(), + name: "积分规则列表".into(), + }, + PermissionDescriptor { + code: "points.rule.manage".into(), + name: "积分规则管理".into(), + }, + ] + } + + fn protected_routes(&self) -> 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), + ) + } +} diff --git a/crates/erp-points/src/service/account_service.rs b/crates/erp-points/src/service/account_service.rs new file mode 100644 index 0000000..1a8b6b0 --- /dev/null +++ b/crates/erp-points/src/service/account_service.rs @@ -0,0 +1,437 @@ +//! 积分账户 Service — 获取/创建账户、积分获取、流水查询、积分统计 + +use chrono::{Duration, Utc}; +use sea_orm::entity::prelude::*; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveValue::Set, TransactionTrait}; +use uuid::Uuid; + +use 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/state.rs b/crates/erp-points/src/state.rs new file mode 100644 index 0000000..13005cf --- /dev/null +++ b/crates/erp-points/src/state.rs @@ -0,0 +1,8 @@ +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; + +#[derive(Clone)] +pub struct PointsState { + pub db: DatabaseConnection, + pub event_bus: EventBus, +}