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_reported: number;
|
||||||
vital_signs_total: number;
|
vital_signs_total: number;
|
||||||
pending_lab_reviews: 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 {
|
export interface OverviewStatistics {
|
||||||
@@ -221,6 +227,51 @@ export interface HealthDataStats {
|
|||||||
|
|
||||||
// --- API ---
|
// --- 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 = {
|
export const pointsApi = {
|
||||||
// Rules
|
// Rules
|
||||||
listRules: async () => {
|
listRules: async () => {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ interface StatCardDef {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
getValue: (p: PersonalStats | null, s: ReturnType<typeof useStatsData>) => number;
|
getValue: (p: PersonalStats | null, s: ReturnType<typeof useStatsData>) => number;
|
||||||
|
getDiff?: (p: PersonalStats | null) => number | undefined;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -106,15 +107,15 @@ const STAT_TEXT_COLORS: string[] = ['#2563EB', '#7C3AED', '#DC2626', '#D97706'];
|
|||||||
|
|
||||||
const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
|
const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
|
||||||
doctor: [
|
doctor: [
|
||||||
{ key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, icon: <TeamOutlined />, path: '/health/patients' },
|
{ 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, icon: <CalendarOutlined />, path: '/health/appointments' },
|
{ 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, icon: <MessageOutlined />, path: '/health/consultations' },
|
{ 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' },
|
{ key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <HeartOutlined />, suffix: '%', path: '/health/follow-ups' },
|
||||||
],
|
],
|
||||||
nurse: [
|
nurse: [
|
||||||
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
|
{ 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, icon: <HeartOutlined />, path: '/health/follow-ups' },
|
{ 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, icon: <AlertOutlined />, 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' },
|
{ key: 'vital-rate', title: '体征上报率', getValue: (p) => p?.vital_signs_report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
|
||||||
],
|
],
|
||||||
admin: [
|
admin: [
|
||||||
@@ -249,6 +250,7 @@ export default function Home() {
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||||
{statDefs.map((def, i) => {
|
{statDefs.map((def, i) => {
|
||||||
const value = def.getValue(personalStats, statsData);
|
const value = def.getValue(personalStats, statsData);
|
||||||
|
const diff = def.getDiff?.(personalStats);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={def.key}
|
key={def.key}
|
||||||
@@ -275,6 +277,11 @@ export default function Home() {
|
|||||||
<StatValue value={value} loading={loading} />
|
<StatValue value={value} loading={loading} />
|
||||||
{def.suffix && <span style={{ fontSize: 14, marginLeft: 2 }}>{def.suffix}</span>}
|
{def.suffix && <span style={{ fontSize: 14, marginLeft: 2 }}>{def.suffix}</span>}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function ActionDetailDrawer({
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
actionInboxApi
|
actionInboxApi
|
||||||
.getThread(item.source_ref)
|
.getThread(item.id)
|
||||||
.then(setThread)
|
.then(setThread)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [item, open]);
|
}, [item, open]);
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ pub struct PersonalStatsResp {
|
|||||||
pub vital_signs_reported: i64,
|
pub vital_signs_reported: i64,
|
||||||
pub vital_signs_total: i64,
|
pub vital_signs_total: i64,
|
||||||
pub pending_lab_reviews: 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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ActionType {
|
pub enum ActionType {
|
||||||
AiSuggestion,
|
AiSuggestion,
|
||||||
|
Alert,
|
||||||
|
Followup,
|
||||||
|
DataAnomaly,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -23,7 +26,7 @@ pub enum ActionPriority {
|
|||||||
Low,
|
Low,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ActionStatus {
|
pub enum ActionStatus {
|
||||||
Pending,
|
Pending,
|
||||||
@@ -92,8 +95,8 @@ pub struct ActionInboxQuery {
|
|||||||
#[derive(Debug, FromQueryResult)]
|
#[derive(Debug, FromQueryResult)]
|
||||||
struct ActionItemRow {
|
struct ActionItemRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
suggestion_type: String,
|
action_type: String,
|
||||||
risk_level: String,
|
priority_raw: String,
|
||||||
status: String,
|
status: String,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
@@ -101,7 +104,7 @@ struct ActionItemRow {
|
|||||||
patient_id: Uuid,
|
patient_id: Uuid,
|
||||||
patient_name: String,
|
patient_name: String,
|
||||||
result_content: Option<String>,
|
result_content: Option<String>,
|
||||||
_analysis_id: Uuid,
|
source_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromQueryResult)]
|
#[derive(Debug, FromQueryResult)]
|
||||||
@@ -110,28 +113,28 @@ struct CountRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromQueryResult)]
|
#[derive(Debug, FromQueryResult)]
|
||||||
struct SuggestionDetail {
|
struct ActionDetail {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
suggestion_type: String,
|
action_type: String,
|
||||||
risk_level: String,
|
priority_raw: String,
|
||||||
status: String,
|
status: String,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
workflow_instance_id: Option<Uuid>,
|
workflow_instance_id: Option<Uuid>,
|
||||||
reanalysis_id: Option<Uuid>,
|
reanalysis_id: Option<Uuid>,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
updated_at: DateTime<Utc>,
|
updated_at: DateTime<Utc>,
|
||||||
_analysis_id: Uuid,
|
source_id: Option<Uuid>,
|
||||||
patient_id: Uuid,
|
patient_id: Uuid,
|
||||||
patient_name: String,
|
patient_name: String,
|
||||||
result_content: Option<String>,
|
result_content: Option<String>,
|
||||||
analysis_created_at: DateTime<Utc>,
|
analysis_created_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 辅助函数 ────────────────────────────────────────────────────────
|
// ── 辅助函数 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn risk_to_priority(risk: &str) -> ActionPriority {
|
fn risk_to_priority(risk: &str) -> ActionPriority {
|
||||||
match risk {
|
match risk {
|
||||||
"high" => ActionPriority::Urgent,
|
"high" | "urgent" => ActionPriority::Urgent,
|
||||||
"medium" => ActionPriority::High,
|
"medium" => ActionPriority::High,
|
||||||
"low" => ActionPriority::Medium,
|
"low" => ActionPriority::Medium,
|
||||||
_ => ActionPriority::Low,
|
_ => ActionPriority::Low,
|
||||||
@@ -143,7 +146,33 @@ fn suggestion_status_to_action(status: &str) -> ActionStatus {
|
|||||||
"pending" => ActionStatus::Pending,
|
"pending" => ActionStatus::Pending,
|
||||||
"approved" => ActionStatus::InProgress,
|
"approved" => ActionStatus::InProgress,
|
||||||
"executed" => ActionStatus::Completed,
|
"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))
|
.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 ────────────────────────────────────────────────────────
|
// ── 公开 API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn list_action_items(
|
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 page_size = query.page_size.unwrap_or(20).min(100);
|
||||||
let offset = (page - 1) * page_size;
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
let status_filter = match query.status.as_deref() {
|
// 各段的 status 过滤条件
|
||||||
Some("pending") => "AND s.status = 'pending'",
|
let (sug_status, alert_status, fu_status) = match query.status.as_deref() {
|
||||||
Some("in_progress") => "AND s.status = 'approved'",
|
Some("pending") => (
|
||||||
Some("completed") => "AND s.status = 'executed'",
|
"AND s.status = 'pending'".into(),
|
||||||
Some("dismissed") => "AND s.status IN ('rejected', 'expired', 'parse_failed')",
|
"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!(
|
let data_sql = format!(
|
||||||
r#"
|
r#"{union_sql}
|
||||||
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}
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE s.risk_level WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
|
CASE priority_raw WHEN 'high' THEN 1 WHEN 'urgent' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END,
|
||||||
s.created_at DESC
|
created_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3"#
|
||||||
"#
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let count_sql = format!("SELECT COUNT(*) AS cnt FROM ({union_sql}) sub");
|
||||||
|
|
||||||
let rows: Vec<ActionItemRow> = FromQueryResult::find_by_statement(
|
let rows: Vec<ActionItemRow> = FromQueryResult::find_by_statement(
|
||||||
Statement::from_sql_and_values(
|
Statement::from_sql_and_values(
|
||||||
DatabaseBackend::Postgres,
|
DatabaseBackend::Postgres,
|
||||||
@@ -218,17 +326,6 @@ pub async fn list_action_items(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| HealthError::DbError(e.to_string()))?;
|
.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(
|
let count_row: Option<CountRow> = FromQueryResult::find_by_statement(
|
||||||
Statement::from_sql_and_values(DatabaseBackend::Postgres, count_sql, [tenant_id.into()]),
|
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
|
let items: Vec<ActionItem> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| ActionItem {
|
.map(|r| {
|
||||||
id: format!("ai_suggestion:{}", r.id),
|
let at = match r.action_type.as_str() {
|
||||||
action_type: ActionType::AiSuggestion,
|
"alert" => ActionType::Alert,
|
||||||
priority: risk_to_priority(&r.risk_level),
|
"followup" => ActionType::Followup,
|
||||||
status: suggestion_status_to_action(&r.status),
|
_ => ActionType::AiSuggestion,
|
||||||
title: extract_title(&r.params, &r.suggestion_type),
|
};
|
||||||
summary: r.result_content.unwrap_or_default().chars().take(100).collect(),
|
let title = match r.action_type.as_str() {
|
||||||
patient_id: r.patient_id,
|
"alert" => "健康告警".into(),
|
||||||
patient_name: r.patient_name,
|
"followup" => "随访任务".into(),
|
||||||
source_ref: r.id.to_string(),
|
_ => extract_title(&r.params, ""),
|
||||||
created_at: r.created_at,
|
};
|
||||||
updated_at: r.updated_at,
|
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();
|
.collect();
|
||||||
|
|
||||||
@@ -269,20 +378,35 @@ pub async fn get_action_thread(
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
source_ref: &str,
|
source_ref: &str,
|
||||||
) -> Result<Option<ThreadResponse>, HealthError> {
|
) -> Result<Option<ThreadResponse>, HealthError> {
|
||||||
let suggestion_id = source_ref
|
// 解析 "action_type:uuid" 格式
|
||||||
.strip_prefix("ai_suggestion:")
|
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()))?;
|
.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}")))?;
|
.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(
|
Statement::from_sql_and_values(
|
||||||
DatabaseBackend::Postgres,
|
DatabaseBackend::Postgres,
|
||||||
r#"
|
r#"
|
||||||
SELECT s.id, s.suggestion_type, s.risk_level, s.status, s.params,
|
SELECT s.id, 'ai_suggestion' AS action_type, s.risk_level AS priority_raw,
|
||||||
s.workflow_instance_id, s.reanalysis_id,
|
s.status, s.params, s.workflow_instance_id, s.reanalysis_id,
|
||||||
s.created_at, s.updated_at,
|
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
|
a.result_content, a.created_at AS analysis_created_at
|
||||||
FROM ai_suggestion s
|
FROM ai_suggestion s
|
||||||
JOIN ai_analysis a ON s.analysis_id = a.id
|
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 action_status = suggestion_status_to_action(&detail.status);
|
||||||
|
|
||||||
// ── 拼装线程时间线 ──
|
|
||||||
let mut thread = Vec::new();
|
let mut thread = Vec::new();
|
||||||
|
|
||||||
// Step 1: AI 分析完成
|
|
||||||
thread.push(ThreadEvent {
|
thread.push(ThreadEvent {
|
||||||
step: "ai_analysis".into(),
|
step: "ai_analysis".into(),
|
||||||
label: "AI 分析完成".into(),
|
label: "AI 分析完成".into(),
|
||||||
status: ActionStatus::Completed,
|
status: ActionStatus::Completed,
|
||||||
detail: None,
|
detail: None,
|
||||||
timestamp: Some(detail.analysis_created_at),
|
timestamp: detail.analysis_created_at,
|
||||||
operator: None,
|
operator: None,
|
||||||
link_to: Some("/health/ai-analysis".into()),
|
link_to: Some("/health/ai-analysis".into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: 医生审批
|
|
||||||
let approval_status = match detail.status.as_str() {
|
let approval_status = match detail.status.as_str() {
|
||||||
"approved" | "executed" => ActionStatus::Completed,
|
"approved" | "executed" => ActionStatus::Completed,
|
||||||
"rejected" => ActionStatus::Dismissed,
|
"rejected" => ActionStatus::Dismissed,
|
||||||
@@ -329,26 +450,17 @@ pub async fn get_action_thread(
|
|||||||
label: "医生审批".into(),
|
label: "医生审批".into(),
|
||||||
status: approval_status,
|
status: approval_status,
|
||||||
detail: None,
|
detail: None,
|
||||||
timestamp: if is_terminal {
|
timestamp: if is_terminal { Some(detail.updated_at) } else { None },
|
||||||
Some(detail.updated_at)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
operator: None,
|
operator: None,
|
||||||
link_to: None,
|
link_to: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 3: 执行安排
|
|
||||||
let has_workflow = detail.workflow_instance_id.is_some();
|
let has_workflow = detail.workflow_instance_id.is_some();
|
||||||
if has_workflow || detail.status == "approved" || detail.status == "executed" {
|
if has_workflow || detail.status == "approved" || detail.status == "executed" {
|
||||||
thread.push(ThreadEvent {
|
thread.push(ThreadEvent {
|
||||||
step: "action_dispatched".into(),
|
step: "action_dispatched".into(),
|
||||||
label: "执行安排".into(),
|
label: "执行安排".into(),
|
||||||
status: if has_workflow {
|
status: if has_workflow { ActionStatus::Completed } else { ActionStatus::Pending },
|
||||||
ActionStatus::Completed
|
|
||||||
} else {
|
|
||||||
ActionStatus::Pending
|
|
||||||
},
|
|
||||||
detail: None,
|
detail: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
operator: None,
|
operator: None,
|
||||||
@@ -356,16 +468,11 @@ pub async fn get_action_thread(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: 再分析对比
|
|
||||||
if detail.reanalysis_id.is_some() || detail.status == "executed" {
|
if detail.reanalysis_id.is_some() || detail.status == "executed" {
|
||||||
thread.push(ThreadEvent {
|
thread.push(ThreadEvent {
|
||||||
step: "reanalysis".into(),
|
step: "reanalysis".into(),
|
||||||
label: "前后对比".into(),
|
label: "前后对比".into(),
|
||||||
status: if detail.reanalysis_id.is_some() {
|
status: if detail.reanalysis_id.is_some() { ActionStatus::Completed } else { ActionStatus::Pending },
|
||||||
ActionStatus::Completed
|
|
||||||
} else {
|
|
||||||
ActionStatus::Pending
|
|
||||||
},
|
|
||||||
detail: None,
|
detail: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
operator: None,
|
operator: None,
|
||||||
@@ -373,7 +480,6 @@ pub async fn get_action_thread(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 动态操作按钮 ──
|
|
||||||
let available_actions = match detail.status.as_str() {
|
let available_actions = match detail.status.as_str() {
|
||||||
"pending" => vec![
|
"pending" => vec![
|
||||||
ActionDefinition {
|
ActionDefinition {
|
||||||
@@ -399,10 +505,7 @@ pub async fn get_action_thread(
|
|||||||
key: "view_comparison".into(),
|
key: "view_comparison".into(),
|
||||||
label: "查看前后对比".into(),
|
label: "查看前后对比".into(),
|
||||||
variant: "primary".into(),
|
variant: "primary".into(),
|
||||||
api_endpoint: Some(format!(
|
api_endpoint: Some(format!("/api/v1/ai/suggestions/{}/comparison", detail.id)),
|
||||||
"/api/v1/ai/suggestions/{}/comparison",
|
|
||||||
detail.id
|
|
||||||
)),
|
|
||||||
}],
|
}],
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
@@ -410,15 +513,213 @@ pub async fn get_action_thread(
|
|||||||
let action_item = ActionItem {
|
let action_item = ActionItem {
|
||||||
id: format!("ai_suggestion:{}", detail.id),
|
id: format!("ai_suggestion:{}", detail.id),
|
||||||
action_type: ActionType::AiSuggestion,
|
action_type: ActionType::AiSuggestion,
|
||||||
priority: risk_to_priority(&detail.risk_level),
|
priority: risk_to_priority(&detail.priority_raw),
|
||||||
status: action_status,
|
status: action_status,
|
||||||
title: extract_title(&detail.params, &detail.suggestion_type),
|
title: extract_title(&detail.params, ""),
|
||||||
summary: detail
|
summary: summarize_result(&detail.result_content),
|
||||||
.result_content
|
patient_id: detail.patient_id,
|
||||||
.unwrap_or_default()
|
patient_name: detail.patient_name,
|
||||||
.chars()
|
source_ref: detail.id.to_string(),
|
||||||
.take(100)
|
created_at: detail.created_at,
|
||||||
.collect(),
|
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_id: detail.patient_id,
|
||||||
patient_name: detail.patient_name,
|
patient_name: detail.patient_name,
|
||||||
source_ref: detail.id.to_string(),
|
source_ref: detail.id.to_string(),
|
||||||
@@ -526,13 +827,104 @@ pub async fn get_workbench_stats(
|
|||||||
|
|
||||||
pub async fn get_team_overview(
|
pub async fn get_team_overview(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
_tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
) -> Result<TeamOverview, HealthError> {
|
) -> 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 {
|
Ok(TeamOverview {
|
||||||
members: vec![],
|
members: team_members,
|
||||||
risk_distribution: RiskDistribution { high: 0, medium: 0, low: 0 },
|
risk_distribution,
|
||||||
total_pending: 0,
|
total_pending,
|
||||||
total_completed: 0,
|
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;
|
use erp_core::error::AppResult;
|
||||||
|
|
||||||
@@ -722,6 +722,49 @@ pub async fn get_personal_stats(
|
|||||||
// abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置)
|
// abnormal_vital_signs: 简化实现,返回 0(完整实现需要关联危急值阈值配置)
|
||||||
let abnormal_vital_signs: i64 = 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 {
|
Ok(PersonalStatsResp {
|
||||||
my_patients,
|
my_patients,
|
||||||
new_patients_this_month,
|
new_patients_this_month,
|
||||||
@@ -736,5 +779,11 @@ pub async fn get_personal_stats(
|
|||||||
vital_signs_reported,
|
vital_signs_reported,
|
||||||
vital_signs_total,
|
vital_signs_total,
|
||||||
pending_lab_reviews,
|
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