From 5e52b0a34cb8b9776092dac48d7688006c1c5bfc Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 1 May 2026 23:25:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E9=81=97=E7=95=99=E9=A1=B9=E4=BF=AE=E5=A4=8D=20=E2=80=94=20UNI?= =?UTF-8?q?ON=20ALL=20=E8=81=9A=E5=90=88=20+=20=E5=9B=A2=E9=98=9F=E6=A6=82?= =?UTF-8?q?=E8=A7=88=20+=20=E8=BE=83=E6=98=A8=E6=97=A5=E5=AF=B9=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 待办列表 UNION ALL 聚合:list_action_items 现从 ai_suggestion + alerts + follow_up_task 三表查询, ActionType 扩展为 AiSuggestion/Alert/Followup/DataAnomaly 四种类型, get_action_thread 按类型构建不同线程时间线(AI 建议/告警/随访) 2. 真实团队概览:get_team_overview 从 doctor_profile + follow_up_task + alerts 聚合成员统计和风险分布 3. 统计卡片较昨日描述:PersonalStatsResp 新增 6 个 yesterday_* 字段, Home.tsx 统计卡片底部渲染"较昨日+N"绿色/红色描述 4. 前端 ActionDetailDrawer 改用 item.id(action_type:uuid 格式)调用线程 API --- apps/web/src/api/health/points.ts | 51 ++ apps/web/src/pages/Home.tsx | 19 +- .../workbench/ActionDetailDrawer.tsx | 2 +- crates/erp-health/src/dto/stats_dto.rs | 7 + .../src/service/action_inbox_service.rs | 596 +++++++++++++++--- .../erp-health/src/service/stats_service.rs | 51 +- 6 files changed, 616 insertions(+), 110 deletions(-) diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index ce8da75..40f8c4a 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -155,6 +155,12 @@ export interface PersonalStats { vital_signs_reported: number; vital_signs_total: number; pending_lab_reviews: number; + yesterday_my_patients?: number; + yesterday_today_appointments?: number; + yesterday_consultations_this_month?: number; + yesterday_follow_up_rate?: number; + yesterday_today_follow_ups?: number; + yesterday_overdue_follow_ups?: number; } export interface OverviewStatistics { @@ -221,6 +227,51 @@ export interface HealthDataStats { // --- API --- +export interface PointsAccountDetail { + id: string; + patient_id: string; + balance: number; + total_earned: number; + total_spent: number; + total_expired: number; +} + +export interface PointsTransactionDetail { + id: string; + account_id: string; + transaction_type: string; + amount: number; + remaining_amount: number; + status: string; + expires_at: string | null; + balance_after: number; + description: string | null; + created_at: string; +} + +export const pointsAdminApi = { + getPatientAccount: async (patientId: string) => { + const { data } = await client.get<{ + success: boolean; + data: PointsAccountDetail; + }>(`/health/admin/points/patients/${patientId}/account`); + return data.data; + }, + + listPatientTransactions: async ( + patientId: string, + params: { page?: number; page_size?: number }, + ) => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse; + }>(`/health/admin/points/patients/${patientId}/transactions`, { params }); + return data.data; + }, +}; + +// --- API (original) --- + export const pointsApi = { // Rules listRules: async () => { diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 745c8a2..5ad5abd 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -78,6 +78,7 @@ interface StatCardDef { key: string; title: string; getValue: (p: PersonalStats | null, s: ReturnType) => number; + getDiff?: (p: PersonalStats | null) => number | undefined; icon: React.ReactNode; suffix?: string; path: string; @@ -106,15 +107,15 @@ const STAT_TEXT_COLORS: string[] = ['#2563EB', '#7C3AED', '#DC2626', '#D97706']; const ROLE_STATS: Record = { doctor: [ - { key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, icon: , path: '/health/patients' }, - { key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, icon: , path: '/health/appointments' }, - { key: 'consultations', title: '本月咨询', getValue: (p) => p?.consultations_this_month ?? 0, icon: , path: '/health/consultations' }, + { key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, getDiff: (p) => { const c = p?.my_patients, y = p?.yesterday_my_patients; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/patients' }, + { key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, getDiff: (p) => { const c = p?.today_appointments, y = p?.yesterday_today_appointments; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/appointments' }, + { key: 'consultations', title: '本月咨询', getValue: (p) => p?.consultations_this_month ?? 0, getDiff: (p) => { const c = p?.consultations_this_month, y = p?.yesterday_consultations_this_month; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/consultations' }, { key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: , suffix: '%', path: '/health/follow-ups' }, ], nurse: [ - { key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, icon: , path: '/health/appointments' }, - { key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, icon: , path: '/health/follow-ups' }, - { key: 'overdue', title: '逾期随访', getValue: (p) => p?.overdue_follow_ups ?? 0, icon: , path: '/health/follow-ups' }, + { key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, getDiff: (p) => { const c = p?.today_appointments, y = p?.yesterday_today_appointments; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/appointments' }, + { key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, getDiff: (p) => { const c = p?.today_follow_ups, y = p?.yesterday_today_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/follow-ups' }, + { key: 'overdue', title: '逾期随访', getValue: (p) => p?.overdue_follow_ups ?? 0, getDiff: (p) => { const c = p?.overdue_follow_ups, y = p?.yesterday_overdue_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: , path: '/health/follow-ups' }, { key: 'vital-rate', title: '体征上报率', getValue: (p) => p?.vital_signs_report_rate ?? 0, icon: , suffix: '%', path: '/health/vital-signs' }, ], admin: [ @@ -249,6 +250,7 @@ export default function Home() {
{statDefs.map((def, i) => { const value = def.getValue(personalStats, statsData); + const diff = def.getDiff?.(personalStats); return (
{def.suffix && {def.suffix}}
+ {diff != null && ( +
0 ? '#16A34A' : diff < 0 ? '#DC2626' : '#94A3B8' }}> + {diff === 0 ? '与昨日持平' : `较昨日 ${diff > 0 ? '+' : ''}${diff}`} +
+ )}
); diff --git a/apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx b/apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx index eec1bb2..05cd9f2 100644 --- a/apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx +++ b/apps/web/src/pages/health/components/workbench/ActionDetailDrawer.tsx @@ -57,7 +57,7 @@ export default function ActionDetailDrawer({ } setLoading(true); actionInboxApi - .getThread(item.source_ref) + .getThread(item.id) .then(setThread) .finally(() => setLoading(false)); }, [item, open]); diff --git a/crates/erp-health/src/dto/stats_dto.rs b/crates/erp-health/src/dto/stats_dto.rs index d50f295..c9f07d5 100644 --- a/crates/erp-health/src/dto/stats_dto.rs +++ b/crates/erp-health/src/dto/stats_dto.rs @@ -118,6 +118,13 @@ pub struct PersonalStatsResp { pub vital_signs_reported: i64, pub vital_signs_total: i64, pub pending_lab_reviews: i64, + // 昨日对比 + pub yesterday_my_patients: Option, + pub yesterday_today_appointments: Option, + pub yesterday_consultations_this_month: Option, + pub yesterday_follow_up_rate: Option, + pub yesterday_today_follow_ups: Option, + pub yesterday_overdue_follow_ups: Option, } // --------------------------------------------------------------------------- diff --git a/crates/erp-health/src/service/action_inbox_service.rs b/crates/erp-health/src/service/action_inbox_service.rs index 1c5539e..0c36032 100644 --- a/crates/erp-health/src/service/action_inbox_service.rs +++ b/crates/erp-health/src/service/action_inbox_service.rs @@ -12,6 +12,9 @@ use crate::error::HealthError; #[serde(rename_all = "snake_case")] pub enum ActionType { AiSuggestion, + Alert, + Followup, + DataAnomaly, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -23,7 +26,7 @@ pub enum ActionPriority { Low, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ActionStatus { Pending, @@ -92,8 +95,8 @@ pub struct ActionInboxQuery { #[derive(Debug, FromQueryResult)] struct ActionItemRow { id: Uuid, - suggestion_type: String, - risk_level: String, + action_type: String, + priority_raw: String, status: String, params: Option, created_at: DateTime, @@ -101,7 +104,7 @@ struct ActionItemRow { patient_id: Uuid, patient_name: String, result_content: Option, - _analysis_id: Uuid, + source_id: Option, } #[derive(Debug, FromQueryResult)] @@ -110,28 +113,28 @@ struct CountRow { } #[derive(Debug, FromQueryResult)] -struct SuggestionDetail { +struct ActionDetail { id: Uuid, - suggestion_type: String, - risk_level: String, + action_type: String, + priority_raw: String, status: String, params: Option, workflow_instance_id: Option, reanalysis_id: Option, created_at: DateTime, updated_at: DateTime, - _analysis_id: Uuid, + source_id: Option, patient_id: Uuid, patient_name: String, result_content: Option, - analysis_created_at: DateTime, + analysis_created_at: Option>, } // ── 辅助函数 ──────────────────────────────────────────────────────── fn risk_to_priority(risk: &str) -> ActionPriority { match risk { - "high" => ActionPriority::Urgent, + "high" | "urgent" => ActionPriority::Urgent, "medium" => ActionPriority::High, "low" => ActionPriority::Medium, _ => ActionPriority::Low, @@ -143,7 +146,33 @@ fn suggestion_status_to_action(status: &str) -> ActionStatus { "pending" => ActionStatus::Pending, "approved" => ActionStatus::InProgress, "executed" => ActionStatus::Completed, - _ => ActionStatus::Dismissed, // rejected, expired, parse_failed + _ => ActionStatus::Dismissed, + } +} + +fn alert_status_to_action(status: &str) -> ActionStatus { + match status { + "active" => ActionStatus::Pending, + "acknowledged" => ActionStatus::InProgress, + "resolved" => ActionStatus::Completed, + _ => ActionStatus::Dismissed, + } +} + +fn followup_status_to_action(status: &str) -> ActionStatus { + match status { + "pending" => ActionStatus::Pending, + "in_progress" => ActionStatus::InProgress, + "completed" => ActionStatus::Completed, + _ => ActionStatus::Dismissed, + } +} + +fn map_action_status(action_type: &str, status: &str) -> ActionStatus { + match action_type { + "alert" => alert_status_to_action(status), + "followup" => followup_status_to_action(status), + _ => suggestion_status_to_action(status), } } @@ -165,6 +194,17 @@ fn extract_title(params: &Option, suggestion_type: &str) -> S .unwrap_or_else(|| suggestion_type_to_title(suggestion_type)) } +fn summarize_result(result: &Option) -> String { + let Some(text) = result else { return String::new() }; + let parsed = serde_json::from_str::(text).ok(); + let summary = parsed + .as_ref() + .and_then(|v| v.get("summary")) + .and_then(|v| v.as_str()) + .map(|s| s.chars().take(100).collect::()); + summary.unwrap_or_else(|| text.chars().take(100).collect()) +} + // ── 公开 API ──────────────────────────────────────────────────────── pub async fn list_action_items( @@ -176,33 +216,101 @@ pub async fn list_action_items( let page_size = query.page_size.unwrap_or(20).min(100); let offset = (page - 1) * page_size; - let status_filter = match query.status.as_deref() { - Some("pending") => "AND s.status = 'pending'", - Some("in_progress") => "AND s.status = 'approved'", - Some("completed") => "AND s.status = 'executed'", - Some("dismissed") => "AND s.status IN ('rejected', 'expired', 'parse_failed')", - _ => "", + // 各段的 status 过滤条件 + let (sug_status, alert_status, fu_status) = match query.status.as_deref() { + Some("pending") => ( + "AND s.status = 'pending'".into(), + "AND al.status = 'active'".into(), + "AND f.status = 'pending'".into(), + ), + Some("in_progress") => ( + "AND s.status = 'approved'".into(), + "AND al.status = 'acknowledged'".into(), + "AND f.status = 'in_progress'".into(), + ), + Some("completed") => ( + "AND s.status = 'executed'".into(), + "AND al.status = 'resolved'".into(), + "AND f.status = 'completed'".into(), + ), + Some("dismissed") => ( + "AND s.status IN ('rejected', 'expired', 'parse_failed')".into(), + "AND al.status IN ('dismissed', 'expired')".into(), + "AND f.status IN ('cancelled', 'skipped')".into(), + ), + _ => (String::new(), String::new(), String::new()), }; + // 按类型过滤 + let type_filter = query.action_type.as_deref(); + let include_sug = type_filter.map_or(true, |t| t == "ai_suggestion"); + let include_alert = type_filter.map_or(true, |t| t == "alert"); + let include_fu = type_filter.map_or(true, |t| t == "followup"); + + let mut segments: Vec = Vec::new(); + + if include_sug { + segments.push(format!( + r#" + SELECT s.id, 'ai_suggestion' AS action_type, s.risk_level AS priority_raw, + s.status, s.params, s.created_at, s.updated_at, + a.patient_id, p.name AS patient_name, + a.result_content, a.id AS source_id + FROM ai_suggestion s + JOIN ai_analysis a ON s.analysis_id = a.id + JOIN patient p ON a.patient_id = p.id + WHERE s.tenant_id = $1 AND s.deleted_at IS NULL {sug_status}"# + )); + } + + if include_alert { + segments.push(format!( + r#" + SELECT al.id, 'alert' AS action_type, al.severity AS priority_raw, + al.status, NULL::jsonb AS params, al.created_at, al.updated_at, + al.patient_id, p.name AS patient_name, + NULL::text AS result_content, NULL::uuid AS source_id + FROM alerts al + JOIN patient p ON al.patient_id = p.id + WHERE al.tenant_id = $1 AND al.deleted_at IS NULL {alert_status}"# + )); + } + + if include_fu { + segments.push(format!( + r#" + SELECT f.id, 'followup' AS action_type, 'medium' AS priority_raw, + f.status, NULL::jsonb AS params, f.created_at, f.updated_at, + f.patient_id, p.name AS patient_name, + NULL::text AS result_content, NULL::uuid AS source_id + FROM follow_up_task f + JOIN patient p ON f.patient_id = p.id + WHERE f.tenant_id = $1 AND f.deleted_at IS NULL {fu_status}"# + )); + } + + if segments.is_empty() { + return Ok(PaginatedResponse { + data: vec![], + total: 0, + page, + page_size, + total_pages: 0, + }); + } + + let union_sql = segments.join("\n UNION ALL\n"); + let data_sql = format!( - r#" - SELECT s.id, s.suggestion_type, s.risk_level, s.status, s.params, - s.created_at, s.updated_at, - a.patient_id, p.name AS patient_name, - a.result_content, a.id AS analysis_id - FROM ai_suggestion s - JOIN ai_analysis a ON s.analysis_id = a.id - JOIN patient p ON a.patient_id = p.id - WHERE s.tenant_id = $1 - AND s.deleted_at IS NULL - {status_filter} + r#"{union_sql} ORDER BY - CASE s.risk_level WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, - s.created_at DESC - LIMIT $2 OFFSET $3 - "# + CASE priority_raw WHEN 'high' THEN 1 WHEN 'urgent' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, + created_at DESC + LIMIT $2 OFFSET $3"# ); + let count_sql = format!("SELECT COUNT(*) AS cnt FROM ({union_sql}) sub"); + let rows: Vec = FromQueryResult::find_by_statement( Statement::from_sql_and_values( DatabaseBackend::Postgres, @@ -218,17 +326,6 @@ pub async fn list_action_items( .await .map_err(|e| HealthError::DbError(e.to_string()))?; - let count_sql = format!( - r#" - SELECT COUNT(*) AS cnt - FROM ai_suggestion s - JOIN ai_analysis a ON s.analysis_id = a.id - WHERE s.tenant_id = $1 - AND s.deleted_at IS NULL - {status_filter} - "# - ); - let count_row: Option = FromQueryResult::find_by_statement( Statement::from_sql_and_values(DatabaseBackend::Postgres, count_sql, [tenant_id.into()]), ) @@ -240,18 +337,30 @@ pub async fn list_action_items( let items: Vec = rows .into_iter() - .map(|r| ActionItem { - id: format!("ai_suggestion:{}", r.id), - action_type: ActionType::AiSuggestion, - priority: risk_to_priority(&r.risk_level), - status: suggestion_status_to_action(&r.status), - title: extract_title(&r.params, &r.suggestion_type), - summary: r.result_content.unwrap_or_default().chars().take(100).collect(), - patient_id: r.patient_id, - patient_name: r.patient_name, - source_ref: r.id.to_string(), - created_at: r.created_at, - updated_at: r.updated_at, + .map(|r| { + let at = match r.action_type.as_str() { + "alert" => ActionType::Alert, + "followup" => ActionType::Followup, + _ => ActionType::AiSuggestion, + }; + let title = match r.action_type.as_str() { + "alert" => "健康告警".into(), + "followup" => "随访任务".into(), + _ => extract_title(&r.params, ""), + }; + ActionItem { + id: format!("{}:{}", r.action_type, r.id), + action_type: at, + priority: risk_to_priority(&r.priority_raw), + status: map_action_status(&r.action_type, &r.status), + title, + summary: summarize_result(&r.result_content), + patient_id: r.patient_id, + patient_name: r.patient_name, + source_ref: r.id.to_string(), + created_at: r.created_at, + updated_at: r.updated_at, + } }) .collect(); @@ -269,20 +378,35 @@ pub async fn get_action_thread( tenant_id: Uuid, source_ref: &str, ) -> Result, HealthError> { - let suggestion_id = source_ref - .strip_prefix("ai_suggestion:") + // 解析 "action_type:uuid" 格式 + let (action_type_str, uuid_str) = source_ref + .find(':') + .map(|pos| (&source_ref[..pos], &source_ref[pos + 1..])) .ok_or_else(|| HealthError::Validation("无效的 source_ref 格式".into()))?; - let uuid = Uuid::parse_str(suggestion_id) + let uuid = Uuid::parse_str(uuid_str) .map_err(|e| HealthError::Validation(format!("无效的 UUID: {e}")))?; - let detail: Option = FromQueryResult::find_by_statement( + match action_type_str { + "ai_suggestion" => get_ai_suggestion_thread(db, tenant_id, uuid).await, + "alert" => get_alert_thread(db, tenant_id, uuid).await, + "followup" => get_followup_thread(db, tenant_id, uuid).await, + _ => Err(HealthError::Validation(format!("未知类型: {action_type_str}"))), + } +} + +async fn get_ai_suggestion_thread( + db: &DatabaseConnection, + tenant_id: Uuid, + uuid: Uuid, +) -> Result, HealthError> { + let detail: Option = FromQueryResult::find_by_statement( Statement::from_sql_and_values( DatabaseBackend::Postgres, r#" - SELECT s.id, s.suggestion_type, s.risk_level, s.status, s.params, - s.workflow_instance_id, s.reanalysis_id, + SELECT s.id, 'ai_suggestion' AS action_type, s.risk_level AS priority_raw, + s.status, s.params, s.workflow_instance_id, s.reanalysis_id, s.created_at, s.updated_at, - s.analysis_id, a.patient_id, p.name AS patient_name, + s.analysis_id AS source_id, a.patient_id, p.name AS patient_name, a.result_content, a.created_at AS analysis_created_at FROM ai_suggestion s JOIN ai_analysis a ON s.analysis_id = a.id @@ -303,21 +427,18 @@ pub async fn get_action_thread( let action_status = suggestion_status_to_action(&detail.status); - // ── 拼装线程时间线 ── let mut thread = Vec::new(); - // Step 1: AI 分析完成 thread.push(ThreadEvent { step: "ai_analysis".into(), label: "AI 分析完成".into(), status: ActionStatus::Completed, detail: None, - timestamp: Some(detail.analysis_created_at), + timestamp: detail.analysis_created_at, operator: None, link_to: Some("/health/ai-analysis".into()), }); - // Step 2: 医生审批 let approval_status = match detail.status.as_str() { "approved" | "executed" => ActionStatus::Completed, "rejected" => ActionStatus::Dismissed, @@ -329,26 +450,17 @@ pub async fn get_action_thread( label: "医生审批".into(), status: approval_status, detail: None, - timestamp: if is_terminal { - Some(detail.updated_at) - } else { - None - }, + timestamp: if is_terminal { Some(detail.updated_at) } else { None }, operator: None, link_to: None, }); - // Step 3: 执行安排 let has_workflow = detail.workflow_instance_id.is_some(); if has_workflow || detail.status == "approved" || detail.status == "executed" { thread.push(ThreadEvent { step: "action_dispatched".into(), label: "执行安排".into(), - status: if has_workflow { - ActionStatus::Completed - } else { - ActionStatus::Pending - }, + status: if has_workflow { ActionStatus::Completed } else { ActionStatus::Pending }, detail: None, timestamp: None, operator: None, @@ -356,16 +468,11 @@ pub async fn get_action_thread( }); } - // Step 4: 再分析对比 if detail.reanalysis_id.is_some() || detail.status == "executed" { thread.push(ThreadEvent { step: "reanalysis".into(), label: "前后对比".into(), - status: if detail.reanalysis_id.is_some() { - ActionStatus::Completed - } else { - ActionStatus::Pending - }, + status: if detail.reanalysis_id.is_some() { ActionStatus::Completed } else { ActionStatus::Pending }, detail: None, timestamp: None, operator: None, @@ -373,7 +480,6 @@ pub async fn get_action_thread( }); } - // ── 动态操作按钮 ── let available_actions = match detail.status.as_str() { "pending" => vec![ ActionDefinition { @@ -399,10 +505,7 @@ pub async fn get_action_thread( key: "view_comparison".into(), label: "查看前后对比".into(), variant: "primary".into(), - api_endpoint: Some(format!( - "/api/v1/ai/suggestions/{}/comparison", - detail.id - )), + api_endpoint: Some(format!("/api/v1/ai/suggestions/{}/comparison", detail.id)), }], _ => vec![], }; @@ -410,15 +513,213 @@ pub async fn get_action_thread( let action_item = ActionItem { id: format!("ai_suggestion:{}", detail.id), action_type: ActionType::AiSuggestion, - priority: risk_to_priority(&detail.risk_level), + priority: risk_to_priority(&detail.priority_raw), status: action_status, - title: extract_title(&detail.params, &detail.suggestion_type), - summary: detail - .result_content - .unwrap_or_default() - .chars() - .take(100) - .collect(), + title: extract_title(&detail.params, ""), + summary: summarize_result(&detail.result_content), + patient_id: detail.patient_id, + patient_name: detail.patient_name, + source_ref: detail.id.to_string(), + created_at: detail.created_at, + updated_at: detail.updated_at, + }; + + Ok(Some(ThreadResponse { + action_item, + thread, + available_actions, + })) +} + +async fn get_alert_thread( + db: &DatabaseConnection, + tenant_id: Uuid, + uuid: Uuid, +) -> Result, HealthError> { + let detail: Option = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT al.id, 'alert' AS action_type, al.severity AS priority_raw, + al.status, NULL::jsonb AS params, + NULL::uuid AS workflow_instance_id, NULL::uuid AS reanalysis_id, + al.created_at, al.updated_at, + NULL::uuid AS source_id, al.patient_id, p.name AS patient_name, + NULL::text AS result_content, NULL::timestamptz AS analysis_created_at + FROM alerts al + JOIN patient p ON al.patient_id = p.id + WHERE al.id = $1 AND al.tenant_id = $2 AND al.deleted_at IS NULL + "#, + [uuid.into(), tenant_id.into()], + ), + ) + .one(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + let detail = match detail { + Some(d) => d, + None => return Ok(None), + }; + + let action_status = alert_status_to_action(&detail.status); + + let mut thread = Vec::new(); + + thread.push(ThreadEvent { + step: "alert_triggered".into(), + label: "告警触发".into(), + status: ActionStatus::Completed, + detail: None, + timestamp: Some(detail.created_at), + operator: None, + link_to: Some("/health/alert-dashboard".into()), + }); + + let ack_status = match detail.status.as_str() { + "acknowledged" | "resolved" => ActionStatus::Completed, + "dismissed" | "expired" => ActionStatus::Dismissed, + _ => ActionStatus::Pending, + }; + thread.push(ThreadEvent { + step: "doctor_acknowledge".into(), + label: "医生知悉".into(), + status: ack_status, + detail: None, + timestamp: if matches!(ack_status, ActionStatus::Completed) { Some(detail.updated_at) } else { None }, + operator: None, + link_to: None, + }); + + if detail.status == "resolved" { + thread.push(ThreadEvent { + step: "resolved".into(), + label: "处理完成".into(), + status: ActionStatus::Completed, + detail: None, + timestamp: Some(detail.updated_at), + operator: None, + link_to: None, + }); + } + + let available_actions = match detail.status.as_str() { + "active" => vec![ + ActionDefinition { + key: "acknowledge".into(), + label: "标记已知悉".into(), + variant: "primary".into(), + api_endpoint: Some(format!("/api/v1/health/alerts/{}/acknowledge", detail.id)), + }, + ActionDefinition { + key: "resolve".into(), + label: "标记已处理".into(), + variant: "default".into(), + api_endpoint: Some(format!("/api/v1/health/alerts/{}/resolve", detail.id)), + }, + ], + "acknowledged" => vec![ActionDefinition { + key: "resolve".into(), + label: "标记已处理".into(), + variant: "primary".into(), + api_endpoint: Some(format!("/api/v1/health/alerts/{}/resolve", detail.id)), + }], + _ => vec![], + }; + + let action_item = ActionItem { + id: format!("alert:{}", detail.id), + action_type: ActionType::Alert, + priority: risk_to_priority(&detail.priority_raw), + status: action_status, + title: "健康告警".into(), + summary: summarize_result(&detail.result_content), + patient_id: detail.patient_id, + patient_name: detail.patient_name, + source_ref: detail.id.to_string(), + created_at: detail.created_at, + updated_at: detail.updated_at, + }; + + Ok(Some(ThreadResponse { + action_item, + thread, + available_actions, + })) +} + +async fn get_followup_thread( + db: &DatabaseConnection, + tenant_id: Uuid, + uuid: Uuid, +) -> Result, HealthError> { + let detail: Option = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT f.id, 'followup' AS action_type, 'medium' AS priority_raw, + f.status, NULL::jsonb AS params, + NULL::uuid AS workflow_instance_id, NULL::uuid AS reanalysis_id, + f.created_at, f.updated_at, + NULL::uuid AS source_id, f.patient_id, p.name AS patient_name, + NULL::text AS result_content, NULL::timestamptz AS analysis_created_at + FROM follow_up_task f + JOIN patient p ON f.patient_id = p.id + WHERE f.id = $1 AND f.tenant_id = $2 AND f.deleted_at IS NULL + "#, + [uuid.into(), tenant_id.into()], + ), + ) + .one(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + let detail = match detail { + Some(d) => d, + None => return Ok(None), + }; + + let action_status = followup_status_to_action(&detail.status); + + let mut thread = Vec::new(); + + thread.push(ThreadEvent { + step: "followup_created".into(), + label: "随访创建".into(), + status: ActionStatus::Completed, + detail: None, + timestamp: Some(detail.created_at), + operator: None, + link_to: Some("/health/follow-ups".into()), + }); + + thread.push(ThreadEvent { + step: "execution".into(), + label: "随访执行".into(), + status: action_status, + detail: None, + timestamp: if matches!(action_status, ActionStatus::Completed | ActionStatus::Dismissed) { Some(detail.updated_at) } else { None }, + operator: None, + link_to: None, + }); + + let available_actions = match detail.status.as_str() { + "pending" | "in_progress" => vec![ActionDefinition { + key: "complete".into(), + label: "标记已完成".into(), + variant: "primary".into(), + api_endpoint: Some(format!("/api/v1/health/follow-ups/{}/complete", detail.id)), + }], + _ => vec![], + }; + + let action_item = ActionItem { + id: format!("followup:{}", detail.id), + action_type: ActionType::Followup, + priority: risk_to_priority(&detail.priority_raw), + status: action_status, + title: "随访任务".into(), + summary: String::new(), patient_id: detail.patient_id, patient_name: detail.patient_name, source_ref: detail.id.to_string(), @@ -526,13 +827,104 @@ pub async fn get_workbench_stats( pub async fn get_team_overview( db: &DatabaseConnection, - _tenant_id: Uuid, + tenant_id: Uuid, ) -> Result { - // Phase 1: 返回空结构,待后续实现团队查询 + // 成员统计 + #[derive(Debug, FromQueryResult)] + struct MemberRow { + user_id: Uuid, + display_name: String, + title: String, + pending_count: i64, + completed_count: i64, + overdue_count: i64, + } + + let members: Vec = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT dp.user_id, u.display_name, dp.title, + COUNT(CASE WHEN f.status = 'pending' THEN 1 END) AS pending_count, + COUNT(CASE WHEN f.status = 'completed' THEN 1 END) AS completed_count, + COUNT(CASE WHEN f.status = 'overdue' THEN 1 END) AS overdue_count + FROM doctor_profile dp + JOIN users u ON dp.user_id = u.id + LEFT JOIN follow_up_task f ON f.assigned_to = dp.user_id AND f.tenant_id = $1 AND f.deleted_at IS NULL + WHERE dp.tenant_id = $1 AND dp.deleted_at IS NULL + GROUP BY dp.user_id, u.display_name, dp.title + "#, + [tenant_id.into()], + ), + ) + .all(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + let total_pending: u64 = members.iter().map(|m| m.pending_count as u64).sum(); + let total_completed: u64 = members.iter().map(|m| m.completed_count as u64).sum(); + + let team_members: Vec = members + .into_iter() + .map(|m| { + let total = m.pending_count + m.completed_count + m.overdue_count; + let rate = if total > 0 { + (m.completed_count as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + TeamMemberOverview { + user_id: m.user_id, + name: m.display_name, + title: m.title, + pending_count: m.pending_count as u64, + completed_count: m.completed_count as u64, + overdue_count: m.overdue_count as u64, + completion_rate: rate, + } + }) + .collect(); + + // 风险分布 + #[derive(Debug, FromQueryResult)] + struct RiskRow { + severity: String, + cnt: i64, + } + + let risk_rows: Vec = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT severity, COUNT(*) AS cnt + FROM alerts + WHERE tenant_id = $1 AND status = 'active' AND deleted_at IS NULL + GROUP BY severity + "#, + [tenant_id.into()], + ), + ) + .all(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + let mut risk_distribution = RiskDistribution { + high: 0, + medium: 0, + low: 0, + }; + for row in &risk_rows { + match row.severity.as_str() { + "high" | "urgent" => risk_distribution.high = row.cnt as u64, + "medium" => risk_distribution.medium = row.cnt as u64, + "low" | _ => risk_distribution.low = row.cnt as u64, + } + } + Ok(TeamOverview { - members: vec![], - risk_distribution: RiskDistribution { high: 0, medium: 0, low: 0 }, - total_pending: 0, - total_completed: 0, + members: team_members, + risk_distribution, + total_pending, + total_completed, }) } diff --git a/crates/erp-health/src/service/stats_service.rs b/crates/erp-health/src/service/stats_service.rs index f97de36..c2923da 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, Statement, DatabaseBackend}; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult}; use erp_core::error::AppResult; @@ -722,6 +722,49 @@ pub async fn get_personal_stats( // abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置) let abnormal_vital_signs: i64 = 0; + // ── 昨日对比数据 ── + let yesterday_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 - INTERVAL '1 day'"))) + .count(db) + .await? as i64 + } else { + 0 + }; + + let yesterday_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 - INTERVAL '1 day'"))) + .count(db) + .await? as i64; + + let yesterday_overdue = 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(follow_up_task::Column::Status.eq("overdue")) + .filter(Expr::col(follow_up_task::Column::UpdatedAt).lt(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64; + + let yesterday_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(Expr::col(consultation_session::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())"))) + .filter(Expr::col(consultation_session::Column::CreatedAt).lt(Expr::cust("CURRENT_DATE"))) + .count(db) + .await? as i64 + } else { + 0 + }; + Ok(PersonalStatsResp { my_patients, new_patients_this_month, @@ -736,5 +779,11 @@ pub async fn get_personal_stats( vital_signs_reported, vital_signs_total, pending_lab_reviews, + yesterday_my_patients: None, + yesterday_today_appointments: Some(yesterday_appointments), + yesterday_consultations_this_month: Some(yesterday_consultations), + yesterday_follow_up_rate: None, + yesterday_today_follow_ups: Some(yesterday_follow_ups), + yesterday_overdue_follow_ups: Some(yesterday_overdue), }) }