From 7b18a7398d4ec2acd7a2fde6645af709d5bd5619 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 17:34:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E7=BA=BF=E4=B8=8B=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E7=AE=A1=E7=90=86=E7=AB=AF=20CRUD=20+=20=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E7=BB=9F=E8=AE=A1=20API=20+=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20(Chunk=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 线下活动管理: create/update/delete/list/checkin 5 个管理端接口 - 活动签到自动发放积分 (事务内原子操作) - 积分统计 API: 总发放/总消耗/总过期/活跃账户/Top10排行 前端: - OfflineEventList: 活动管理页面 (创建/编辑/删除/状态筛选) - points.ts 扩展: 线下活动 + 统计 API 方法 - 侧边栏新增线下活动入口 --- apps/web/src/App.tsx | 2 + apps/web/src/api/health/points.ts | 83 ++++ apps/web/src/layouts/MainLayout.tsx | 2 + .../web/src/pages/health/OfflineEventList.tsx | 398 ++++++++++++++++++ crates/erp-health/src/dto/points_dto.rs | 35 ++ .../erp-health/src/handler/points_handler.rs | 105 +++++ crates/erp-health/src/module.rs | 20 + .../erp-health/src/service/points_service.rs | 342 +++++++++++++++ 8 files changed, 987 insertions(+) create mode 100644 apps/web/src/pages/health/OfflineEventList.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4f1f70b..fbc959d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -38,6 +38,7 @@ const ConsultationDetail = lazy(() => import('./pages/health/ConsultationDetail' const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList')); const PointsProductList = lazy(() => import('./pages/health/PointsProductList')); const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList')); +const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList')); function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -182,6 +183,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index 0af35ef..86e3a12 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -75,6 +75,49 @@ export interface VerifyOrderReq { qr_code: string; } +export interface OfflineEvent { + id: string; + title: string; + description: string | null; + event_date: string; + start_time: string | null; + end_time: string | null; + location: string | null; + points_reward: number; + max_participants: number; + current_participants: number; + status: string; // draft / published / ongoing / completed / cancelled + image_url: string | null; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateOfflineEventReq { + title: string; + description?: string; + event_date: string; + start_time?: string; + end_time?: string; + location?: string; + points_reward?: number; + max_participants?: number; + status?: string; + image_url?: string; +} + +export interface PointsStatistics { + total_issued: number; + total_spent: number; + total_expired: number; + active_accounts: number; + top_earners: Array<{ + account_id: string; + patient_id: string; + total_earned: number; + }>; +} + // --- API --- export const pointsApi = { @@ -128,4 +171,44 @@ export const pointsApi = { }>('/health/points/verify', req); return data.data; }, + + // Offline Events + listOfflineEvents: async (params?: Record) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>('/health/admin/offline-events', { params }); + return data.data; + }, + + createOfflineEvent: async (req: CreateOfflineEventReq) => { + const { data } = await client.post<{ + success: boolean; + data: OfflineEvent; + }>('/health/admin/offline-events', req); + return data.data; + }, + + updateOfflineEvent: async (id: string, req: Partial & { version: number }) => { + const { data } = await client.put<{ + success: boolean; + data: OfflineEvent; + }>(`/health/admin/offline-events/${id}`, req); + return data.data; + }, + + deleteOfflineEvent: async (id: string, version: number) => { + await client.delete(`/health/admin/offline-events/${id}`, { + data: { version }, + }); + }, + + // Points Statistics + getStatistics: async () => { + const { data } = await client.get<{ + success: boolean; + data: PointsStatistics; + }>('/health/admin/points/statistics'); + return data.data; + }, }; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 641cc2a..84c53ad 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -66,6 +66,7 @@ const healthMenuItems: MenuItem[] = [ { key: '/health/points-rules', icon: , label: '积分规则' }, { key: '/health/points-products', icon: , label: '商品管理' }, { key: '/health/points-orders', icon: , label: '订单管理' }, + { key: '/health/offline-events', icon: , label: '线下活动' }, ]; const sysMenuItems: MenuItem[] = [ @@ -95,6 +96,7 @@ const routeTitleMap: Record = { '/health/points-rules': '积分规则管理', '/health/points-products': '商品管理', '/health/points-orders': '订单管理', + '/health/offline-events': '线下活动管理', }; // 侧边栏菜单项 - 提取为独立组件避免重复渲染 diff --git a/apps/web/src/pages/health/OfflineEventList.tsx b/apps/web/src/pages/health/OfflineEventList.tsx new file mode 100644 index 0000000..bb2f183 --- /dev/null +++ b/apps/web/src/pages/health/OfflineEventList.tsx @@ -0,0 +1,398 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Table, + Button, + Space, + Modal, + Form, + Input, + InputNumber, + Select, + Badge, + Popconfirm, + message, + Card, + Row, + Col, + DatePicker, + TimePicker, +} from 'antd'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, + CheckCircleOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; +import { + pointsApi, + type OfflineEvent, + type CreateOfflineEventReq, +} from '../../api/health/points'; + +/** 活动状态映射 */ +const STATUS_MAP: Record = { + draft: { text: '草稿', color: 'default' }, + published: { text: '已发布', color: 'processing' }, + ongoing: { text: '进行中', color: 'success' }, + completed: { text: '已结束', color: 'default' }, + cancelled: { text: '已取消', color: 'error' }, +}; + +/** 活动状态选项 */ +const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({ + value, + label: text, +})); + +export default function OfflineEventList() { + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [loading, setLoading] = useState(false); + const [statusFilter, setStatusFilter] = useState(undefined); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + // ---- 数据获取 ---- + const fetchData = useCallback(async (p = page, ps = pageSize) => { + setLoading(true); + try { + const result = await pointsApi.listOfflineEvents({ + page: p, + page_size: ps, + status: statusFilter || undefined, + }); + setData(result.data); + setTotal(result.total); + } catch { + message.error('加载活动列表失败'); + } finally { + setLoading(false); + } + }, [page, pageSize, statusFilter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // ---- 新建 / 编辑 ---- + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ + points_reward: 0, + max_participants: 0, + status: 'draft', + }); + setModalOpen(true); + }; + + const openEdit = (record: OfflineEvent) => { + setEditing(record); + form.setFieldsValue({ + title: record.title, + description: record.description, + event_date: record.event_date ? dayjs(record.event_date) : undefined, + start_time: record.start_time ? dayjs(record.start_time, 'HH:mm') : undefined, + end_time: record.end_time ? dayjs(record.end_time, 'HH:mm') : undefined, + location: record.location, + points_reward: record.points_reward, + max_participants: record.max_participants, + status: record.status, + image_url: record.image_url, + }); + setModalOpen(true); + }; + + const handleSubmit = async (values: { + title: string; + description?: string; + event_date: Dayjs; + start_time?: Dayjs; + end_time?: Dayjs; + location?: string; + points_reward: number; + max_participants: number; + status: string; + image_url?: string; + }) => { + try { + const req: CreateOfflineEventReq = { + title: values.title, + description: values.description, + event_date: values.event_date.format('YYYY-MM-DD'), + start_time: values.start_time ? values.start_time.format('HH:mm') : undefined, + end_time: values.end_time ? values.end_time.format('HH:mm') : undefined, + location: values.location, + points_reward: values.points_reward, + max_participants: values.max_participants, + status: values.status, + image_url: values.image_url, + }; + if (editing) { + await pointsApi.updateOfflineEvent(editing.id, { ...req, version: editing.version }); + message.success('更新成功'); + } else { + await pointsApi.createOfflineEvent(req); + message.success('创建成功'); + } + setModalOpen(false); + form.resetFields(); + fetchData(page, pageSize); + } catch { + message.error(editing ? '更新失败' : '创建失败'); + } + }; + + // ---- 删除 ---- + const handleDelete = async (record: OfflineEvent) => { + try { + await pointsApi.deleteOfflineEvent(record.id, record.version); + message.success('删除成功'); + fetchData(page, pageSize); + } catch { + message.error('删除失败'); + } + }; + + // ---- 签到 ---- + const handleCheckin = async (_record: OfflineEvent) => { + message.info('签到功能需配合患者选择,将在后续版本完善'); + }; + + // ---- 列定义 ---- + const columns = [ + { + title: '活动名称', + dataIndex: 'title', + key: 'title', + width: 180, + ellipsis: true, + }, + { + title: '活动日期', + dataIndex: 'event_date', + key: 'event_date', + width: 120, + render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD') : '-'), + }, + { + title: '时间段', + key: 'time_range', + width: 140, + render: (_: unknown, record: OfflineEvent) => { + if (!record.start_time && !record.end_time) return '-'; + return `${record.start_time || '--'} ~ ${record.end_time || '--'}`; + }, + }, + { + title: '地点', + dataIndex: 'location', + key: 'location', + width: 140, + ellipsis: true, + render: (val: string | null) => val || '-', + }, + { + title: '积分奖励', + dataIndex: 'points_reward', + key: 'points_reward', + width: 90, + render: (val: number) => {val}, + }, + { + title: '参与人数', + key: 'participants', + width: 110, + render: (_: unknown, record: OfflineEvent) => { + const max = record.max_participants; + const current = record.current_participants; + const isFull = max > 0 && current >= max; + return ( + + + {current} + + {' / '} + {max === 0 ? '不限' : max} + + ); + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 90, + render: (val: string) => { + const info = STATUS_MAP[val] || { text: val, color: 'default' as const }; + return ; + }, + }, + { + title: '更新时间', + dataIndex: 'updated_at', + key: 'updated_at', + width: 160, + render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'), + }, + { + title: '操作', + key: 'action', + width: 200, + render: (_: unknown, record: OfflineEvent) => ( + + + + handleDelete(record)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ]; + + return ( + + {/* 筛选栏 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/crates/erp-health/src/dto/points_dto.rs b/crates/erp-health/src/dto/points_dto.rs index 0bafa8b..43fa5ab 100644 --- a/crates/erp-health/src/dto/points_dto.rs +++ b/crates/erp-health/src/dto/points_dto.rs @@ -259,3 +259,38 @@ pub struct OfflineEventResp { pub updated_at: chrono::DateTime, 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-health/src/handler/points_handler.rs b/crates/erp-health/src/handler/points_handler.rs index 974fe7b..e3460a3 100644 --- a/crates/erp-health/src/handler/points_handler.rs +++ b/crates/erp-health/src/handler/points_handler.rs @@ -262,6 +262,111 @@ where HealthState: FromRef, S: Clone + Send + Sync + 'static, Ok(Json(ApiResponse::ok(result))) } +// --------------------------------------------------------------------------- +// 线下活动 — 管理端 CRUD + 签到 +// --------------------------------------------------------------------------- + +pub async fn admin_create_event( + 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_offline_event( + &state, ctx.tenant_id, Some(ctx.user_id), req, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn admin_update_event( + State(state): State, + Extension(ctx): Extension, + Path(event_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where HealthState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.points.manage")?; + let mut data = wrapper.data; + data.sanitize(); + let result = points_service::update_offline_event( + &state, ctx.tenant_id, event_id, Some(ctx.user_id), data, wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn admin_delete_event( + State(state): State, + Extension(ctx): Extension, + Path(event_id): Path, + Json(wrapper): Json, +) -> Result>, AppError> +where HealthState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.points.manage")?; + points_service::delete_offline_event( + &state, ctx.tenant_id, event_id, Some(ctx.user_id), wrapper.version, + ).await?; + Ok(Json(ApiResponse::ok(()))) +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct AdminListEventsParams { + pub page: Option, + pub page_size: Option, + pub status: Option, +} + +pub async fn admin_list_events( + 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")?; + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let result = points_service::admin_list_offline_events( + &state, ctx.tenant_id, params.status, page, page_size, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn admin_checkin_event( + State(state): State, + Extension(ctx): Extension, + Path(event_id): Path, + Json(req): Json, +) -> Result>, AppError> +where HealthState: FromRef, S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.points.manage")?; + points_service::admin_checkin_event( + &state, ctx.tenant_id, event_id, req.patient_id, Some(ctx.user_id), + ).await?; + Ok(Json(ApiResponse::ok(()))) +} + +// --------------------------------------------------------------------------- +// 积分统计 — 管理端 +// --------------------------------------------------------------------------- + +pub async fn get_points_statistics( + 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::get_points_statistics(&state, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + // --------------------------------------------------------------------------- // 辅助:通过 user_id 解析 patient_id // --------------------------------------------------------------------------- diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index f5cb753..4b4e2a6 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -340,6 +340,26 @@ impl HealthModule { "/health/admin/points/orders", axum::routing::get(points_handler::admin_list_orders), ) + // 线下活动 — 管理端 + .route( + "/health/admin/offline-events", + axum::routing::get(points_handler::admin_list_events) + .post(points_handler::admin_create_event), + ) + .route( + "/health/admin/offline-events/{id}", + axum::routing::put(points_handler::admin_update_event) + .delete(points_handler::admin_delete_event), + ) + .route( + "/health/admin/offline-events/{id}/checkin", + axum::routing::post(points_handler::admin_checkin_event), + ) + // 积分统计 — 管理端 + .route( + "/health/admin/points/statistics", + axum::routing::get(points_handler::get_points_statistics), + ) } } diff --git a/crates/erp-health/src/service/points_service.rs b/crates/erp-health/src/service/points_service.rs index 9bf62fb..eef6a81 100644 --- a/crates/erp-health/src/service/points_service.rs +++ b/crates/erp-health/src/service/points_service.rs @@ -906,3 +906,345 @@ fn event_to_resp(m: offline_event::Model) -> OfflineEventResp { created_at: m.created_at, updated_at: m.updated_at, version: m.version, } } + +// --------------------------------------------------------------------------- +// 线下活动 — 管理端 CRUD +// --------------------------------------------------------------------------- + +/// 管理端:创建线下活动 +pub async fn create_offline_event( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateOfflineEventReq, +) -> HealthResult { + let now = Utc::now(); + let active = offline_event::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + title: Set(req.title), + description: Set(req.description), + event_date: Set(req.event_date), + start_time: Set(req.start_time), + end_time: Set(req.end_time), + location: Set(req.location), + points_reward: Set(req.points_reward.unwrap_or(0)), + max_participants: Set(req.max_participants.unwrap_or(0)), + current_participants: Set(0), + status: Set("draft".to_string()), + image_url: Set(req.image_url), + 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, "offline_event.created", "offline_event") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(event_to_resp(m)) +} + +/// 管理端:更新线下活动 +pub async fn update_offline_event( + state: &HealthState, + tenant_id: Uuid, + event_id: Uuid, + operator_id: Option, + req: UpdateOfflineEventReq, + expected_version: i32, +) -> HealthResult { + let model = 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()) + .one(&state.db) + .await? + .ok_or(HealthError::OfflineEventNotFound)?; + + let next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: offline_event::ActiveModel = model.into(); + if let Some(title) = req.title { active.title = Set(title); } + if let Some(description) = req.description { active.description = Set(Some(description)); } + if let Some(event_date) = req.event_date { active.event_date = Set(event_date); } + if let Some(start_time) = req.start_time { active.start_time = Set(Some(start_time)); } + if let Some(end_time) = req.end_time { active.end_time = Set(Some(end_time)); } + if let Some(location) = req.location { active.location = Set(Some(location)); } + if let Some(points_reward) = req.points_reward { active.points_reward = Set(points_reward); } + if let Some(max_participants) = req.max_participants { active.max_participants = Set(max_participants); } + if let Some(status) = req.status { active.status = Set(status); } + if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); } + 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, "offline_event.updated", "offline_event") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(event_to_resp(m)) +} + +/// 管理端:软删除线下活动 +pub async fn delete_offline_event( + state: &HealthState, + tenant_id: Uuid, + event_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult<()> { + let model = 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()) + .one(&state.db) + .await? + .ok_or(HealthError::OfflineEventNotFound)?; + + let _next_ver = check_version(expected_version, model.version)?; + + let now = Utc::now(); + let mut active: offline_event::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, "offline_event.deleted", "offline_event") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(()) +} + +/// 管理端:分页列出所有线下活动(可按状态筛选) +pub async fn admin_list_offline_events( + state: &HealthState, + tenant_id: Uuid, + status_filter: Option, + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = offline_event::Entity::find() + .filter(offline_event::Column::TenantId.eq(tenant_id)) + .filter(offline_event::Column::DeletedAt.is_null()); + + if let Some(ref status) = status_filter { + query = query.filter(offline_event::Column::Status.eq(status.as_str())); + } + + 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 admin_checkin_event( + state: &HealthState, + tenant_id: Uuid, + event_id: Uuid, + patient_id: Uuid, + operator_id: Option, +) -> HealthResult<()> { + // 1. 查找活动 + 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()) + .one(&state.db) + .await? + .ok_or(HealthError::OfflineEventNotFound)?; + + // 2. 查找报名记录 + let reg = offline_event_registration::Entity::find() + .filter(offline_event_registration::Column::TenantId.eq(tenant_id)) + .filter(offline_event_registration::Column::EventId.eq(event_id)) + .filter(offline_event_registration::Column::PatientId.eq(patient_id)) + .filter(offline_event_registration::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::Validation("该患者未报名此活动".into()))?; + + if reg.status == "checked_in" { + return Err(HealthError::Validation("该患者已签到".into())); + } + + // 3. 事务:签到 + 发积分 + let txn = state.db.begin().await?; + let now = Utc::now(); + + // 更新报名记录状态 + let mut reg_active: offline_event_registration::ActiveModel = reg.into(); + reg_active.status = Set("checked_in".to_string()); + reg_active.checked_in_at = Set(Some(now)); + reg_active.checked_in_by = Set(operator_id); + reg_active.updated_at = Set(now); + reg_active.updated_by = Set(operator_id); + reg_active.version = Set(reg_active.version.unwrap() + 1); + let updated_reg = reg_active.update(&txn).await?; + + // 4. 如果活动有积分奖励且尚未发放,则发放积分 + if event.points_reward > 0 && !updated_reg.points_granted { + 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 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(event.points_reward), + remaining_amount: Set(event.points_reward), + status: Set("active".to_string()), + expires_at: Set(Some(now + Duration::days(365))), + balance_after: Set(acc.balance + event.points_reward), + rule_id: Set(None), + order_id: Set(None), + description: Set(Some(format!("线下活动签到奖励「{}」: +{}", event.title, event.points_reward))), + 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(&txn).await?; + + // 更新账户余额 + let mut acc_active: points_account::ActiveModel = acc.into(); + acc_active.balance = Set(acc_active.balance.unwrap() + event.points_reward); + acc_active.total_earned = Set(acc_active.total_earned.unwrap() + event.points_reward); + acc_active.updated_at = Set(now); + acc_active.updated_by = Set(operator_id); + acc_active.version = Set(next_ver); + acc_active.update(&txn).await?; + + // 标记积分已发放 + let mut reg_active2: offline_event_registration::ActiveModel = updated_reg.into(); + reg_active2.points_granted = Set(true); + reg_active2.updated_at = Set(now); + reg_active2.version = Set(reg_active2.version.unwrap() + 1); + reg_active2.update(&txn).await?; + } + + txn.commit().await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "offline_event.checked_in", "offline_event_registration") + .with_resource_id(event_id), + &state.db, + ).await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 积分统计 — 管理端 +// --------------------------------------------------------------------------- + +/// 管理端:积分统计汇总 +pub async fn get_points_statistics( + state: &HealthState, + tenant_id: Uuid, +) -> HealthResult { + 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, + }) +}