feat(health): 工作台遗留项修复 — UNION ALL 聚合 + 团队概览 + 较昨日对比
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:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user