feat(health): 工作台遗留项修复 — UNION ALL 聚合 + 团队概览 + 较昨日对比
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

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
This commit is contained in:
iven
2026-05-01 23:25:38 +08:00
parent 310a3cec90
commit 5e52b0a34c
6 changed files with 616 additions and 110 deletions

View File

@@ -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<PointsTransactionDetail>;
}>(`/health/admin/points/patients/${patientId}/transactions`, { params });
return data.data;
},
};
// --- API (original) ---
export const pointsApi = {
// Rules
listRules: async () => {

View File

@@ -78,6 +78,7 @@ interface StatCardDef {
key: string;
title: string;
getValue: (p: PersonalStats | null, s: ReturnType<typeof useStatsData>) => 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<DashboardRole, StatCardDef[]> = {
doctor: [
{ key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, icon: <TeamOutlined />, path: '/health/patients' },
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
{ key: 'consultations', title: '本月咨询', getValue: (p) => p?.consultations_this_month ?? 0, icon: <MessageOutlined />, 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: <TeamOutlined />, 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: <CalendarOutlined />, 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: <MessageOutlined />, path: '/health/consultations' },
{ key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <HeartOutlined />, suffix: '%', path: '/health/follow-ups' },
],
nurse: [
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
{ key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, icon: <HeartOutlined />, path: '/health/follow-ups' },
{ key: 'overdue', title: '逾期随访', getValue: (p) => p?.overdue_follow_ups ?? 0, icon: <AlertOutlined />, 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: <CalendarOutlined />, 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: <HeartOutlined />, 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: <AlertOutlined />, path: '/health/follow-ups' },
{ key: 'vital-rate', title: '体征上报率', getValue: (p) => p?.vital_signs_report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
],
admin: [
@@ -249,6 +250,7 @@ export default function Home() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{statDefs.map((def, i) => {
const value = def.getValue(personalStats, statsData);
const diff = def.getDiff?.(personalStats);
return (
<div
key={def.key}
@@ -275,6 +277,11 @@ export default function Home() {
<StatValue value={value} loading={loading} />
{def.suffix && <span style={{ fontSize: 14, marginLeft: 2 }}>{def.suffix}</span>}
</div>
{diff != null && (
<div style={{ fontSize: 11, marginTop: 4, color: diff > 0 ? '#16A34A' : diff < 0 ? '#DC2626' : '#94A3B8' }}>
{diff === 0 ? '与昨日持平' : `较昨日 ${diff > 0 ? '+' : ''}${diff}`}
</div>
)}
</div>
</div>
);

View File

@@ -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]);

View File

@@ -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<i64>,
pub yesterday_today_appointments: Option<i64>,
pub yesterday_consultations_this_month: Option<i64>,
pub yesterday_follow_up_rate: Option<f64>,
pub yesterday_today_follow_ups: Option<i64>,
pub yesterday_overdue_follow_ups: Option<i64>,
}
// ---------------------------------------------------------------------------

View File

@@ -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<serde_json::Value>,
created_at: DateTime<Utc>,
@@ -101,7 +104,7 @@ struct ActionItemRow {
patient_id: Uuid,
patient_name: String,
result_content: Option<String>,
_analysis_id: Uuid,
source_id: Option<Uuid>,
}
#[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<serde_json::Value>,
workflow_instance_id: Option<Uuid>,
reanalysis_id: Option<Uuid>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
_analysis_id: Uuid,
source_id: Option<Uuid>,
patient_id: Uuid,
patient_name: String,
result_content: Option<String>,
analysis_created_at: DateTime<Utc>,
analysis_created_at: Option<DateTime<Utc>>,
}
// ── 辅助函数 ────────────────────────────────────────────────────────
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<serde_json::Value>, suggestion_type: &str) -> S
.unwrap_or_else(|| suggestion_type_to_title(suggestion_type))
}
fn summarize_result(result: &Option<String>) -> String {
let Some(text) = result else { return String::new() };
let parsed = serde_json::from_str::<serde_json::Value>(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::<String>());
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<String> = 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<ActionItemRow> = 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<CountRow> = 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<ActionItem> = 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<Option<ThreadResponse>, 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<SuggestionDetail> = 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<Option<ThreadResponse>, HealthError> {
let detail: Option<ActionDetail> = 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<Option<ThreadResponse>, HealthError> {
let detail: Option<ActionDetail> = 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<Option<ThreadResponse>, HealthError> {
let detail: Option<ActionDetail> = 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<TeamOverview, HealthError> {
// 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<MemberRow> = 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<TeamMemberOverview> = 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<RiskRow> = 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,
})
}

View File

@@ -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),
})
}