diff --git a/crates/erp-ai/src/dto/suggestion.rs b/crates/erp-ai/src/dto/suggestion.rs index 46f7d87..6d9e71e 100644 --- a/crates/erp-ai/src/dto/suggestion.rs +++ b/crates/erp-ai/src/dto/suggestion.rs @@ -67,6 +67,19 @@ impl SuggestionStatus { Self::ParseFailed => "parse_failed", } } + + /// 合法状态转换:仅允许相邻状态单向流转 + pub fn can_transition_to(&self, target: SuggestionStatus) -> bool { + matches!( + (self, target), + (Self::Pending, Self::Approved) + | (Self::Pending, Self::Rejected) + | (Self::Pending, Self::Expired) + | (Self::Approved, Self::Executed) + | (Self::Approved, Self::Rejected) + | (Self::Approved, Self::Expired) + ) + } } /// AI 输出的单条结构化建议 diff --git a/crates/erp-ai/src/handler/suggestion_handler.rs b/crates/erp-ai/src/handler/suggestion_handler.rs index 69bc710..41cdcbd 100644 --- a/crates/erp-ai/src/handler/suggestion_handler.rs +++ b/crates/erp-ai/src/handler/suggestion_handler.rs @@ -1,4 +1,4 @@ -use axum::extract::{Extension, FromRef, Path, Query, State}; +use axum::extract::{Extension, FromRef, Path, State}; use axum::Json; use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, TenantContext}; @@ -17,7 +17,7 @@ pub struct ListSuggestionsQuery { pub async fn list_suggestions( State(state): State, Extension(ctx): Extension, - Query(params): Query, + axum::extract::Query(params): axum::extract::Query, ) -> Result>, erp_core::error::AppError> where AiState: FromRef, @@ -73,7 +73,7 @@ where } }; - SuggestionService::update_status( + let updated = SuggestionService::update_status( &state.db, id, ctx.tenant_id, @@ -82,12 +82,51 @@ where ) .await?; + // 发布建议状态变更事件 + publish_status_event(&state, &updated).await; + Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id, "status": new_status.as_str(), })))) } +#[derive(Debug, Deserialize)] +pub struct ExecuteBody { + pub action_result: Option, +} + +/// 执行建议:护士标记建议为已执行,可选记录执行结果 +pub async fn execute_suggestion( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.suggestion.manage")?; + + let updated = SuggestionService::execute_suggestion( + &state.db, + id, + ctx.tenant_id, + body.action_result, + Some(ctx.user_id), + ) + .await?; + + // 发布建议状态变更事件 + publish_status_event(&state, &updated).await; + + Ok(Json(ApiResponse::ok(serde_json::json!({ + "id": id, + "status": "executed", + })))) +} + /// 获取 AI 建议的前后对比报告。 pub async fn get_comparison( State(state): State, @@ -127,3 +166,20 @@ where })))), } } + +/// 发布建议状态变更事件 +async fn publish_status_event(state: &AiState, suggestion: &crate::entity::ai_suggestion::Model) { + let event = erp_core::events::DomainEvent::new( + "ai.suggestion.status_changed", + suggestion.tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "suggestion_id": suggestion.id, + "analysis_id": suggestion.analysis_id, + "suggestion_type": suggestion.suggestion_type, + "risk_level": suggestion.risk_level, + "status": suggestion.status, + "updated_by": suggestion.updated_by, + })), + ); + state.event_bus.publish(event, &state.db).await; +} diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index c9bbb02..84d7c1f 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -219,6 +219,10 @@ impl AiModule { "/ai/suggestions/{id}/approve", axum::routing::post(crate::handler::suggestion_handler::approve_suggestion), ) + .route( + "/ai/suggestions/{id}/execute", + axum::routing::post(crate::handler::suggestion_handler::execute_suggestion), + ) .route( "/ai/suggestions/{id}/comparison", axum::routing::get(crate::handler::suggestion_handler::get_comparison), diff --git a/crates/erp-ai/src/service/suggestion.rs b/crates/erp-ai/src/service/suggestion.rs index 73e9281..bb3fb71 100644 --- a/crates/erp-ai/src/service/suggestion.rs +++ b/crates/erp-ai/src/service/suggestion.rs @@ -71,14 +71,14 @@ impl SuggestionService { Ok(items) } - /// 更新建议状态(带乐观锁 + tenant_id 过滤) + /// 更新建议状态(带合法转换校验 + 乐观锁 + tenant_id 过滤) pub async fn update_status( db: &sea_orm::DatabaseConnection, suggestion_id: Uuid, tenant_id: Uuid, new_status: SuggestionStatus, updated_by: Option, - ) -> AppResult<()> { + ) -> AppResult { let item = ai_suggestion::Entity::find() .filter(ai_suggestion::Column::Id.eq(suggestion_id)) .filter(ai_suggestion::Column::TenantId.eq(tenant_id)) @@ -89,13 +89,85 @@ impl SuggestionService { crate::error::AiError::AnalysisNotFound("建议不存在".into()) })?; + let current_status = parse_status(&item.status); + if !current_status.can_transition_to(new_status) { + return Err(crate::error::AiError::Validation(format!( + "建议状态不允许从 {} 转换为 {}", + current_status.as_str(), + new_status.as_str() + )) + .into()); + } + let current_version = item.version_lock; let mut active: ai_suggestion::ActiveModel = item.into(); active.status = Set(new_status.as_str().to_string()); active.updated_by = Set(updated_by); active.version_lock = Set(current_version + 1); - active.update(db).await?; - Ok(()) + let updated = active.update(db).await?; + Ok(updated) + } + + /// 执行建议:状态从 Approved → Executed,同时记录执行结果 + pub async fn execute_suggestion( + db: &sea_orm::DatabaseConnection, + suggestion_id: Uuid, + tenant_id: Uuid, + action_result: Option, + executed_by: Option, + ) -> AppResult { + let item = ai_suggestion::Entity::find() + .filter(ai_suggestion::Column::Id.eq(suggestion_id)) + .filter(ai_suggestion::Column::TenantId.eq(tenant_id)) + .filter(ai_suggestion::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| { + crate::error::AiError::AnalysisNotFound("建议不存在".into()) + })?; + + let current_status = parse_status(&item.status); + // 允许从 Pending 或 Approved 直接执行(护士可能跳过审批) + if !matches!(current_status, SuggestionStatus::Pending | SuggestionStatus::Approved) { + return Err(crate::error::AiError::Validation(format!( + "建议状态为 {},无法执行(需要 pending 或 approved)", + current_status.as_str() + )) + .into()); + } + + let current_version = item.version_lock; + let mut active: ai_suggestion::ActiveModel = item.into(); + active.status = Set(SuggestionStatus::Executed.as_str().to_string()); + active.action_result = Set(action_result); + active.updated_by = Set(executed_by); + active.version_lock = Set(current_version + 1); + let updated = active.update(db).await?; + Ok(updated) + } + + /// 批量过期超时建议(超过 N 天仍为 Pending/Approved 的建议标记为 Expired) + pub async fn expire_stale_suggestions( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + max_age_days: i64, + ) -> AppResult { + let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days); + let sql = r#" + UPDATE ai_suggestion + SET status = 'expired', updated_at = NOW(), version_lock = version_lock + 1 + WHERE tenant_id = $1 + AND deleted_at IS NULL + AND status IN ('pending', 'approved') + AND created_at < $2 + "#; + let result = sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [tenant_id.into(), cutoff.into()], + ); + let res = sea_orm::ConnectionTrait::execute(db, result).await?; + Ok(res.rows_affected()) } /// 标记为解析失败(仅记录日志,不创建建议记录) @@ -110,3 +182,77 @@ impl SuggestionService { Ok(()) } } + +/// 从数据库字符串解析 SuggestionStatus +fn parse_status(s: &str) -> SuggestionStatus { + match s { + "approved" => SuggestionStatus::Approved, + "rejected" => SuggestionStatus::Rejected, + "executed" => SuggestionStatus::Executed, + "expired" => SuggestionStatus::Expired, + "parse_failed" => SuggestionStatus::ParseFailed, + _ => SuggestionStatus::Pending, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pending_can_transition_to_approved() { + assert!(SuggestionStatus::Pending.can_transition_to(SuggestionStatus::Approved)); + } + + #[test] + fn pending_can_transition_to_rejected() { + assert!(SuggestionStatus::Pending.can_transition_to(SuggestionStatus::Rejected)); + } + + #[test] + fn pending_can_transition_to_expired() { + assert!(SuggestionStatus::Pending.can_transition_to(SuggestionStatus::Expired)); + } + + #[test] + fn approved_can_transition_to_executed() { + assert!(SuggestionStatus::Approved.can_transition_to(SuggestionStatus::Executed)); + } + + #[test] + fn approved_can_transition_to_rejected() { + assert!(SuggestionStatus::Approved.can_transition_to(SuggestionStatus::Rejected)); + } + + #[test] + fn rejected_cannot_transition() { + assert!(!SuggestionStatus::Rejected.can_transition_to(SuggestionStatus::Approved)); + assert!(!SuggestionStatus::Rejected.can_transition_to(SuggestionStatus::Pending)); + assert!(!SuggestionStatus::Rejected.can_transition_to(SuggestionStatus::Executed)); + } + + #[test] + fn executed_cannot_transition() { + assert!(!SuggestionStatus::Executed.can_transition_to(SuggestionStatus::Pending)); + assert!(!SuggestionStatus::Executed.can_transition_to(SuggestionStatus::Approved)); + } + + #[test] + fn expired_cannot_transition() { + assert!(!SuggestionStatus::Expired.can_transition_to(SuggestionStatus::Pending)); + } + + #[test] + fn parse_status_roundtrip() { + for status in [ + SuggestionStatus::Pending, + SuggestionStatus::Approved, + SuggestionStatus::Rejected, + SuggestionStatus::Executed, + SuggestionStatus::Expired, + SuggestionStatus::ParseFailed, + ] { + assert_eq!(parse_status(status.as_str()), status); + } + } +}