diff --git a/Cargo.lock b/Cargo.lock index 0db747b..fe2b206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1625,6 +1625,25 @@ dependencies = [ "wit-bindgen 0.55.0", ] +[[package]] +name = "erp-points" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "chrono", + "erp-core", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", + "validator", +] + [[package]] name = "erp-server" version = "0.1.0" @@ -1640,6 +1659,7 @@ dependencies = [ "erp-health", "erp-message", "erp-plugin", + "erp-points", "erp-server-migration", "erp-workflow", "moka", diff --git a/Cargo.toml b/Cargo.toml index c157830..42a9eb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/erp-plugin-itops", "crates/erp-health", "crates/erp-ai", + "crates/erp-points", "crates/erp-plugin-assessment", "crates/erp-dialysis", ] @@ -105,6 +106,7 @@ erp-config = { path = "crates/erp-config" } erp-plugin = { path = "crates/erp-plugin" } erp-health = { path = "crates/erp-health" } erp-ai = { path = "crates/erp-ai" } +erp-points = { path = "crates/erp-points" } # Async streaming futures = "0.3" diff --git a/crates/erp-points/src/dto/mod.rs b/crates/erp-points/src/dto/mod.rs new file mode 100644 index 0000000..c29ed33 --- /dev/null +++ b/crates/erp-points/src/dto/mod.rs @@ -0,0 +1,6 @@ +pub mod points_dto; + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteWithVersion { + pub version: i32, +} diff --git a/crates/erp-points/src/dto/points_dto.rs b/crates/erp-points/src/dto/points_dto.rs new file mode 100644 index 0000000..056a553 --- /dev/null +++ b/crates/erp-points/src/dto/points_dto.rs @@ -0,0 +1,314 @@ +use chrono::NaiveDate; +use erp_core::sanitize::sanitize_option; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// 积分账户 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PointsAccountResp { + pub id: Uuid, + pub patient_id: Uuid, + pub balance: i32, + pub total_earned: i32, + pub total_spent: i32, + pub total_expired: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateAccountReq { + pub patient_id: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CheckinStatusResp { + pub checked_in_today: bool, + pub consecutive_days: i32, + pub next_streak_milestone: Option, +} + +// --------------------------------------------------------------------------- +// 积分流水 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PointsTransactionResp { + pub id: Uuid, + pub account_id: Uuid, + pub transaction_type: String, + pub amount: i32, + pub remaining_amount: i32, + pub status: String, + pub expires_at: Option>, + pub balance_after: i32, + pub description: Option, + pub created_at: chrono::DateTime, +} + +// --------------------------------------------------------------------------- +// 积分规则 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreatePointsRuleReq { + pub event_type: String, + pub name: String, + pub description: Option, + pub points_value: i32, + #[serde(default)] + pub daily_cap: i32, + #[serde(default)] + pub streak_7d_bonus: i32, + #[serde(default)] + pub streak_14d_bonus: i32, + #[serde(default)] + pub streak_30d_bonus: i32, +} + +impl CreatePointsRuleReq { + pub fn sanitize(&mut self) { + self.description = sanitize_option(self.description.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdatePointsRuleReq { + pub name: Option, + pub description: Option, + pub points_value: Option, + pub daily_cap: Option, + pub streak_7d_bonus: Option, + pub streak_14d_bonus: Option, + pub streak_30d_bonus: Option, + pub is_active: Option, +} + +impl UpdatePointsRuleReq { + pub fn sanitize(&mut self) { + self.description = sanitize_option(self.description.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PointsRuleResp { + pub id: Uuid, + pub event_type: String, + pub name: String, + pub description: Option, + pub points_value: i32, + pub daily_cap: i32, + pub streak_7d_bonus: i32, + pub streak_14d_bonus: i32, + pub streak_30d_bonus: i32, + pub is_active: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +// --------------------------------------------------------------------------- +// 兑换商品 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreatePointsProductReq { + pub name: String, + pub product_type: Option, + pub points_cost: i32, + pub stock: Option, + pub image_url: Option, + pub description: Option, + pub service_config: Option, + pub sort_order: Option, +} + +impl CreatePointsProductReq { + pub fn sanitize(&mut self) { + self.description = sanitize_option(self.description.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdatePointsProductReq { + pub name: Option, + pub product_type: Option, + pub points_cost: Option, + pub stock: Option, + pub image_url: Option, + pub description: Option, + pub service_config: Option, + pub is_active: Option, + pub sort_order: Option, +} + +impl UpdatePointsProductReq { + pub fn sanitize(&mut self) { + self.description = sanitize_option(self.description.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PointsProductResp { + pub id: Uuid, + pub name: String, + pub product_type: String, + pub points_cost: i32, + pub stock: i32, + pub image_url: Option, + pub description: Option, + pub is_active: bool, + pub sort_order: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +// --------------------------------------------------------------------------- +// 兑换订单 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateOrderReq { + pub product_id: Uuid, + pub patient_id: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PointsOrderResp { + pub id: Uuid, + pub patient_id: Uuid, + pub product_id: Uuid, + pub product_name: Option, + pub points_cost: i32, + pub status: String, + pub qr_code: Option, + pub verified_by: Option, + pub verified_at: Option>, + pub expires_at: Option>, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct VerifyOrderReq { + pub qr_code: Uuid, +} + +// --------------------------------------------------------------------------- +// 线下活动 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateOfflineEventReq { + pub title: String, + pub description: Option, + pub event_date: NaiveDate, + pub start_time: Option, + pub end_time: Option, + pub location: Option, + pub points_reward: Option, + pub max_participants: Option, + pub image_url: Option, +} + +impl CreateOfflineEventReq { + pub fn sanitize(&mut self) { + self.description = sanitize_option(self.description.take()); + self.location = sanitize_option(self.location.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateOfflineEventReq { + pub title: Option, + pub description: Option, + pub event_date: Option, + pub start_time: Option, + pub end_time: Option, + pub location: Option, + pub points_reward: Option, + pub max_participants: Option, + pub status: Option, + pub image_url: Option, +} + +impl UpdateOfflineEventReq { + pub fn sanitize(&mut self) { + self.description = sanitize_option(self.description.take()); + self.location = sanitize_option(self.location.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct OfflineEventResp { + pub id: Uuid, + pub title: String, + pub description: Option, + pub event_date: NaiveDate, + pub start_time: Option, + pub end_time: Option, + pub location: Option, + pub points_reward: i32, + pub max_participants: i32, + pub current_participants: i32, + pub status: String, + pub image_url: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +// --------------------------------------------------------------------------- +// 管理端:带版本号的更新/删除包装 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateRuleWithVersion { + pub data: UpdatePointsRuleReq, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateProductWithVersion { + pub data: UpdatePointsProductReq, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateOfflineEventWithVersion { + pub data: UpdateOfflineEventReq, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct AdminCheckinReq { + pub patient_id: Uuid, +} + +// --------------------------------------------------------------------------- +// 积分统计 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PointsStatisticsResp { + pub total_issued: i64, + pub total_spent: i64, + pub total_expired: i64, + pub active_accounts: i64, + pub top_earners: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TopEarner { + pub account_id: Uuid, + pub patient_id: Uuid, + pub total_earned: i32, +} diff --git a/crates/erp-points/src/entity/points_account.rs b/crates/erp-points/src/entity/points_account.rs index 5fd0a14..8501902 100644 --- a/crates/erp-points/src/entity/points_account.rs +++ b/crates/erp-points/src/entity/points_account.rs @@ -24,19 +24,6 @@ pub struct Model { } #[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() - } -} +pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_checkin.rs b/crates/erp-points/src/entity/points_checkin.rs index bd1fa62..8564c85 100644 --- a/crates/erp-points/src/entity/points_checkin.rs +++ b/crates/erp-points/src/entity/points_checkin.rs @@ -19,19 +19,6 @@ pub struct Model { } #[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() - } -} +pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/entity/points_order.rs b/crates/erp-points/src/entity/points_order.rs index 4a8573a..d8c9c80 100644 --- a/crates/erp-points/src/entity/points_order.rs +++ b/crates/erp-points/src/entity/points_order.rs @@ -33,19 +33,6 @@ pub struct Model { } #[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() - } -} +pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-points/src/error.rs b/crates/erp-points/src/error.rs index b651850..4061fc0 100644 --- a/crates/erp-points/src/error.rs +++ b/crates/erp-points/src/error.rs @@ -48,4 +48,14 @@ impl From for PointsError { } } +impl From for PointsError { + fn from(err: AppError) -> Self { + match err { + AppError::VersionMismatch => PointsError::VersionMismatch, + AppError::Validation(s) => PointsError::Validation(s), + other => PointsError::DbError(other.to_string()), + } + } +} + pub type PointsResult = Result; diff --git a/crates/erp-points/src/handler/mod.rs b/crates/erp-points/src/handler/mod.rs new file mode 100644 index 0000000..03dc437 --- /dev/null +++ b/crates/erp-points/src/handler/mod.rs @@ -0,0 +1 @@ +pub mod points_handler; diff --git a/crates/erp-points/src/handler/points_handler.rs b/crates/erp-points/src/handler/points_handler.rs new file mode 100644 index 0000000..1c29798 --- /dev/null +++ b/crates/erp-points/src/handler/points_handler.rs @@ -0,0 +1,291 @@ +//! 积分模块 Handler — 管理端 CRUD + 简化端点 + +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use axum::http::StatusCode; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::dto::points_dto::*; +use crate::service::{account_service, product_service}; +use crate::state::PointsState; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PaginationParams { + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ProductTypeParam { + pub product_type: Option, +} + +// --------------------------------------------------------------------------- +// 积分账户 — 管理端 +// --------------------------------------------------------------------------- + +/// 创建积分账户(管理端) +pub async fn create_account( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.account.manage")?; + let result = account_service::get_account(&state, ctx.tenant_id, req.patient_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 列出积分账户(管理端 — 501 待实现) +pub async fn list_accounts( + State(_state): State, + Extension(ctx): Extension, + Query(_params): Query, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.account.list")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分账户列表接口待实现")))) +} + +/// 获取单个积分账户 +pub async fn get_account( + State(state): State, + Extension(ctx): Extension, + Path(patient_id): Path, +) -> Result>, AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.account.list")?; + let result = account_service::get_account(&state, ctx.tenant_id, patient_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +// --------------------------------------------------------------------------- +// 积分商品 +// --------------------------------------------------------------------------- + +/// 列出积分商品 +pub async fn list_products( + State(state): State, + Extension(ctx): Extension, + Query(pt): Query, + Query(page): Query, +) -> Result>>, AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.product.list")?; + let p = page.page.unwrap_or(1); + let ps = page.page_size.unwrap_or(20); + let result = product_service::list_products( + &state, ctx.tenant_id, pt.product_type, p, ps, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 获取单个商品 +pub async fn get_product( + State(state): State, + Extension(ctx): Extension, + Path(product_id): Path, +) -> Result>, AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.product.list")?; + let result = product_service::get_product(&state, ctx.tenant_id, product_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 创建积分商品 +pub async fn create_product( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.product.manage")?; + let mut req = req; + req.sanitize(); + let result = product_service::create_product( + &state, ctx.tenant_id, Some(ctx.user_id), req, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 更新积分商品(乐观锁) +pub async fn update_product( + State(state): State, + Extension(ctx): Extension, + Path(product_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.product.manage")?; + let mut data = wrapper.data; + data.sanitize(); + let result = product_service::update_product( + &state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 删除积分商品(软删除 + 乐观锁) +pub async fn delete_product( + State(state): State, + Extension(ctx): Extension, + Path(product_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.product.manage")?; + product_service::delete_product( + &state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(()))) +} + +// --------------------------------------------------------------------------- +// 积分订单 — 501 待迁移 +// --------------------------------------------------------------------------- + +/// 列出积分订单(501 待实现) +pub async fn list_orders( + State(_state): State, + Extension(ctx): Extension, + Query(_params): Query, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.order.list")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分订单列表接口待实现")))) +} + +/// 创建积分订单/兑换商品(501 待实现) +pub async fn create_order( + State(_state): State, + Extension(ctx): Extension, + Json(_req): Json, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.order.manage")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分订单创建接口待实现")))) +} + +/// 获取积分订单详情(501 待实现) +pub async fn get_order( + State(_state): State, + Extension(ctx): Extension, + Path(_id): Path, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.order.list")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分订单详情接口待实现")))) +} + +// --------------------------------------------------------------------------- +// 积分规则 — 501 待迁移 +// --------------------------------------------------------------------------- + +/// 列出积分规则(501 待实现) +pub async fn list_rules( + State(_state): State, + Extension(ctx): Extension, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.rule.list")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则列表接口待实现")))) +} + +/// 创建积分规则(501 待实现) +pub async fn create_rule( + State(_state): State, + Extension(ctx): Extension, + Json(_req): Json, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.rule.manage")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则创建接口待实现")))) +} + +/// 获取积分规则详情(501 待实现) +pub async fn get_rule( + State(_state): State, + Extension(ctx): Extension, + Path(_id): Path, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.rule.list")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则详情接口待实现")))) +} + +/// 更新积分规则(501 待实现) +pub async fn update_rule( + State(_state): State, + Extension(ctx): Extension, + Path(_id): Path, + Json(_wrapper): Json, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.rule.manage")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则更新接口待实现")))) +} + +/// 删除积分规则(501 待实现) +pub async fn delete_rule( + State(_state): State, + Extension(ctx): Extension, + Path(_id): Path, + Json(_wrapper): Json, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.rule.manage")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分规则删除接口待实现")))) +} + +// --------------------------------------------------------------------------- +// 每日打卡 +// --------------------------------------------------------------------------- + +/// 每日打卡签到(501 待实现) +pub async fn check_in( + State(_state): State, + Extension(ctx): Extension, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.account.manage")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("每日打卡接口待实现")))) +} + +// --------------------------------------------------------------------------- +// 积分流水 +// --------------------------------------------------------------------------- + +/// 列出积分流水(501 待实现 — 需 order_service 提供 patient_id 参数) +pub async fn list_transactions( + State(_state): State, + Extension(ctx): Extension, + Query(_params): Query, +) -> Result<(StatusCode, Json>), AppError> +where PointsState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "points.account.list")?; + Ok((StatusCode::NOT_IMPLEMENTED, Json(ApiResponse::ok("积分流水列表接口待实现")))) +} diff --git a/crates/erp-points/src/lib.rs b/crates/erp-points/src/lib.rs index 0ce1e14..034ac5b 100644 --- a/crates/erp-points/src/lib.rs +++ b/crates/erp-points/src/lib.rs @@ -6,3 +6,6 @@ pub mod handler; pub mod module; pub mod service; pub mod state; + +pub use module::PointsModule; +pub use state::PointsState; diff --git a/crates/erp-points/src/module.rs b/crates/erp-points/src/module.rs index 08577a7..cecb893 100644 --- a/crates/erp-points/src/module.rs +++ b/crates/erp-points/src/module.rs @@ -1,4 +1,6 @@ +use async_trait::async_trait; use axum::Router; +use erp_core::error::AppResult; use erp_core::module::{ErpModule, ModuleContext, ModuleType, PermissionDescriptor}; use crate::handler::points_handler; @@ -10,67 +12,8 @@ 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 + pub fn protected_routes() -> Router where PointsState: axum::extract::FromRef, S: Clone + Send + Sync + 'static, @@ -126,3 +69,79 @@ impl ErpModule for PointsModule { ) } } + +#[async_trait] +impl ErpModule for PointsModule { + fn name(&self) -> &str { + "积分商城" + } + + fn id(&self) -> &str { + "erp-points" + } + + fn module_type(&self) -> ModuleType { + ModuleType::Builtin + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "points.account.list".into(), + name: "积分账户列表".into(), + description: "查看积分账户列表和详情".into(), + module: "erp-points".into(), + }, + PermissionDescriptor { + code: "points.account.manage".into(), + name: "积分账户管理".into(), + description: "管理积分账户".into(), + module: "erp-points".into(), + }, + PermissionDescriptor { + code: "points.product.list".into(), + name: "积分商品列表".into(), + description: "查看积分商品列表和详情".into(), + module: "erp-points".into(), + }, + PermissionDescriptor { + code: "points.product.manage".into(), + name: "积分商品管理".into(), + description: "创建、编辑、删除积分商品".into(), + module: "erp-points".into(), + }, + PermissionDescriptor { + code: "points.order.list".into(), + name: "积分订单列表".into(), + description: "查看积分订单列表和详情".into(), + module: "erp-points".into(), + }, + PermissionDescriptor { + code: "points.order.manage".into(), + name: "积分订单管理".into(), + description: "创建、核销积分订单".into(), + module: "erp-points".into(), + }, + PermissionDescriptor { + code: "points.rule.list".into(), + name: "积分规则列表".into(), + description: "查看积分规则列表和详情".into(), + module: "erp-points".into(), + }, + PermissionDescriptor { + code: "points.rule.manage".into(), + name: "积分规则管理".into(), + description: "创建、编辑、删除积分规则".into(), + module: "erp-points".into(), + }, + ] + } + + async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { + Ok(()) + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/erp-points/src/service/account_service.rs b/crates/erp-points/src/service/account_service.rs index 1a8b6b0..2fea63f 100644 --- a/crates/erp-points/src/service/account_service.rs +++ b/crates/erp-points/src/service/account_service.rs @@ -3,7 +3,7 @@ use chrono::{Duration, Utc}; use sea_orm::entity::prelude::*; use sea_orm::sea_query::Expr; -use sea_orm::{ActiveValue::Set, TransactionTrait}; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect, TransactionTrait}; use uuid::Uuid; use erp_core::audit::AuditLog; diff --git a/crates/erp-points/src/service/mod.rs b/crates/erp-points/src/service/mod.rs new file mode 100644 index 0000000..6ddaaa3 --- /dev/null +++ b/crates/erp-points/src/service/mod.rs @@ -0,0 +1,2 @@ +pub mod account_service; +pub mod product_service; diff --git a/crates/erp-points/src/service/product_service.rs b/crates/erp-points/src/service/product_service.rs new file mode 100644 index 0000000..426c50e --- /dev/null +++ b/crates/erp-points/src/service/product_service.rs @@ -0,0 +1,282 @@ +//! 积分商品管理 Service — CRUD + 乐观锁 + +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; + +use crate::dto::points_dto::*; +use crate::entity::points_product; +use crate::error::{PointsError, PointsResult}; +use crate::state::PointsState; + +/// 分页列出商品(可按类型筛选) +pub async fn list_products( + state: &PointsState, + tenant_id: Uuid, + product_type: Option, + page: u64, + page_size: u64, +) -> PointsResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = points_product::Entity::find() + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::IsActive.eq(true)) + .filter(points_product::Column::DeletedAt.is_null()); + + if let Some(ref pt) = product_type { + query = query.filter(points_product::Column::ProductType.eq(pt.as_str())); + } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_asc(points_product::Column::SortOrder) + .order_by_desc(points_product::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models + .into_iter() + .map(|m| PointsProductResp { + id: m.id, + name: m.name, + product_type: m.product_type, + points_cost: m.points_cost, + stock: m.stock, + image_url: m.image_url, + description: m.description, + is_active: m.is_active, + sort_order: m.sort_order, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }) + .collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size: limit, + total_pages, + }) +} + +/// 获取单个商品 +pub async fn get_product( + state: &PointsState, + tenant_id: Uuid, + product_id: Uuid, +) -> PointsResult { + let m = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(PointsError::PointsProductNotFound)?; + + Ok(PointsProductResp { + id: m.id, + name: m.name, + product_type: m.product_type, + points_cost: m.points_cost, + stock: m.stock, + image_url: m.image_url, + description: m.description, + is_active: m.is_active, + sort_order: m.sort_order, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }) +} + +/// 创建商品 +pub async fn create_product( + state: &PointsState, + tenant_id: Uuid, + operator_id: Option, + req: CreatePointsProductReq, +) -> PointsResult { + let now = Utc::now(); + let active = points_product::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + name: Set(req.name), + product_type: Set(req.product_type.unwrap_or_else(|| "physical".into())), + points_cost: Set(req.points_cost), + stock: Set(req.stock.unwrap_or(-1)), + image_url: Set(req.image_url), + description: Set(req.description), + service_config: Set(req.service_config), + is_active: Set(true), + sort_order: Set(req.sort_order.unwrap_or(0)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new( + tenant_id, + operator_id, + "points_product.created", + "points_product", + ) + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(PointsProductResp { + id: m.id, + name: m.name, + product_type: m.product_type, + points_cost: m.points_cost, + stock: m.stock, + image_url: m.image_url, + description: m.description, + is_active: m.is_active, + sort_order: m.sort_order, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }) +} + +/// 更新商品(乐观锁) +pub async fn update_product( + state: &PointsState, + tenant_id: Uuid, + product_id: Uuid, + operator_id: Option, + req: UpdatePointsProductReq, + expected_version: i32, +) -> PointsResult { + let model = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(PointsError::PointsProductNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_product::ActiveModel = model.into(); + if let Some(name) = req.name { + active.name = Set(name); + } + if let Some(product_type) = req.product_type { + active.product_type = Set(product_type); + } + if let Some(points_cost) = req.points_cost { + active.points_cost = Set(points_cost); + } + if let Some(stock) = req.stock { + active.stock = Set(stock); + } + if let Some(image_url) = req.image_url { + active.image_url = Set(Some(image_url)); + } + if let Some(description) = req.description { + active.description = Set(Some(description)); + } + if let Some(service_config) = req.service_config { + active.service_config = Set(Some(service_config)); + } + if let Some(is_active) = req.is_active { + active.is_active = Set(is_active); + } + if let Some(sort_order) = req.sort_order { + active.sort_order = Set(sort_order); + } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new( + tenant_id, + operator_id, + "points_product.updated", + "points_product", + ) + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(PointsProductResp { + id: m.id, + name: m.name, + product_type: m.product_type, + points_cost: m.points_cost, + stock: m.stock, + image_url: m.image_url, + description: m.description, + is_active: m.is_active, + sort_order: m.sort_order, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }) +} + +/// 软删除商品 +pub async fn delete_product( + state: &PointsState, + tenant_id: Uuid, + product_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> PointsResult<()> { + let model = points_product::Entity::find() + .filter(points_product::Column::Id.eq(product_id)) + .filter(points_product::Column::TenantId.eq(tenant_id)) + .filter(points_product::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(PointsError::PointsProductNotFound)?; + + let _next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: points_product::ActiveModel = model.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new( + tenant_id, + operator_id, + "points_product.deleted", + "points_product", + ) + .with_resource_id(m.id), + &state.db, + ) + .await; + + Ok(()) +} diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index 3c9f7d7..083d7af 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -30,6 +30,7 @@ erp-message.workspace = true erp-plugin.workspace = true erp-health.workspace = true erp-ai.workspace = true +erp-points.workspace = true anyhow.workspace = true uuid.workspace = true chrono.workspace = true diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index d443af1..8271ca0 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -349,6 +349,14 @@ async fn main() -> anyhow::Result<()> { "AI module initialized" ); + // Initialize points module + let points_module = erp_points::PointsModule; + tracing::info!( + module = points_module.name(), + version = points_module.version(), + "Points module initialized" + ); + // Initialize module registry and register modules let registry = ModuleRegistry::new() .register(auth_module) @@ -356,7 +364,8 @@ async fn main() -> anyhow::Result<()> { .register(workflow_module) .register(message_module) .register(health_module) - .register(ai_module); + .register(ai_module) + .register(points_module); tracing::info!( module_count = registry.modules().len(), "Modules registered" @@ -535,6 +544,7 @@ async fn main() -> anyhow::Result<()> { .merge(erp_plugin::module::PluginModule::protected_routes()) .merge(erp_health::HealthModule::protected_routes()) .merge(erp_ai::AiModule::protected_routes()) + .merge(erp_points::PointsModule::protected_routes()) .merge(handlers::audit_log::audit_log_router()) .route( "/upload", diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index 2586380..6ae857b 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -122,3 +122,13 @@ impl FromRef for erp_ai::AiState { state.ai_state.clone() } } + +/// Allow erp-points handlers to extract their required state. +impl FromRef for erp_points::PointsState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + } + } +}