diff --git a/crates/erp-health/src/handler/action_inbox_handler.rs b/crates/erp-health/src/handler/action_inbox_handler.rs new file mode 100644 index 0000000..0453f1f --- /dev/null +++ b/crates/erp-health/src/handler/action_inbox_handler.rs @@ -0,0 +1,43 @@ +use axum::extract::{FromRef, Json, Path, Query, State}; +use axum::Extension; +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::service::action_inbox_service::{ + self, ActionInboxQuery, ActionItem, ThreadResponse, +}; +use crate::state::HealthState; + +pub async fn list_action_inbox( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.action-inbox.list")?; + let result = + action_inbox_service::list_action_items(&state.db, ctx.tenant_id, &query).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_action_thread( + State(state): State, + Extension(ctx): Extension, + Path(source_ref): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.action-inbox.list")?; + let result = + action_inbox_service::get_action_thread(&state.db, ctx.tenant_id, &source_ref).await?; + match result { + Some(resp) => Ok(Json(ApiResponse::ok(resp))), + None => Err(crate::error::HealthError::Validation("行动项未找到".into()).into()), + } +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 47222ec..94dfc7a 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -1,3 +1,4 @@ +pub mod action_inbox_handler; pub mod alert_handler; pub mod alert_rule_handler; pub mod appointment_handler; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index b6a403c..5ed5fcd 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -6,6 +6,7 @@ use erp_core::events::EventBus; use erp_core::module::{ErpModule, PermissionDescriptor}; use crate::handler::{ + action_inbox_handler, alert_handler, alert_rule_handler, appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, health_data_handler, medication_record_handler, medication_reminder_handler, patient_handler, points_handler, stats_handler, @@ -685,6 +686,15 @@ impl HealthModule { "/health/devices/{id}", axum::routing::delete(device_handler::unbind_device), ) + // 行动收件箱 + .route( + "/health/action-inbox", + axum::routing::get(action_inbox_handler::list_action_inbox), + ) + .route( + "/health/action-inbox/{source_ref}/thread", + axum::routing::get(action_inbox_handler::get_action_thread), + ) } } @@ -1051,6 +1061,19 @@ impl ErpModule for HealthModule { description: "创建/编辑/删除药物提醒".into(), module: "health".into(), }, + // 行动收件箱 + PermissionDescriptor { + code: "health.action-inbox.list".into(), + name: "查看行动收件箱".into(), + description: "查看统一行动收件箱中的待办事项".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.action-inbox.manage".into(), + name: "管理行动项".into(), + description: "审批/拒绝/标记行动收件箱中的事项".into(), + module: "health".into(), + }, ] } diff --git a/crates/erp-health/src/service/action_inbox_service.rs b/crates/erp-health/src/service/action_inbox_service.rs new file mode 100644 index 0000000..ede6984 --- /dev/null +++ b/crates/erp-health/src/service/action_inbox_service.rs @@ -0,0 +1,434 @@ +use chrono::{DateTime, Utc}; +use erp_core::types::PaginatedResponse; +use sea_orm::{DatabaseBackend, DatabaseConnection, FromQueryResult, Statement}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::HealthError; + +// ── DTO ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActionType { + AiSuggestion, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActionPriority { + Urgent, + High, + Medium, + Low, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActionStatus { + Pending, + InProgress, + Completed, + Dismissed, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ActionItem { + pub id: String, + pub action_type: ActionType, + pub priority: ActionPriority, + pub status: ActionStatus, + pub title: String, + pub summary: String, + pub patient_id: Uuid, + pub patient_name: String, + pub source_ref: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThreadEvent { + pub step: String, + pub label: String, + pub status: ActionStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub operator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub link_to: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ActionDefinition { + pub key: String, + pub label: String, + pub variant: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_endpoint: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThreadResponse { + pub action_item: ActionItem, + pub thread: Vec, + pub available_actions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ActionInboxQuery { + pub status: Option, + #[serde(rename = "type")] + pub action_type: Option, + pub page: Option, + pub page_size: Option, +} + +// ── 内部查询结构体 ────────────────────────────────────────────────── + +#[derive(Debug, FromQueryResult)] +struct ActionItemRow { + id: Uuid, + suggestion_type: String, + risk_level: String, + status: String, + params: Option, + created_at: DateTime, + updated_at: DateTime, + patient_id: Uuid, + patient_name: String, + result_content: Option, + _analysis_id: Uuid, +} + +#[derive(Debug, FromQueryResult)] +struct CountRow { + cnt: i64, +} + +#[derive(Debug, FromQueryResult)] +struct SuggestionDetail { + id: Uuid, + suggestion_type: String, + risk_level: String, + status: String, + params: Option, + workflow_instance_id: Option, + reanalysis_id: Option, + created_at: DateTime, + updated_at: DateTime, + _analysis_id: Uuid, + patient_id: Uuid, + patient_name: String, + result_content: Option, + analysis_created_at: DateTime, +} + +// ── 辅助函数 ──────────────────────────────────────────────────────── + +fn risk_to_priority(risk: &str) -> ActionPriority { + match risk { + "high" => ActionPriority::Urgent, + "medium" => ActionPriority::High, + "low" => ActionPriority::Medium, + _ => ActionPriority::Low, + } +} + +fn suggestion_status_to_action(status: &str) -> ActionStatus { + match status { + "pending" => ActionStatus::Pending, + "approved" => ActionStatus::InProgress, + "executed" => ActionStatus::Completed, + _ => ActionStatus::Dismissed, // rejected, expired, parse_failed + } +} + +fn suggestion_type_to_title(suggestion_type: &str) -> String { + match suggestion_type { + "followup" => "建议安排随访".into(), + "appointment" => "建议预约复查".into(), + "alert" => "建议关注指标异常".into(), + _ => "AI 健康建议".into(), + } +} + +fn extract_title(params: &Option, suggestion_type: &str) -> String { + params + .as_ref() + .and_then(|p| p.get("reason")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| suggestion_type_to_title(suggestion_type)) +} + +// ── 公开 API ──────────────────────────────────────────────────────── + +pub async fn list_action_items( + db: &DatabaseConnection, + tenant_id: Uuid, + query: &ActionInboxQuery, +) -> Result, HealthError> { + let page = query.page.unwrap_or(1).max(1); + 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')", + _ => "", + }; + + 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} + 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 + "# + ); + + let rows: Vec = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + data_sql, + [ + tenant_id.into(), + (page_size as i64).into(), + (offset as i64).into(), + ], + ), + ) + .all(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + let count_sql = format!( + r#" + SELECT COUNT(*) AS cnt + FROM ai_suggestion s + JOIN ai_analysis a ON s.analysis_id = a.id + WHERE s.tenant_id = $1 + AND s.deleted_at IS NULL + {status_filter} + "# + ); + + let count_row: Option = FromQueryResult::find_by_statement( + Statement::from_sql_and_values(DatabaseBackend::Postgres, count_sql, [tenant_id.into()]), + ) + .one(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))?; + + let total = count_row.map(|r| r.cnt).unwrap_or(0) as u64; + + let items: Vec = rows + .into_iter() + .map(|r| ActionItem { + id: format!("ai_suggestion:{}", r.id), + action_type: ActionType::AiSuggestion, + priority: risk_to_priority(&r.risk_level), + status: suggestion_status_to_action(&r.status), + title: extract_title(&r.params, &r.suggestion_type), + summary: r.result_content.unwrap_or_default().chars().take(100).collect(), + patient_id: r.patient_id, + patient_name: r.patient_name, + source_ref: r.id.to_string(), + created_at: r.created_at, + updated_at: r.updated_at, + }) + .collect(); + + Ok(PaginatedResponse { + data: items, + total, + page, + page_size, + total_pages: if page_size > 0 { (total + page_size - 1) / page_size } else { 0 }, + }) +} + +pub async fn get_action_thread( + db: &DatabaseConnection, + tenant_id: Uuid, + source_ref: &str, +) -> Result, HealthError> { + let suggestion_id = source_ref + .strip_prefix("ai_suggestion:") + .ok_or_else(|| HealthError::Validation("无效的 source_ref 格式".into()))?; + let uuid = Uuid::parse_str(suggestion_id) + .map_err(|e| HealthError::Validation(format!("无效的 UUID: {e}")))?; + + let detail: Option = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#" + SELECT s.id, s.suggestion_type, s.risk_level, s.status, s.params, + s.workflow_instance_id, s.reanalysis_id, + s.created_at, s.updated_at, + s.analysis_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 + JOIN patient p ON a.patient_id = p.id + WHERE s.id = $1 AND s.tenant_id = $2 AND s.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 = 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), + 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, + _ => ActionStatus::InProgress, + }; + let is_terminal = matches!(approval_status, ActionStatus::Completed | ActionStatus::Dismissed); + thread.push(ThreadEvent { + step: "doctor_approval".into(), + label: "医生审批".into(), + status: approval_status, + detail: 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 + }, + detail: None, + timestamp: None, + operator: None, + link_to: None, + }); + } + + // 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 + }, + detail: None, + timestamp: None, + operator: None, + link_to: detail.reanalysis_id.map(|id| format!("/health/ai-analysis/{id}")), + }); + } + + // ── 动态操作按钮 ── + let available_actions = match detail.status.as_str() { + "pending" => vec![ + ActionDefinition { + key: "approve".into(), + label: "批准并执行".into(), + variant: "primary".into(), + api_endpoint: Some(format!("/api/v1/ai/suggestions/{}/approve", detail.id)), + }, + ActionDefinition { + key: "reject".into(), + label: "拒绝".into(), + variant: "danger".into(), + api_endpoint: Some(format!("/api/v1/ai/suggestions/{}/approve", detail.id)), + }, + ], + "approved" => vec![ActionDefinition { + key: "acknowledge".into(), + label: "标记已知悉".into(), + variant: "default".into(), + api_endpoint: None, + }], + "executed" => vec![ActionDefinition { + key: "view_comparison".into(), + label: "查看前后对比".into(), + variant: "primary".into(), + api_endpoint: Some(format!( + "/api/v1/ai/suggestions/{}/comparison", + detail.id + )), + }], + _ => vec![], + }; + + let action_item = ActionItem { + id: format!("ai_suggestion:{}", detail.id), + action_type: ActionType::AiSuggestion, + priority: risk_to_priority(&detail.risk_level), + status: action_status, + title: extract_title(&detail.params, &detail.suggestion_type), + summary: detail + .result_content + .unwrap_or_default() + .chars() + .take(100) + .collect(), + 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, + })) +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 87db442..1b89bbd 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -1,3 +1,4 @@ +pub mod action_inbox_service; pub mod ai_action_dispatcher; pub mod ai_suggestion_loader; pub mod alert_engine;