diff --git a/crates/erp-health/src/handler/action_inbox_handler.rs b/crates/erp-health/src/handler/action_inbox_handler.rs index 0453f1f..01c2334 100644 --- a/crates/erp-health/src/handler/action_inbox_handler.rs +++ b/crates/erp-health/src/handler/action_inbox_handler.rs @@ -5,7 +5,7 @@ use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::service::action_inbox_service::{ - self, ActionInboxQuery, ActionItem, ThreadResponse, + self, ActionInboxQuery, ActionItem, TeamOverview, ThreadResponse, WorkbenchStats, }; use crate::state::HealthState; @@ -41,3 +41,31 @@ where None => Err(crate::error::HealthError::Validation("行动项未找到".into()).into()), } } + +pub async fn get_workbench_stats( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.action-inbox.list")?; + let result = + action_inbox_service::get_workbench_stats(&state.db, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_team_overview( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.action-inbox.team")?; + let result = + action_inbox_service::get_team_overview(&state.db, ctx.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 5ed5fcd..26764e1 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -691,6 +691,14 @@ impl HealthModule { "/health/action-inbox", axum::routing::get(action_inbox_handler::list_action_inbox), ) + .route( + "/health/action-inbox/stats", + axum::routing::get(action_inbox_handler::get_workbench_stats), + ) + .route( + "/health/action-inbox/team", + axum::routing::get(action_inbox_handler::get_team_overview), + ) .route( "/health/action-inbox/{source_ref}/thread", axum::routing::get(action_inbox_handler::get_action_thread), @@ -1074,6 +1082,12 @@ impl ErpModule for HealthModule { description: "审批/拒绝/标记行动收件箱中的事项".into(), module: "health".into(), }, + PermissionDescriptor { + code: "health.action-inbox.team".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 index ede6984..4997b26 100644 --- a/crates/erp-health/src/service/action_inbox_service.rs +++ b/crates/erp-health/src/service/action_inbox_service.rs @@ -432,3 +432,107 @@ pub async fn get_action_thread( available_actions, })) } + +// ── 工作台统计 ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct WorkbenchStats { + pub total_pending: u64, + pub ai_suggestion_pending: u64, + pub urgent_alerts: u64, + pub followup_due: u64, + pub completion_rate: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TeamMemberOverview { + pub user_id: Uuid, + pub name: String, + pub title: String, + pub pending_count: u64, + pub completed_count: u64, + pub overdue_count: u64, + pub completion_rate: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RiskDistribution { + pub high: u64, + pub medium: u64, + pub low: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TeamOverview { + pub members: Vec, + pub risk_distribution: RiskDistribution, + pub total_pending: u64, + pub total_completed: u64, +} + +pub async fn get_workbench_stats( + db: &DatabaseConnection, + tenant_id: Uuid, +) -> Result { + let ai_pending: i64 = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*) AS cnt FROM ai_suggestion WHERE tenant_id = $1 AND status = 'pending' AND deleted_at IS NULL", + [tenant_id.into()], + ), + ) + .one(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))? + .map(|r: CountRow| r.cnt) + .unwrap_or(0); + + let urgent_alerts: i64 = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*) AS cnt FROM alert WHERE tenant_id = $1 AND severity = 'urgent' AND status = 'active' AND deleted_at IS NULL", + [tenant_id.into()], + ), + ) + .one(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))? + .map(|r: CountRow| r.cnt) + .unwrap_or(0); + + let followup_due: i64 = FromQueryResult::find_by_statement( + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COUNT(*) AS cnt FROM follow_up_plan WHERE tenant_id = $1 AND status = 'scheduled' AND next_date <= NOW() AND deleted_at IS NULL", + [tenant_id.into()], + ), + ) + .one(db) + .await + .map_err(|e| HealthError::DbError(e.to_string()))? + .map(|r: CountRow| r.cnt) + .unwrap_or(0); + + let total_pending = (ai_pending + urgent_alerts + followup_due) as u64; + + Ok(WorkbenchStats { + total_pending, + ai_suggestion_pending: ai_pending as u64, + urgent_alerts: urgent_alerts as u64, + followup_due: followup_due as u64, + completion_rate: None, + }) +} + +pub async fn get_team_overview( + db: &DatabaseConnection, + _tenant_id: Uuid, +) -> Result { + // Phase 1: 返回空结构,待后续实现团队查询 + Ok(TeamOverview { + members: vec![], + risk_distribution: RiskDistribution { high: 0, medium: 0, low: 0 }, + total_pending: 0, + total_completed: 0, + }) +}