diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index 3dd8b69..8923ab4 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -141,6 +141,22 @@ export interface FollowUpStatistics { completion_rate: number; } +export interface PersonalStats { + my_patients: number; + new_patients_this_month: number; + follow_up_rate: number; + consultations_this_month: number; + pending_consultations: number; + vital_signs_report_rate: number; + today_appointments: number; + overdue_follow_ups: number; + today_follow_ups: number; + abnormal_vital_signs: number; + vital_signs_reported: number; + vital_signs_total: number; + pending_lab_reviews: number; +} + export interface OverviewStatistics { patients: PatientStatistics; consultations: ConsultationStatistics; @@ -359,4 +375,12 @@ export const pointsApi = { }>('/health/admin/statistics/health-data'); return data.data; }, + + getPersonalStats: async (): Promise => { + const { data } = await client.get<{ + success: boolean; + data: PersonalStats; + }>('/health/admin/statistics/personal-stats'); + return data.data; + }, }; diff --git a/apps/web/src/hooks/useDashboardRole.ts b/apps/web/src/hooks/useDashboardRole.ts new file mode 100644 index 0000000..636ae50 --- /dev/null +++ b/apps/web/src/hooks/useDashboardRole.ts @@ -0,0 +1,19 @@ +import { useAuthStore } from '../stores/auth'; + +type DashboardRole = 'doctor' | 'nurse' | 'admin' | 'operator'; + +const ROLE_PRIORITY: DashboardRole[] = ['doctor', 'nurse', 'admin', 'operator']; + +export function useDashboardRole(): DashboardRole { + const user = useAuthStore(s => s.user); + + if (!user?.roles?.length) return 'admin'; + + const codes = user.roles.map(r => r.code); + for (const role of ROLE_PRIORITY) { + if (codes.some(c => c === role || c.startsWith(role))) return role; + } + return 'admin'; +} + +export type { DashboardRole }; diff --git a/apps/web/src/pages/health/StatisticsDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard.tsx index b891141..01b51d1 100644 --- a/apps/web/src/pages/health/StatisticsDashboard.tsx +++ b/apps/web/src/pages/health/StatisticsDashboard.tsx @@ -1,256 +1,18 @@ -import { - Row, - Col, - Card, - Statistic, - Table, - Spin, - Alert, - Button, - Typography, - Tooltip, -} from 'antd'; -import { - UserOutlined, - MessageOutlined, - PhoneOutlined, - TrophyOutlined, - TeamOutlined, - CalendarOutlined, - ShoppingOutlined, - ReloadOutlined, - CommentOutlined, - MedicineBoxOutlined, - FileTextOutlined, - ClockCircleOutlined, - ArrowUpOutlined, -} from '@ant-design/icons'; -import { useNavigate } from 'react-router-dom'; -import type { PointsStatistics } from '../../api/health/points'; -import { useStatsData } from './StatisticsDashboard/useStatsData'; -import HealthDataCenter from './StatisticsDashboard/HealthDataCenter'; +import { useDashboardRole } from '../../hooks/useDashboardRole'; +import { DoctorDashboard } from './StatisticsDashboard/DoctorDashboard'; +import { NurseDashboard } from './StatisticsDashboard/NurseDashboard'; +import { AdminDashboard } from './StatisticsDashboard/AdminDashboard'; +import { OperatorDashboard } from './StatisticsDashboard/OperatorDashboard'; -const { Title: AntTitle, Text } = Typography; - -interface StatCardConfig { - title: string; - value: number; - suffix?: string; - precision?: number; - prefix: React.ReactNode; - subtitle?: string; - color: string; - bgColor: string; -} - -interface QuickLinkConfig { - title: string; - icon: React.ReactNode; - path: string; - color: string; -} - -interface TopEarnerRow { - rank: number; - patient_id: string; - total_earned: number; -} - -const QUICK_LINKS: QuickLinkConfig[] = [ - { title: '患者管理', icon: , path: '/health/patients', color: '#2563eb' }, - { title: '预约排班', icon: , path: '/health/appointments', color: '#059669' }, - { title: '随访管理', icon: , path: '/health/follow-up-tasks', color: '#d97706' }, - { title: '咨询管理', icon: , path: '/health/consultations', color: '#7c3aed' }, - { title: '积分规则', icon: , path: '/health/points-rules', color: '#dc2626' }, - { title: '商品管理', icon: , path: '/health/points-products', color: '#0891b2' }, - { title: '订单管理', icon: , path: '/health/points-orders', color: '#4f46e5' }, - { title: '线下活动', icon: , path: '/health/offline-events', color: '#be185d' }, -]; - -function buildStatCards(stats: ReturnType): StatCardConfig[] { - return [ - { - title: '患者总数', - value: stats.patientStats?.total_patients ?? 0, - prefix: , - subtitle: stats.patientStats?.new_this_month ? `本月 +${stats.patientStats.new_this_month}` : undefined, - color: '#2563eb', bgColor: '#eff6ff', - }, - { - title: '咨询总量', - value: stats.consultationStats?.total_sessions ?? 0, - prefix: , - subtitle: stats.consultationStats?.this_month ? `本月 +${stats.consultationStats.this_month}` : undefined, - color: '#7c3aed', bgColor: '#f5f3ff', - }, - { - title: '随访完成率', - value: stats.followUpStats?.completion_rate ?? 0, - suffix: '%', precision: 1, - prefix: , - subtitle: stats.followUpStats?.pending ? `待处理: ${stats.followUpStats.pending}` : undefined, - color: '#059669', bgColor: '#ecfdf5', - }, - { - title: '积分总发放', - value: stats.pointsStats?.total_issued ?? 0, - prefix: , - subtitle: stats.pointsStats?.active_accounts ? `活跃账户: ${stats.pointsStats.active_accounts}` : undefined, - color: '#d97706', bgColor: '#fffbeb', - }, - ]; -} - -const topEarnerColumns = [ - { - title: '排名', dataIndex: 'rank', key: 'rank', width: 70, - render: (rank: number) => { - const medalColors = ['#d97706', '#6b7280', '#b45309']; - const color = rank <= 3 ? medalColors[rank - 1] : undefined; - return {rank}; - }, - }, - { - title: '患者 ID', dataIndex: 'patient_id', key: 'patient_id', width: 180, - render: (id: string) => ( - - {id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id} - - ), - }, - { - title: '累计积分', dataIndex: 'total_earned', key: 'total_earned', width: 140, - render: (val: number) => {val.toLocaleString()}, - }, -]; - -function buildTopEarnerData(pointsStats: PointsStatistics | null): TopEarnerRow[] { - return (pointsStats?.top_earners ?? []).map((item, idx) => ({ - rank: idx + 1, - patient_id: item.patient_id, - total_earned: item.total_earned, - })); -} +const DASHBOARD_MAP = { + doctor: DoctorDashboard, + nurse: NurseDashboard, + admin: AdminDashboard, + operator: OperatorDashboard, +} as const; export default function StatisticsDashboard() { - const navigate = useNavigate(); - const stats = useStatsData(); - const statCards = buildStatCards(stats); - const topEarnerData = buildTopEarnerData(stats.pointsStats); - - if (stats.loading) { - return ( -
- -
- ); - } - - if (stats.error) { - return ( - } onClick={stats.refresh}>重试} - /> - ); - } - - return ( -
- - {statCards.map((card) => ( - - - {card.title}} - value={card.value} - precision={card.precision} - suffix={card.suffix} - prefix={ - - {card.prefix} - - } - valueStyle={{ color: card.color, fontSize: 28, fontWeight: 700 }} - /> - {card.subtitle && ( -
- {card.subtitle} -
- )} -
- - ))} -
- - 积分统计} - bordered={false} - style={{ borderRadius: 12 }} - extra={} - > - - } /> - - - } /> - - 积分排行 Top 10 - - - - 健康数据中心} - bordered={false} - style={{ borderRadius: 12 }} - > - - - - 快捷入口} - bordered={false} - style={{ borderRadius: 12 }} - > - - {QUICK_LINKS.map((link) => ( - - navigate(link.path)} - > -
- {link.icon} -
-
{link.title}
-
- - ))} - - - - {topEarnerData.length > 0 && ( - 最近活动} - bordered={false} - style={{ borderRadius: 12 }} - > -
- - )} - - ); + const role = useDashboardRole(); + const DashboardComponent = DASHBOARD_MAP[role]; + return ; } diff --git a/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx new file mode 100644 index 0000000..ab5ecc8 --- /dev/null +++ b/apps/web/src/pages/health/StatisticsDashboard/AdminDashboard.tsx @@ -0,0 +1,81 @@ +import { Row, Col, Card, Statistic, Tabs, Spin, Typography, Flex } from 'antd'; +import { + TeamOutlined, + CalendarOutlined, + SafetyCertificateOutlined, + MedicineBoxOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { useStatsData } from './useStatsData'; +import { useCountUp } from '../../../hooks/useCountUp'; +import HealthDataCenter from './HealthDataCenter'; + +export function AdminDashboard() { + const { patientStats, followUpStats, healthDataStats, loading } = useStatsData(); + + if (loading && !patientStats) return ; + + return ( +
+ +
+ 管理中心 + 数据概览 +
+
+ + +
+ + } /> + + + + + } /> + + + + + } + /> + + + + + } + /> + + + + + } /> + + + + + {/* 健康数据中心 Tab */} + + }, + { key: 'lab', label: '化验报告', children: }, + { key: 'appointments', label: '预约分析', children: }, + { key: 'vital-signs', label: '体征数据', children: }, + ]} + /> + + + ); +} diff --git a/apps/web/src/pages/health/StatisticsDashboard/DoctorDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard/DoctorDashboard.tsx new file mode 100644 index 0000000..b013abc --- /dev/null +++ b/apps/web/src/pages/health/StatisticsDashboard/DoctorDashboard.tsx @@ -0,0 +1,146 @@ +import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex } from 'antd'; +import { + TeamOutlined, + MessageOutlined, + SafetyCertificateOutlined, + MedicineBoxOutlined, + ArrowUpOutlined, +} from '@ant-design/icons'; +import { useEffect, useState, useCallback } from 'react'; +import { pointsApi, type PersonalStats } from '../../../api/health/points'; +import { useStatsData } from './useStatsData'; +import { useCountUp } from '../../../hooks/useCountUp'; + +export function DoctorDashboard() { + const [personal, setPersonal] = useState(null); + const [loading, setLoading] = useState(true); + const { consultationStats } = useStatsData(); + + const fetchPersonal = useCallback(async () => { + try { + const data = await pointsApi.getPersonalStats(); + setPersonal(data); + } catch { + // 个人统计可能无权限,静默降级 + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchPersonal(); }, [fetchPersonal]); + + if (loading && !personal) return ; + + const p = personal; + + return ( +
+ +
+ 今日工作台 + + {p ? `${p.today_appointments} 个预约 · ${p.today_follow_ups} 条待随访` : '加载中...'} + +
+
+ + + {/* 紧急提醒 */} + {p && p.abnormal_vital_signs > 0 && ( +
+ + + {p.abnormal_vital_signs} 位患者体征异常 + + {p.pending_lab_reviews > 0 && ( + + {p.pending_lab_reviews} 份化验待审 + + )} + + + )} + + {/* 统计卡片 */} + + + } + suffix={p && p.new_patients_this_month > 0 ? ( + + {p.new_patients_this_month}新增 + + ) : undefined} + /> + + + + + } + valueStyle={{ color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' }} + /> + + + + + } + suffix={p && p.pending_consultations > 0 ? ( + + {p.pending_consultations}待回复 + + ) : undefined} + /> + + + + + } + valueStyle={{ color: (p?.vital_signs_report_rate ?? 0) >= 70 ? '#3f8600' : '#cf1322' }} + /> + + + + {/* 化验审核 */} + {p && p.pending_lab_reviews > 0 && ( + + + } + /> + + + )} + + {/* 咨询消息 */} + 0 ? 12 : 24}> + + } + /> + + + + + ); +} diff --git a/apps/web/src/pages/health/StatisticsDashboard/NurseDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard/NurseDashboard.tsx new file mode 100644 index 0000000..a4aa81c --- /dev/null +++ b/apps/web/src/pages/health/StatisticsDashboard/NurseDashboard.tsx @@ -0,0 +1,124 @@ +import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd'; +import { + TeamOutlined, + SafetyCertificateOutlined, + CalendarOutlined, + AlertOutlined, +} from '@ant-design/icons'; +import { useEffect, useState, useCallback } from 'react'; +import { pointsApi, type PersonalStats } from '../../../api/health/points'; +import { useCountUp } from '../../../hooks/useCountUp'; + +export function NurseDashboard() { + const [personal, setPersonal] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchPersonal = useCallback(async () => { + try { + const data = await pointsApi.getPersonalStats(); + setPersonal(data); + } catch { + // 静默降级 + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchPersonal(); }, [fetchPersonal]); + + if (loading && !personal) return ; + + const p = personal; + + return ( +
+ +
+ 随访监控台 + + {p ? `今日待随访 ${p.today_follow_ups} 人 · 体征异常 ${p.abnormal_vital_signs} 人` : '加载中...'} + +
+
+ + + {/* 异常提醒 */} + {p && p.abnormal_vital_signs > 0 && ( +
+ + + + + + {p.abnormal_vital_signs} 位患者体征异常 + + + 查看全部 → + + + + )} + + {/* 今日随访队列 */} + + + } + /> + + + + {/* 今日体征上报 */} + + + + {pct}%} + /> + + + {p?.vital_signs_reported ?? 0}已上报 + + | + + {(p?.vital_signs_total ?? 0) - (p?.vital_signs_reported ?? 0)}未上报 + + + + + + + {/* 统计卡 */} + + + } /> + + + + + } + /> + + + + + } + valueStyle={{ color: (p?.overdue_follow_ups ?? 0) > 0 ? '#cf1322' : undefined }} + /> + + + + + ); +} diff --git a/apps/web/src/pages/health/StatisticsDashboard/OperatorDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard/OperatorDashboard.tsx new file mode 100644 index 0000000..5687992 --- /dev/null +++ b/apps/web/src/pages/health/StatisticsDashboard/OperatorDashboard.tsx @@ -0,0 +1,84 @@ +import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd'; +import { + TrophyOutlined, + FileTextOutlined, + CalendarOutlined, + ShoppingOutlined, +} from '@ant-design/icons'; +import { useStatsData } from './useStatsData'; +import { useCountUp } from '../../../hooks/useCountUp'; + +export function OperatorDashboard() { + const { pointsStats, loading } = useStatsData(); + + if (loading && !pointsStats) return ; + + return ( +
+ +
+ 运营中心 + 积分、内容、活动 +
+
+ + +
+ + } /> + + + + + } + suffix={pointsStats ? ( + + 消费率{pointsStats.total_issued > 0 ? Math.round(pointsStats.total_spent / pointsStats.total_issued * 100) : 0}% + + ) : undefined} + /> + + + + + } /> + + + + + } /> + + + + {/* 积分排行 */} + + + ( + + {idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'} + {item.total_earned} 分 + + )} + /> + + + + {/* 热门文章 */} + + + } + /> + + + + + ); +} diff --git a/crates/erp-health/src/dto/stats_dto.rs b/crates/erp-health/src/dto/stats_dto.rs index 3970b9a..fd72ad8 100644 --- a/crates/erp-health/src/dto/stats_dto.rs +++ b/crates/erp-health/src/dto/stats_dto.rs @@ -115,6 +115,28 @@ pub struct HealthDataStatsResp { pub vital_signs_report_rate: VitalSignsReportRateResp, } +// --------------------------------------------------------------------------- +// 个人维度统计 +// --------------------------------------------------------------------------- + +/// 当前用户个人维度的工作量统计 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PersonalStatsResp { + pub my_patients: i64, + pub new_patients_this_month: i64, + pub follow_up_rate: f64, + pub consultations_this_month: i64, + pub pending_consultations: i64, + pub vital_signs_report_rate: f64, + pub today_appointments: i64, + pub overdue_follow_ups: i64, + pub today_follow_ups: i64, + pub abnormal_vital_signs: i64, + pub vital_signs_reported: i64, + pub vital_signs_total: i64, + pub pending_lab_reviews: i64, +} + // --------------------------------------------------------------------------- // 通用结构 // --------------------------------------------------------------------------- diff --git a/crates/erp-health/src/handler/stats_handler.rs b/crates/erp-health/src/handler/stats_handler.rs index b6cbbdb..b1bf2b1 100644 --- a/crates/erp-health/src/handler/stats_handler.rs +++ b/crates/erp-health/src/handler/stats_handler.rs @@ -134,3 +134,20 @@ where let result = stats_service::get_health_data_stats(&state, ctx.tenant_id).await?; Ok(Json(ApiResponse::ok(result))) } + +// --------------------------------------------------------------------------- +// 个人维度统计 +// --------------------------------------------------------------------------- + +pub async fn get_personal_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.patient.list")?; + let result = stats_service::get_personal_stats(&state, ctx.user_id, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index f7ea19a..7d8c88e 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -577,6 +577,10 @@ impl HealthModule { "/health/admin/statistics/health-data", axum::routing::get(stats_handler::get_health_data_stats), ) + .route( + "/health/admin/statistics/personal-stats", + axum::routing::get(stats_handler::get_personal_stats), + ) // 危急值阈值配置 .route( "/health/critical-value-thresholds", diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs index 74332cb..1e199ba 100644 --- a/crates/erp-health/src/service/stats_service.rs +++ b/crates/erp-health/src/service/stats_service.rs @@ -1,4 +1,4 @@ -use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, FromQueryResult as _}; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult}; use erp_core::error::AppResult; @@ -6,7 +6,7 @@ use crate::dto::stats_dto::*; use crate::entity::{ patient, consultation_session, follow_up_task, points_transaction, dialysis_record, lab_report, - appointment, vital_signs, + appointment, vital_signs, patient_doctor_relation, doctor_profile, }; use crate::state::HealthState; @@ -621,3 +621,254 @@ async fn compute_daily_report_rate( DailyReportRate { date: r.date, reported: r.reported, total, rate } }).collect()) } + +// --------------------------------------------------------------------------- +// 个人维度统计 +// --------------------------------------------------------------------------- + +pub async fn get_personal_stats( + state: &HealthState, + user_id: uuid::Uuid, + tenant_id: uuid::Uuid, +) -> AppResult { + let db = &state.db; + + // 通过 user_id 查找 doctor_profile 以获得 doctor_id + let doctor_profile = doctor_profile::Entity::find() + .filter(doctor_profile::Column::TenantId.eq(tenant_id)) + .filter(doctor_profile::Column::DeletedAt.is_null()) + .filter(doctor_profile::Column::UserId.eq(user_id)) + .one(db) + .await?; + + let doctor_id = doctor_profile.map(|p| p.id); + + // my_patients: 通过 patient_doctor_relation 统计 + let my_patients = if let Some(did) = doctor_id { + patient_doctor_relation::Entity::find() + .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_doctor_relation::Column::DeletedAt.is_null()) + .filter(patient_doctor_relation::Column::DoctorId.eq(did)) + .count(db) + .await? as i64 + } else { + 0 + }; + + // new_patients_this_month: 本月新增关联患者 + let new_patients_this_month = if let Some(did) = doctor_id { + let sql = r#" + SELECT COUNT(*) AS cnt + FROM patient_doctor_relation pdr + INNER JOIN patient p ON p.id = pdr.patient_id AND p.deleted_at IS NULL AND p.tenant_id = $1 + WHERE pdr.tenant_id = $1 AND pdr.deleted_at IS NULL + AND pdr.doctor_id = $2 + AND p.created_at >= date_trunc('month', NOW()) + "#; + + #[derive(Debug, FromQueryResult)] + struct Cnt { + cnt: i64, + } + + let result: Option = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into(), did.into()], + ), + ) + .one(db) + .await?; + + result.map(|r| r.cnt).unwrap_or(0) + } else { + 0 + }; + + // follow_up_rate: 分配给当前用户的随访完成率 + let sql = r#" + SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt + FROM follow_up_task + WHERE tenant_id = $1 AND deleted_at IS NULL AND assigned_to = $2 + GROUP BY GROUPING SETS ((status), ()) + "#; + + #[derive(Debug, FromQueryResult)] + struct StatusCount { + status: String, + cnt: i64, + } + + let fu_rows: Vec = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into(), user_id.into()], + ), + ) + .all(db) + .await?; + + let mut fu_total: i64 = 0; + let mut fu_completed: i64 = 0; + let mut overdue_follow_ups: i64 = 0; + for row in &fu_rows { + match row.status.as_str() { + "__total" => fu_total = row.cnt, + "completed" => fu_completed = row.cnt, + "overdue" => overdue_follow_ups = row.cnt, + _ => {} + } + } + let follow_up_rate = if fu_total > 0 { + (fu_completed as f64 / fu_total as f64) * 100.0 + } else { + 0.0 + }; + + // consultations_this_month / pending_consultations: 咨询统计 + let consultations_this_month = if let Some(did) = doctor_id { + consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::DoctorId.eq(did)) + .filter(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .count(db) + .await? as i64 + } else { + 0 + }; + + let pending_consultations = if let Some(did) = doctor_id { + consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::DoctorId.eq(did)) + .filter(consultation_session::Column::Status.eq("active")) + .count(db) + .await? as i64 + } else { + 0 + }; + + // today_appointments: 今日预约 + let today_appointments = if let Some(did) = doctor_id { + appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::DeletedAt.is_null()) + .filter(appointment::Column::DoctorId.eq(did)) + .filter(Expr::col(appointment::Column::AppointmentDate).eq(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64 + } else { + 0 + }; + + // today_follow_ups: 今日随访任务 + let today_follow_ups = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter(follow_up_task::Column::AssignedTo.eq(user_id)) + .filter(Expr::col(follow_up_task::Column::PlannedDate).eq(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64; + + // vital_signs_report_rate: 当前医生的患者体征上报率 + let (vital_signs_reported, vital_signs_total, vital_signs_report_rate) = if my_patients > 0 { + let vs_sql = r#" + SELECT + COUNT(DISTINCT vs.patient_id) AS reported, + $3::bigint AS total + FROM vital_signs vs + WHERE vs.tenant_id = $1 AND vs.deleted_at IS NULL + AND vs.created_at >= date_trunc('month', NOW()) + AND vs.patient_id IN ( + SELECT patient_id FROM patient_doctor_relation + WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL + ) + "#; + + #[derive(Debug, FromQueryResult)] + struct VsCount { + reported: i64, + total: i64, + } + + let result: Option = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + vs_sql, + [tenant_id.into(), doctor_id.unwrap_or_default().into(), my_patients.into()], + ), + ) + .one(db) + .await?; + + match result { + Some(r) => { + let rate = if r.total > 0 { + (r.reported as f64 / r.total as f64) * 100.0 + } else { + 0.0 + }; + (r.reported, r.total, rate) + } + None => (0, my_patients, 0.0), + } + } else { + (0, 0, 0.0) + }; + + // pending_lab_reviews: 待审核化验报告(与当前医生的患者关联) + let pending_lab_reviews = if doctor_id.is_some() { + let lr_sql = r#" + SELECT COUNT(*) AS cnt + FROM lab_report lr + WHERE lr.tenant_id = $1 AND lr.deleted_at IS NULL + AND lr.status = 'pending' + AND lr.patient_id IN ( + SELECT patient_id FROM patient_doctor_relation + WHERE doctor_id = $2 AND tenant_id = $1 AND deleted_at IS NULL + ) + "#; + + #[derive(Debug, FromQueryResult)] + struct LrCnt { + cnt: i64, + } + + let result: Option = FromQueryResult::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + lr_sql, + [tenant_id.into(), doctor_id.unwrap_or_default().into()], + ), + ) + .one(db) + .await?; + + result.map(|r| r.cnt).unwrap_or(0) + } else { + 0 + }; + + // abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置) + let abnormal_vital_signs: i64 = 0; + + Ok(PersonalStatsResp { + my_patients, + new_patients_this_month, + follow_up_rate, + consultations_this_month, + pending_consultations, + vital_signs_report_rate, + today_appointments, + overdue_follow_ups, + today_follow_ups, + abnormal_vital_signs, + vital_signs_reported, + vital_signs_total, + pending_lab_reviews, + }) +}