chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容: - 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook - 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed - 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段 - 启动: 微信凭据检查块, ensure_ai_workflows() 调用 - 迁移: 新增 m20260613_000170_drop_wechat_users.rs - 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1 - E2E: health-data page, flows/ 目录 保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
This commit is contained in:
21
crates/erp-workflow/Cargo.toml
Normal file
21
crates/erp-workflow/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "erp-workflow"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
erp-core.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
axum = { workspace = true }
|
||||
sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "with-uuid", "with-chrono", "with-json"] }
|
||||
tracing = { workspace = true }
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
utoipa = { workspace = true, features = ["uuid", "chrono"] }
|
||||
async-trait.workspace = true
|
||||
validator.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
252
crates/erp-workflow/src/dto.rs
Normal file
252
crates/erp-workflow/src/dto.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
// --- 流程图节点/边定义 ---
|
||||
|
||||
/// BPMN 节点类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
|
||||
pub enum NodeType {
|
||||
StartEvent,
|
||||
EndEvent,
|
||||
UserTask,
|
||||
ServiceTask,
|
||||
ExclusiveGateway,
|
||||
ParallelGateway,
|
||||
}
|
||||
|
||||
/// 流程图节点定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct NodeDef {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: NodeType,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assignee_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_groups: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_type: Option<String>,
|
||||
/// 服务任务 HTTP 调用配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_config: Option<ServiceTaskConfig>,
|
||||
/// 前端渲染位置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<NodePosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct NodePosition {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// ServiceTask HTTP 调用配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
|
||||
pub struct ServiceTaskConfig {
|
||||
/// 请求 URL(仅允许 http/https 协议,禁止内网地址)
|
||||
#[validate(length(min = 1, max = 2048), custom(function = "validate_service_url"))]
|
||||
pub url: String,
|
||||
/// HTTP 方法(GET / POST),默认 GET
|
||||
#[serde(default = "default_method")]
|
||||
#[validate(custom(function = "validate_http_method"))]
|
||||
pub method: String,
|
||||
/// POST body 模板(支持从流程变量替换 ${var_name})
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_method() -> String {
|
||||
"GET".to_string()
|
||||
}
|
||||
|
||||
fn validate_service_url(value: &str) -> Result<(), validator::ValidationError> {
|
||||
if !value.starts_with("https://") && !value.starts_with("http://") {
|
||||
return Err(validator::ValidationError::new("invalid_url_scheme"));
|
||||
}
|
||||
if value.contains("127.0.0.1") || value.contains("localhost") || value.contains("0.0.0.0") {
|
||||
return Err(validator::ValidationError::new("ssrf_blocked"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_http_method(value: &str) -> Result<(), validator::ValidationError> {
|
||||
match value {
|
||||
"GET" | "POST" => Ok(()),
|
||||
_ => Err(validator::ValidationError::new("invalid_http_method")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 流程图连线定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EdgeDef {
|
||||
pub id: String,
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
/// 条件表达式(排他网关分支)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub condition: Option<String>,
|
||||
/// 前端渲染标签
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// 完整流程图
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct FlowDiagram {
|
||||
pub nodes: Vec<NodeDef>,
|
||||
pub edges: Vec<EdgeDef>,
|
||||
}
|
||||
|
||||
// --- 流程定义 DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProcessDefinitionResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
pub version: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub nodes: serde_json::Value,
|
||||
pub edges: serde_json::Value,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub lock_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateProcessDefinitionReq {
|
||||
#[validate(length(min = 1, max = 200, message = "流程名称不能为空"))]
|
||||
pub name: String,
|
||||
#[validate(length(min = 1, max = 100, message = "流程编码不能为空"))]
|
||||
pub key: String,
|
||||
pub category: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub nodes: Vec<NodeDef>,
|
||||
pub edges: Vec<EdgeDef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateProcessDefinitionReq {
|
||||
#[validate(length(max = 200, message = "流程名称过长"))]
|
||||
pub name: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub nodes: Option<Vec<NodeDef>>,
|
||||
pub edges: Option<Vec<EdgeDef>>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// --- 流程实例 DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProcessInstanceResp {
|
||||
pub id: Uuid,
|
||||
pub definition_id: Uuid,
|
||||
pub definition_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub business_key: Option<String>,
|
||||
pub status: String,
|
||||
pub started_by: Uuid,
|
||||
pub started_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 当前活跃的 token 位置
|
||||
pub active_tokens: Vec<TokenResp>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct StartInstanceReq {
|
||||
pub definition_id: Uuid,
|
||||
pub business_key: Option<String>,
|
||||
/// 初始流程变量
|
||||
pub variables: Option<Vec<SetVariableReq>>,
|
||||
}
|
||||
|
||||
// --- Token DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TokenResp {
|
||||
pub id: Uuid,
|
||||
pub node_id: String,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- 任务 DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TaskResp {
|
||||
pub id: Uuid,
|
||||
pub instance_id: Uuid,
|
||||
pub token_id: Uuid,
|
||||
pub node_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assignee_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_groups: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outcome: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_data: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub due_date: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 流程定义名称(用于列表展示)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub definition_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub business_key: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CompleteTaskReq {
|
||||
#[validate(length(min = 1, max = 50, message = "审批结果不能为空"))]
|
||||
pub outcome: String,
|
||||
pub form_data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct DelegateTaskReq {
|
||||
pub delegate_to: Uuid,
|
||||
}
|
||||
|
||||
// --- 流程变量 DTOs ---
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProcessVariableResp {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub var_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_string: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_number: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_boolean: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_date: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
|
||||
pub struct SetVariableReq {
|
||||
#[validate(length(min = 1, max = 100, message = "变量名不能为空"))]
|
||||
pub name: String,
|
||||
pub var_type: Option<String>,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
704
crates/erp-workflow/src/engine/executor.rs
Normal file
704
crates/erp-workflow/src/engine/executor.rs
Normal file
@@ -0,0 +1,704 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::NodeType;
|
||||
use crate::engine::expression::ExpressionEvaluator;
|
||||
use crate::engine::model::FlowGraph;
|
||||
use crate::entity::{process_instance, task, token};
|
||||
use crate::error::{WorkflowError, WorkflowResult};
|
||||
|
||||
/// Token 驱动的流程执行引擎。
|
||||
///
|
||||
/// 核心职责:
|
||||
/// - 在流程启动时,于 StartEvent 创建第一个 token
|
||||
/// - 在任务完成时推进 token 到下一个节点
|
||||
/// - 处理网关分支/汇合逻辑
|
||||
/// - 在 EndEvent 完成实例
|
||||
pub struct FlowExecutor;
|
||||
|
||||
impl FlowExecutor {
|
||||
/// 启动流程:在 StartEvent 的后继节点创建 token。
|
||||
///
|
||||
/// 返回创建的 token ID 列表。
|
||||
pub async fn start(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
graph: &FlowGraph,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<Vec<Uuid>> {
|
||||
let start_id = graph
|
||||
.start_node_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| WorkflowError::InvalidDiagram("流程图没有开始事件".to_string()))?;
|
||||
|
||||
// 获取 StartEvent 的出边,推进到后继节点
|
||||
let outgoing = graph.get_outgoing_edges(start_id);
|
||||
if outgoing.is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"开始事件没有出边".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// StartEvent 只有一条出边
|
||||
let first_edge = &outgoing[0];
|
||||
let target_node_id = &first_edge.target;
|
||||
|
||||
Self::create_token_at_node(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
target_node_id,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 推进 token:消费当前 token,在下一节点创建新 token。
|
||||
///
|
||||
/// 返回新创建的 token ID 列表。
|
||||
pub async fn advance(
|
||||
token_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
graph: &FlowGraph,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<Vec<Uuid>> {
|
||||
// 读取当前 token
|
||||
let current_token = token::Entity::find_by_id(token_id)
|
||||
.one(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("Token 不存在: {token_id}")))?;
|
||||
|
||||
if current_token.status != "active" {
|
||||
return Err(WorkflowError::InvalidState(format!(
|
||||
"Token 状态不是 active: {}",
|
||||
current_token.status
|
||||
)));
|
||||
}
|
||||
|
||||
let node_id = current_token.node_id.clone();
|
||||
|
||||
// 消费当前 token
|
||||
let mut active: token::ActiveModel = current_token.into();
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
active.status = Set("consumed".to_string());
|
||||
active.consumed_at = Set(Some(Utc::now()));
|
||||
active
|
||||
.update(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 获取当前节点的出边
|
||||
let outgoing = graph.get_outgoing_edges(&node_id);
|
||||
let current_node = graph
|
||||
.nodes
|
||||
.get(&node_id)
|
||||
.ok_or_else(|| WorkflowError::InvalidDiagram(format!("节点不存在: {node_id}")))?;
|
||||
|
||||
match current_node.node_type {
|
||||
NodeType::ExclusiveGateway => {
|
||||
// 排他网关:求值条件,选择一条分支
|
||||
Self::advance_exclusive_gateway(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&outgoing,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
NodeType::ParallelGateway => {
|
||||
// 并行网关:为每条出边创建 token
|
||||
Self::advance_parallel_gateway(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&outgoing,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
// 普通节点:沿出边前进
|
||||
if outgoing.is_empty() {
|
||||
// 没有出边(理论上只有 EndEvent 会到这里)
|
||||
Ok(vec![])
|
||||
} else {
|
||||
let mut new_tokens = Vec::new();
|
||||
for edge in &outgoing {
|
||||
let tokens = Self::create_token_at_node(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&edge.target,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await?;
|
||||
new_tokens.extend(tokens);
|
||||
}
|
||||
Ok(new_tokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 排他网关分支:求值条件,选择第一个满足条件的分支。
|
||||
async fn advance_exclusive_gateway(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
outgoing: &[&crate::engine::model::FlowEdge],
|
||||
graph: &FlowGraph,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<Vec<Uuid>> {
|
||||
let mut default_target: Option<&str> = None;
|
||||
let mut matched_target: Option<&str> = None;
|
||||
|
||||
for edge in outgoing {
|
||||
if let Some(condition) = &edge.condition {
|
||||
match ExpressionEvaluator::eval(condition, variables) {
|
||||
Ok(true) => {
|
||||
matched_target = Some(&edge.target);
|
||||
break;
|
||||
}
|
||||
Ok(false) => continue,
|
||||
Err(_) => continue, // 条件求值失败,跳过
|
||||
}
|
||||
} else {
|
||||
// 无条件的边作为默认分支
|
||||
default_target = Some(&edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
let target = matched_target.or(default_target).ok_or_else(|| {
|
||||
WorkflowError::ExpressionError("排他网关没有匹配的条件分支".to_string())
|
||||
})?;
|
||||
|
||||
Self::create_token_at_node(instance_id, tenant_id, target, graph, variables, txn).await
|
||||
}
|
||||
|
||||
/// 并行网关分支:为每条出边创建 token。
|
||||
async fn advance_parallel_gateway(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
outgoing: &[&crate::engine::model::FlowEdge],
|
||||
graph: &FlowGraph,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<Vec<Uuid>> {
|
||||
let mut new_tokens = Vec::new();
|
||||
for edge in outgoing {
|
||||
let tokens = Self::create_token_at_node(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&edge.target,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await?;
|
||||
new_tokens.extend(tokens);
|
||||
}
|
||||
Ok(new_tokens)
|
||||
}
|
||||
|
||||
/// 在指定节点创建 token,并根据节点类型执行相应逻辑。
|
||||
fn create_token_at_node<'a>(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
node_id: &'a str,
|
||||
graph: &'a FlowGraph,
|
||||
variables: &'a HashMap<String, serde_json::Value>,
|
||||
txn: &'a impl ConnectionTrait,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = WorkflowResult<Vec<Uuid>>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let node = graph
|
||||
.nodes
|
||||
.get(node_id)
|
||||
.ok_or_else(|| WorkflowError::InvalidDiagram(format!("节点不存在: {node_id}")))?;
|
||||
|
||||
match node.node_type {
|
||||
NodeType::EndEvent => {
|
||||
// 到达 EndEvent,不创建新 token
|
||||
// 检查实例是否所有 token 都完成
|
||||
Self::check_instance_completion(instance_id, tenant_id, txn).await?;
|
||||
Ok(vec![])
|
||||
}
|
||||
NodeType::ParallelGateway if Self::is_join_gateway(node_id, graph) => {
|
||||
// 并行网关汇合:等待所有入边 token 到达
|
||||
Self::handle_join_gateway(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
node_id,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
NodeType::ServiceTask => {
|
||||
// ServiceTask 自动执行 HTTP 调用
|
||||
let now = Utc::now();
|
||||
let system_user = uuid::Uuid::nil();
|
||||
let auto_token_id = Uuid::now_v7();
|
||||
|
||||
let token_model = token::ActiveModel {
|
||||
id: Set(auto_token_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
status: Set("consumed".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user),
|
||||
updated_by: Set(system_user),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
consumed_at: Set(Some(now)),
|
||||
};
|
||||
token_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 执行 HTTP 调用(如果配置了 service_config)
|
||||
let var_name = format!("service_task_{node_id}_result");
|
||||
let result_value = Self::execute_service_task(node, variables).await;
|
||||
// 将结果存储为流程变量
|
||||
Self::set_process_variable(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&var_name,
|
||||
&result_value,
|
||||
txn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 沿出边继续推进
|
||||
let outgoing = graph.get_outgoing_edges(node_id);
|
||||
let mut new_tokens = Vec::new();
|
||||
for edge in &outgoing {
|
||||
let tokens = Self::create_token_at_node(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&edge.target,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await?;
|
||||
new_tokens.extend(tokens);
|
||||
}
|
||||
Ok(new_tokens)
|
||||
}
|
||||
_ => {
|
||||
// UserTask / 网关(分支)等:创建活跃 token
|
||||
let new_token_id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let system_user = uuid::Uuid::nil();
|
||||
|
||||
let token_model = token::ActiveModel {
|
||||
id: Set(new_token_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
status: Set("active".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user),
|
||||
updated_by: Set(system_user),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
consumed_at: Set(None),
|
||||
};
|
||||
token_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// UserTask: 同时创建 task 记录
|
||||
if node.node_type == NodeType::UserTask {
|
||||
let task_model = task::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
token_id: Set(new_token_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
node_name: Set(Some(node.name.clone())),
|
||||
assignee_id: Set(node.assignee_id),
|
||||
candidate_groups: Set(node
|
||||
.candidate_groups
|
||||
.as_ref()
|
||||
.map(|g| serde_json::to_value(g).unwrap_or_default())),
|
||||
status: Set("pending".to_string()),
|
||||
outcome: Set(None),
|
||||
form_data: Set(None),
|
||||
due_date: Set(None),
|
||||
completed_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(Uuid::nil()),
|
||||
updated_by: Set(Uuid::nil()),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
task_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(vec![new_token_id])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 判断并行网关是否是汇合模式(入边数 > 出边数,或者入边数 > 1)。
|
||||
fn is_join_gateway(node_id: &str, graph: &FlowGraph) -> bool {
|
||||
let incoming = graph.get_incoming_edges(node_id);
|
||||
incoming.len() > 1
|
||||
}
|
||||
|
||||
/// 处理并行网关汇合逻辑。
|
||||
///
|
||||
/// 当所有入边的源节点都有已消费的 token 时,创建新 token 推进到后继。
|
||||
async fn handle_join_gateway(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
node_id: &str,
|
||||
graph: &FlowGraph,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<Vec<Uuid>> {
|
||||
let incoming = graph.get_incoming_edges(node_id);
|
||||
|
||||
// 检查所有入边的源节点是否都有已消费/已完成的 token
|
||||
for edge in &incoming {
|
||||
let has_consumed = token::Entity::find()
|
||||
.filter(token::Column::InstanceId.eq(instance_id))
|
||||
.filter(token::Column::NodeId.eq(&edge.source))
|
||||
.filter(token::Column::Status.is_in(["consumed", "active"]))
|
||||
.one(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
if has_consumed.is_none() {
|
||||
// 还有分支没有到达,等待
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// 检查是否还有活跃的 token(来自其他分支)
|
||||
let has_active = token::Entity::find()
|
||||
.filter(token::Column::InstanceId.eq(instance_id))
|
||||
.filter(token::Column::NodeId.eq(&edge.source))
|
||||
.filter(token::Column::Status.eq("active"))
|
||||
.one(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
if has_active.is_some() {
|
||||
// 还有分支在执行中,等待
|
||||
return Ok(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
// 所有分支都完成了,先将 consumed tokens 标记为 completed 防止并发重复触发
|
||||
for edge in &incoming {
|
||||
let consumed_tokens = token::Entity::find()
|
||||
.filter(token::Column::InstanceId.eq(instance_id))
|
||||
.filter(token::Column::NodeId.eq(&edge.source))
|
||||
.filter(token::Column::Status.eq("consumed"))
|
||||
.all(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
for t in consumed_tokens {
|
||||
let ver = t.version;
|
||||
let mut active: token::ActiveModel = t.into();
|
||||
active.status = Set("completed".to_string());
|
||||
active.version = Set(ver + 1);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active
|
||||
.update(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 沿出边继续创建新 token
|
||||
let outgoing = graph.get_outgoing_edges(node_id);
|
||||
let mut new_tokens = Vec::new();
|
||||
for edge in &outgoing {
|
||||
let tokens = Self::create_token_at_node(
|
||||
instance_id,
|
||||
tenant_id,
|
||||
&edge.target,
|
||||
graph,
|
||||
variables,
|
||||
txn,
|
||||
)
|
||||
.await?;
|
||||
new_tokens.extend(tokens);
|
||||
}
|
||||
Ok(new_tokens)
|
||||
}
|
||||
|
||||
/// 执行 ServiceTask HTTP 调用。
|
||||
///
|
||||
/// 根据 `service_config` 中的 url/method/body 发起 HTTP 请求。
|
||||
/// 如果没有配置 `service_config` 或调用失败,返回错误信息 JSON 而不是阻塞流程。
|
||||
async fn execute_service_task(
|
||||
node: &crate::engine::model::FlowNode,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
) -> serde_json::Value {
|
||||
let config = match &node.service_config {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
node_id = &node.id,
|
||||
node_name = %node.name,
|
||||
"ServiceTask 没有 service_config 配置,跳过 HTTP 调用"
|
||||
);
|
||||
return serde_json::json!({
|
||||
"status": "skipped",
|
||||
"reason": "未配置 service_config"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let method = config.method.to_uppercase();
|
||||
let url = &config.url;
|
||||
|
||||
tracing::info!(
|
||||
node_id = &node.id,
|
||||
node_name = %node.name,
|
||||
method = %method,
|
||||
url = %url,
|
||||
"ServiceTask 开始 HTTP 调用"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let result = match method.as_str() {
|
||||
"POST" => {
|
||||
let body = config.body.as_ref().map(|b| {
|
||||
// 简单变量替换:${var_name} → variables 中的值
|
||||
let mut body_str = b.to_string();
|
||||
for (key, val) in variables {
|
||||
let placeholder = format!("${{{key}}}");
|
||||
body_str = body_str.replace(&placeholder, &val.to_string());
|
||||
}
|
||||
body_str
|
||||
});
|
||||
client.post(url).body(body.unwrap_or_default()).send().await
|
||||
}
|
||||
_ => {
|
||||
// 默认 GET
|
||||
client.get(url).send().await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::info!(
|
||||
node_id = &node.id,
|
||||
status = status,
|
||||
"ServiceTask HTTP 调用完成"
|
||||
);
|
||||
serde_json::json!({
|
||||
"status": "success",
|
||||
"http_status": status,
|
||||
"body": body,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
node_id = &node.id,
|
||||
error = %e,
|
||||
"ServiceTask HTTP 调用失败(流程继续推进)"
|
||||
);
|
||||
serde_json::json!({
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将流程变量写入 process_variables 表。
|
||||
async fn set_process_variable(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<()> {
|
||||
use crate::entity::process_variable;
|
||||
|
||||
let now = Utc::now();
|
||||
let system_user = Uuid::nil();
|
||||
let var_model = process_variable::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
name: Set(name.to_string()),
|
||||
var_type: Set("json".to_string()),
|
||||
value_string: Set(Some(value.to_string())),
|
||||
value_number: Set(None),
|
||||
value_boolean: Set(None),
|
||||
value_date: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user),
|
||||
updated_by: Set(system_user),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
var_model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查实例是否所有 token 都已完成,如果是则完成实例。
|
||||
async fn check_instance_completion(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<()> {
|
||||
let active_count = token::Entity::find()
|
||||
.filter(token::Column::InstanceId.eq(instance_id))
|
||||
.filter(token::Column::Status.eq("active"))
|
||||
.count(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
if active_count == 0 {
|
||||
// 所有 token 都完成,标记实例完成
|
||||
let instance = process_instance::Entity::find_by_id(instance_id)
|
||||
.one(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {instance_id}")))?;
|
||||
|
||||
let mut active: process_instance::ActiveModel = instance.into();
|
||||
active.version = Set(active.version.take().unwrap_or(0) + 1);
|
||||
active.status = Set("completed".to_string());
|
||||
active.completed_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active
|
||||
.update(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 写入完成事件到 outbox,由 relay 广播
|
||||
let now = Utc::now();
|
||||
let outbox_event = erp_core::entity::domain_event::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
event_type: Set("process_instance.completed".to_string()),
|
||||
payload: Set(Some(serde_json::json!({ "instance_id": instance_id }))),
|
||||
correlation_id: Set(Some(Uuid::now_v7())),
|
||||
status: Set("pending".to_string()),
|
||||
attempts: Set(0),
|
||||
last_error: Set(None),
|
||||
created_at: Set(now),
|
||||
published_at: Set(None),
|
||||
};
|
||||
outbox_event
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{EdgeDef, NodeDef, NodeType};
|
||||
|
||||
fn make_node(id: &str, node_type: NodeType) -> NodeDef {
|
||||
NodeDef {
|
||||
id: id.to_string(),
|
||||
node_type,
|
||||
name: id.to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef {
|
||||
EdgeDef {
|
||||
id: id.to_string(),
|
||||
source: source.to_string(),
|
||||
target: target.to_string(),
|
||||
condition: None,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_join_gateway_with_multiple_incoming() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("a", NodeType::UserTask),
|
||||
make_node("b", NodeType::ServiceTask),
|
||||
make_node("join", NodeType::ParallelGateway),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "a"),
|
||||
make_edge("e2", "start", "b"),
|
||||
make_edge("e3", "a", "join"),
|
||||
make_edge("e4", "b", "join"),
|
||||
make_edge("e5", "join", "end"),
|
||||
];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
assert!(FlowExecutor::is_join_gateway("join", &graph));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_join_gateway_single_incoming() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("fork", NodeType::ParallelGateway),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "fork"),
|
||||
make_edge("e2", "fork", "end"),
|
||||
];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
assert!(!FlowExecutor::is_join_gateway("fork", &graph));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_join_gateway_for_nonexistent_node() {
|
||||
let graph = FlowGraph::build(&[], &[]);
|
||||
assert!(!FlowExecutor::is_join_gateway("nonexistent", &graph));
|
||||
}
|
||||
}
|
||||
431
crates/erp-workflow/src/engine/expression.rs
Normal file
431
crates/erp-workflow/src/engine/expression.rs
Normal file
@@ -0,0 +1,431 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::{WorkflowError, WorkflowResult};
|
||||
|
||||
/// 简单表达式求值器。
|
||||
///
|
||||
/// 支持的比较运算符:>, >=, <, <=, ==, !=
|
||||
/// 支持 && 和 || 逻辑运算。
|
||||
/// 操作数可以是变量名(从 variables map 查找)或字面量(数字、字符串)。
|
||||
///
|
||||
/// 示例:
|
||||
/// - `amount > 1000`
|
||||
/// - `status == "approved"`
|
||||
/// - `score >= 60 && attendance > 80`
|
||||
pub struct ExpressionEvaluator;
|
||||
|
||||
impl ExpressionEvaluator {
|
||||
/// 求值单个条件表达式。
|
||||
///
|
||||
/// 表达式格式: `{left} {op} {right}` 或复合表达式 `{expr1} && {expr2}`
|
||||
pub fn eval(
|
||||
expr: &str,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
) -> WorkflowResult<bool> {
|
||||
let expr = expr.trim();
|
||||
|
||||
// 处理逻辑 OR
|
||||
if let Some(idx) = Self::find_logical_op(expr, "||") {
|
||||
let left = &expr[..idx];
|
||||
let right = &expr[idx + 2..];
|
||||
return Ok(Self::eval(left, variables)? || Self::eval(right, variables)?);
|
||||
}
|
||||
|
||||
// 处理逻辑 AND
|
||||
if let Some(idx) = Self::find_logical_op(expr, "&&") {
|
||||
let left = &expr[..idx];
|
||||
let right = &expr[idx + 2..];
|
||||
return Ok(Self::eval(left, variables)? && Self::eval(right, variables)?);
|
||||
}
|
||||
|
||||
// 处理单个比较表达式
|
||||
Self::eval_comparison(expr, variables)
|
||||
}
|
||||
|
||||
/// 查找逻辑运算符位置,跳过引号内的内容。
|
||||
fn find_logical_op(expr: &str, op: &str) -> Option<usize> {
|
||||
let mut in_string = false;
|
||||
let mut string_char = ' ';
|
||||
let chars: Vec<char> = expr.chars().collect();
|
||||
let op_chars: Vec<char> = op.chars().collect();
|
||||
let op_len = op_chars.len();
|
||||
|
||||
for i in 0..chars.len().saturating_sub(op_len - 1) {
|
||||
let c = chars[i];
|
||||
|
||||
if !in_string && (c == '"' || c == '\'') {
|
||||
in_string = true;
|
||||
string_char = c;
|
||||
continue;
|
||||
}
|
||||
if in_string && c == string_char {
|
||||
in_string = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_string {
|
||||
continue;
|
||||
}
|
||||
|
||||
if chars[i..].starts_with(&op_chars) {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 求值单个比较表达式。
|
||||
fn eval_comparison(
|
||||
expr: &str,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
) -> WorkflowResult<bool> {
|
||||
let operators = [">=", "<=", "!=", "==", ">", "<"];
|
||||
|
||||
for op in &operators {
|
||||
if let Some(idx) = Self::find_comparison_op(expr, op) {
|
||||
let left = expr[..idx].trim();
|
||||
let right = expr[idx + op.len()..].trim();
|
||||
|
||||
let left_val = Self::resolve_value(left, variables)?;
|
||||
let right_val = Self::resolve_value(right, variables)?;
|
||||
|
||||
return Self::compare(&left_val, &right_val, op);
|
||||
}
|
||||
}
|
||||
|
||||
Err(WorkflowError::ExpressionError(format!(
|
||||
"无法解析表达式: '{}'",
|
||||
expr
|
||||
)))
|
||||
}
|
||||
|
||||
/// 查找比较运算符位置,跳过引号内的内容。
|
||||
fn find_comparison_op(expr: &str, op: &str) -> Option<usize> {
|
||||
let mut in_string = false;
|
||||
let mut string_char = ' ';
|
||||
let bytes = expr.as_bytes();
|
||||
let op_bytes = op.as_bytes();
|
||||
let op_len = op_bytes.len();
|
||||
|
||||
for i in 0..bytes.len().saturating_sub(op_len - 1) {
|
||||
let c = bytes[i] as char;
|
||||
|
||||
if !in_string && (c == '"' || c == '\'') {
|
||||
in_string = true;
|
||||
string_char = c;
|
||||
continue;
|
||||
}
|
||||
if in_string && c == string_char {
|
||||
in_string = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_string {
|
||||
continue;
|
||||
}
|
||||
|
||||
if bytes[i..].starts_with(op_bytes) {
|
||||
// 确保不是被嵌在其他运算符里(如 != 中的 =)
|
||||
// 对于 > 和 < 检查后面不是 = 或 >
|
||||
if op == ">" || op == "<" {
|
||||
if i + op_len < bytes.len() {
|
||||
let next = bytes[i + op_len] as char;
|
||||
if next == '=' || (op == ">" && next == '>') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 也检查前面不是 ! 或 = 或 < 或 >
|
||||
if i > 0 {
|
||||
let prev = bytes[i - 1] as char;
|
||||
if prev == '!' || prev == '=' || prev == '<' || prev == '>' {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 对于 ==, >=, <=, != 确保前面不是 ! 或 = (避免匹配到 == 中的第二个 =)
|
||||
// 这已经通过从长到短匹配处理了
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析值:字符串字面量、数字字面量或变量引用。
|
||||
fn resolve_value(
|
||||
token: &str,
|
||||
variables: &HashMap<String, serde_json::Value>,
|
||||
) -> WorkflowResult<serde_json::Value> {
|
||||
let token = token.trim();
|
||||
|
||||
// 字符串字面量
|
||||
if (token.starts_with('"') && token.ends_with('"'))
|
||||
|| (token.starts_with('\'') && token.ends_with('\''))
|
||||
{
|
||||
return Ok(serde_json::Value::String(
|
||||
token[1..token.len() - 1].to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 数字字面量
|
||||
if let Ok(n) = token.parse::<i64>() {
|
||||
return Ok(serde_json::Value::Number(n.into()));
|
||||
}
|
||||
if let Ok(f) = token.parse::<f64>()
|
||||
&& let Some(n) = serde_json::Number::from_f64(f)
|
||||
{
|
||||
return Ok(serde_json::Value::Number(n));
|
||||
}
|
||||
|
||||
// 布尔字面量
|
||||
if token == "true" {
|
||||
return Ok(serde_json::Value::Bool(true));
|
||||
}
|
||||
if token == "false" {
|
||||
return Ok(serde_json::Value::Bool(false));
|
||||
}
|
||||
|
||||
// 变量引用
|
||||
if let Some(val) = variables.get(token) {
|
||||
return Ok(val.clone());
|
||||
}
|
||||
|
||||
Err(WorkflowError::ExpressionError(format!(
|
||||
"未知的变量或值: '{}'",
|
||||
token
|
||||
)))
|
||||
}
|
||||
|
||||
/// 比较两个 JSON 值。
|
||||
fn compare(
|
||||
left: &serde_json::Value,
|
||||
right: &serde_json::Value,
|
||||
op: &str,
|
||||
) -> WorkflowResult<bool> {
|
||||
match op {
|
||||
"==" => Ok(Self::values_equal(left, right)),
|
||||
"!=" => Ok(!Self::values_equal(left, right)),
|
||||
">" => Ok(Self::values_compare(left, right)? == std::cmp::Ordering::Greater),
|
||||
">=" => Ok(Self::values_compare(left, right)? != std::cmp::Ordering::Less),
|
||||
"<" => Ok(Self::values_compare(left, right)? == std::cmp::Ordering::Less),
|
||||
"<=" => Ok(Self::values_compare(left, right)? != std::cmp::Ordering::Greater),
|
||||
_ => Err(WorkflowError::ExpressionError(format!(
|
||||
"不支持的比较运算符: '{}'",
|
||||
op
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn values_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool {
|
||||
// 数值比较:允许整数和浮点数互比
|
||||
if left.is_number() && right.is_number() {
|
||||
return left.as_f64() == right.as_f64();
|
||||
}
|
||||
left == right
|
||||
}
|
||||
|
||||
fn values_compare(
|
||||
left: &serde_json::Value,
|
||||
right: &serde_json::Value,
|
||||
) -> WorkflowResult<std::cmp::Ordering> {
|
||||
if left.is_number() && right.is_number() {
|
||||
let l = left.as_f64().unwrap_or(0.0);
|
||||
let r = right.as_f64().unwrap_or(0.0);
|
||||
return Ok(l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal));
|
||||
}
|
||||
|
||||
if let (Some(l), Some(r)) = (left.as_str(), right.as_str()) {
|
||||
return Ok(l.cmp(r));
|
||||
}
|
||||
|
||||
Err(WorkflowError::ExpressionError(format!(
|
||||
"无法比较 {:?} 和 {:?}",
|
||||
left, right
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn make_vars() -> HashMap<String, serde_json::Value> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert("amount".to_string(), json!(1500));
|
||||
m.insert("status".to_string(), json!("approved"));
|
||||
m.insert("score".to_string(), json!(85));
|
||||
m.insert("name".to_string(), json!("Alice"));
|
||||
m.insert("active".to_string(), json!(true));
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_greater_than() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount > 1000", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount > 2000", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_less_than() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount < 2000", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount < 1000", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_equals() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount == 1500", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount == 1000", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_equals() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("status == \"approved\"", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("status == \"rejected\"", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_not_equals() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("status != \"rejected\"", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_greater_or_equal() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount >= 1500", &vars).unwrap());
|
||||
assert!(ExpressionEvaluator::eval("amount >= 1000", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount >= 2000", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logical_and() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount > 1000 && score > 80", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount > 2000 && score > 80", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logical_or() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount > 2000 || score > 80", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount > 2000 || score > 90", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_variable() {
|
||||
let vars = make_vars();
|
||||
let result = ExpressionEvaluator::eval("unknown > 0", &vars);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_expression() {
|
||||
let vars = make_vars();
|
||||
let result = ExpressionEvaluator::eval("justavariable", &vars);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---- 扩展边界测试 ----
|
||||
|
||||
#[test]
|
||||
fn test_less_or_equal() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount <= 1500", &vars).unwrap());
|
||||
assert!(ExpressionEvaluator::eval("amount <= 2000", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount <= 1000", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_boolean_equality() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("active == true", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("active == false", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_literal_with_single_quotes() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("status == 'approved'", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_float_comparison() {
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("temperature".to_string(), json!(36.5));
|
||||
assert!(ExpressionEvaluator::eval("temperature >= 36.0", &vars).unwrap());
|
||||
assert!(ExpressionEvaluator::eval("temperature < 37.0", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("temperature > 37.5", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integer_float_cross_comparison() {
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("val".to_string(), json!(10));
|
||||
assert!(ExpressionEvaluator::eval("val == 10.0", &vars).unwrap());
|
||||
assert!(ExpressionEvaluator::eval("val >= 9.5", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_comparison_lexicographic() {
|
||||
let vars = make_vars();
|
||||
// name = "Alice"
|
||||
assert!(!ExpressionEvaluator::eval("name > \"Alice\"", &vars).unwrap());
|
||||
assert!(ExpressionEvaluator::eval("name >= \"Alice\"", &vars).unwrap());
|
||||
assert!(ExpressionEvaluator::eval("name < \"Bob\"", &vars).unwrap());
|
||||
assert!(ExpressionEvaluator::eval("name == \"Alice\"", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compound_and_or() {
|
||||
let vars = make_vars();
|
||||
// amount > 1000 (true) && score > 80 (true) || amount > 3000 (false)
|
||||
// OR 先绑定,所以是 amount>1000 && score>80 (true) || amount>3000 (false)
|
||||
// 注意:当前实现从左到右先匹配 ||,所以实际解析为:
|
||||
// amount > 1000 && score > 80 || amount > 3000
|
||||
// find_logical_op 先找 || → split at ||
|
||||
// left = "amount > 1000 && score > 80", right = "amount > 3000"
|
||||
// left = true, right = false → true || false = true
|
||||
assert!(
|
||||
ExpressionEvaluator::eval("amount > 1000 && score > 80 || amount > 3000", &vars)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compound_all_false() {
|
||||
let vars = make_vars();
|
||||
assert!(!ExpressionEvaluator::eval("amount > 2000 && score > 90", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_number_not_equals() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval("amount != 1000", &vars).unwrap());
|
||||
assert!(!ExpressionEvaluator::eval("amount != 1500", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expression_with_extra_whitespace() {
|
||||
let vars = make_vars();
|
||||
assert!(ExpressionEvaluator::eval(" amount > 1000 ", &vars).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unresolvable_variable_returns_error() {
|
||||
let vars = HashMap::new();
|
||||
let result = ExpressionEvaluator::eval("missing_var > 10", &vars);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("未知的变量"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_expression_returns_error() {
|
||||
let vars = HashMap::new();
|
||||
let result = ExpressionEvaluator::eval("", &vars);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
5
crates/erp-workflow/src/engine/mod.rs
Normal file
5
crates/erp-workflow/src/engine/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod executor;
|
||||
pub mod expression;
|
||||
pub mod model;
|
||||
pub mod parser;
|
||||
pub mod timeout;
|
||||
385
crates/erp-workflow/src/engine/model.rs
Normal file
385
crates/erp-workflow/src/engine/model.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::dto::{EdgeDef, NodeDef, NodeType};
|
||||
|
||||
/// 内存中的流程图模型,用于执行引擎。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowGraph {
|
||||
/// node_id → FlowNode
|
||||
pub nodes: HashMap<String, FlowNode>,
|
||||
/// edge_id → FlowEdge
|
||||
pub edges: HashMap<String, FlowEdge>,
|
||||
/// node_id → 从该节点出发的边列表
|
||||
pub outgoing: HashMap<String, Vec<String>>,
|
||||
/// node_id → 到达该节点的边列表
|
||||
pub incoming: HashMap<String, Vec<String>>,
|
||||
/// StartEvent 的 node_id
|
||||
pub start_node_id: Option<String>,
|
||||
/// 所有 EndEvent 的 node_id
|
||||
pub end_node_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 内存中的节点模型。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowNode {
|
||||
pub id: String,
|
||||
pub node_type: NodeType,
|
||||
pub name: String,
|
||||
pub assignee_id: Option<uuid::Uuid>,
|
||||
pub candidate_groups: Option<Vec<String>>,
|
||||
pub service_type: Option<String>,
|
||||
pub service_config: Option<crate::dto::ServiceTaskConfig>,
|
||||
}
|
||||
|
||||
/// 内存中的边模型。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowEdge {
|
||||
pub id: String,
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub condition: Option<String>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
impl FlowGraph {
|
||||
/// 从 DTO 节点和边列表构建 FlowGraph。
|
||||
pub fn build(nodes: &[NodeDef], edges: &[EdgeDef]) -> Self {
|
||||
let mut graph = FlowGraph {
|
||||
nodes: HashMap::new(),
|
||||
edges: HashMap::new(),
|
||||
outgoing: HashMap::new(),
|
||||
incoming: HashMap::new(),
|
||||
start_node_id: None,
|
||||
end_node_ids: Vec::new(),
|
||||
};
|
||||
|
||||
for n in nodes {
|
||||
let flow_node = FlowNode {
|
||||
id: n.id.clone(),
|
||||
node_type: n.node_type.clone(),
|
||||
name: n.name.clone(),
|
||||
assignee_id: n.assignee_id,
|
||||
candidate_groups: n.candidate_groups.clone(),
|
||||
service_type: n.service_type.clone(),
|
||||
service_config: n.service_config.clone(),
|
||||
};
|
||||
|
||||
if n.node_type == NodeType::StartEvent {
|
||||
graph.start_node_id = Some(n.id.clone());
|
||||
}
|
||||
if n.node_type == NodeType::EndEvent {
|
||||
graph.end_node_ids.push(n.id.clone());
|
||||
}
|
||||
|
||||
graph.nodes.insert(n.id.clone(), flow_node);
|
||||
graph.outgoing.insert(n.id.clone(), Vec::new());
|
||||
graph.incoming.insert(n.id.clone(), Vec::new());
|
||||
}
|
||||
|
||||
for e in edges {
|
||||
graph.edges.insert(
|
||||
e.id.clone(),
|
||||
FlowEdge {
|
||||
id: e.id.clone(),
|
||||
source: e.source.clone(),
|
||||
target: e.target.clone(),
|
||||
condition: e.condition.clone(),
|
||||
label: e.label.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(out) = graph.outgoing.get_mut(&e.source) {
|
||||
out.push(e.id.clone());
|
||||
}
|
||||
if let Some(inc) = graph.incoming.get_mut(&e.target) {
|
||||
inc.push(e.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
/// 获取节点的出边。
|
||||
pub fn get_outgoing_edges(&self, node_id: &str) -> Vec<&FlowEdge> {
|
||||
self.outgoing
|
||||
.get(node_id)
|
||||
.map(|edge_ids| {
|
||||
edge_ids
|
||||
.iter()
|
||||
.filter_map(|eid| self.edges.get(eid))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 获取节点的入边。
|
||||
pub fn get_incoming_edges(&self, node_id: &str) -> Vec<&FlowEdge> {
|
||||
self.incoming
|
||||
.get(node_id)
|
||||
.map(|edge_ids| {
|
||||
edge_ids
|
||||
.iter()
|
||||
.filter_map(|eid| self.edges.get(eid))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::{EdgeDef, NodeDef, NodeType};
|
||||
|
||||
fn make_node(id: &str, node_type: NodeType) -> NodeDef {
|
||||
NodeDef {
|
||||
id: id.to_string(),
|
||||
node_type,
|
||||
name: id.to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef {
|
||||
EdgeDef {
|
||||
id: id.to_string(),
|
||||
source: source.to_string(),
|
||||
target: target.to_string(),
|
||||
condition: None,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- build ----
|
||||
|
||||
#[test]
|
||||
fn build_empty_graph() {
|
||||
let graph = FlowGraph::build(&[], &[]);
|
||||
assert!(graph.nodes.is_empty());
|
||||
assert!(graph.edges.is_empty());
|
||||
assert!(graph.start_node_id.is_none());
|
||||
assert!(graph.end_node_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_identifies_start_and_end() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("task1", NodeType::UserTask),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "task1"),
|
||||
make_edge("e2", "task1", "end"),
|
||||
];
|
||||
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
assert_eq!(graph.start_node_id, Some("start".to_string()));
|
||||
assert_eq!(graph.end_node_ids, vec!["end".to_string()]);
|
||||
assert_eq!(graph.nodes.len(), 3);
|
||||
assert_eq!(graph.edges.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_multiple_end_events() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("gw", NodeType::ExclusiveGateway),
|
||||
make_node("end1", NodeType::EndEvent),
|
||||
make_node("end2", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "gw"),
|
||||
make_edge("e2", "gw", "end1"),
|
||||
make_edge("e3", "gw", "end2"),
|
||||
];
|
||||
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
assert_eq!(graph.end_node_ids.len(), 2);
|
||||
assert!(graph.end_node_ids.contains(&"end1".to_string()));
|
||||
assert!(graph.end_node_ids.contains(&"end2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_copies_node_properties() {
|
||||
let user_id = {
|
||||
let ts = uuid::Timestamp::now(uuid::NoContext);
|
||||
uuid::Uuid::new_v7(ts)
|
||||
};
|
||||
let nodes = vec![NodeDef {
|
||||
id: "task".to_string(),
|
||||
node_type: NodeType::UserTask,
|
||||
name: "审批".to_string(),
|
||||
assignee_id: Some(user_id),
|
||||
candidate_groups: Some(vec!["managers".to_string()]),
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
}];
|
||||
let graph = FlowGraph::build(&nodes, &[]);
|
||||
|
||||
let node = graph.nodes.get("task").unwrap();
|
||||
assert_eq!(node.name, "审批");
|
||||
assert_eq!(node.assignee_id, Some(user_id));
|
||||
assert_eq!(node.candidate_groups, Some(vec!["managers".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_edge_with_condition_and_label() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![EdgeDef {
|
||||
id: "e1".to_string(),
|
||||
source: "start".to_string(),
|
||||
target: "end".to_string(),
|
||||
condition: Some("amount > 1000".to_string()),
|
||||
label: Some("高额审批".to_string()),
|
||||
}];
|
||||
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
let edge = graph.edges.get("e1").unwrap();
|
||||
assert_eq!(edge.condition, Some("amount > 1000".to_string()));
|
||||
assert_eq!(edge.label, Some("高额审批".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_edge_to_unknown_node_still_recorded() {
|
||||
let nodes = vec![make_node("start", NodeType::StartEvent)];
|
||||
// edge target "missing" 不在 nodes 中,但 edge 仍被记录
|
||||
let edges = vec![make_edge("e1", "start", "missing")];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
assert_eq!(graph.edges.len(), 1);
|
||||
// outgoing 为 start 有 e1,但 incoming["missing"] 不存在
|
||||
assert_eq!(graph.outgoing.get("start").unwrap().len(), 1);
|
||||
assert!(graph.incoming.get("missing").is_none());
|
||||
}
|
||||
|
||||
// ---- get_outgoing_edges ----
|
||||
|
||||
#[test]
|
||||
fn outgoing_edges_for_known_node() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![make_edge("e1", "start", "end")];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
let out = graph.get_outgoing_edges("start");
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].target, "end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_edges_for_unknown_node_empty() {
|
||||
let graph = FlowGraph::build(&[], &[]);
|
||||
assert!(graph.get_outgoing_edges("nonexistent").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_edges_parallel_gateway_fan_out() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("fork", NodeType::ParallelGateway),
|
||||
make_node("a", NodeType::UserTask),
|
||||
make_node("b", NodeType::ServiceTask),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "fork"),
|
||||
make_edge("e2", "fork", "a"),
|
||||
make_edge("e3", "fork", "b"),
|
||||
];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
let out = graph.get_outgoing_edges("fork");
|
||||
assert_eq!(out.len(), 2);
|
||||
let targets: Vec<&str> = out.iter().map(|e| e.target.as_str()).collect();
|
||||
assert!(targets.contains(&"a"));
|
||||
assert!(targets.contains(&"b"));
|
||||
}
|
||||
|
||||
// ---- get_incoming_edges ----
|
||||
|
||||
#[test]
|
||||
fn incoming_edges_for_known_node() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![make_edge("e1", "start", "end")];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
let inc = graph.get_incoming_edges("end");
|
||||
assert_eq!(inc.len(), 1);
|
||||
assert_eq!(inc[0].source, "start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_edges_for_unknown_node_empty() {
|
||||
let graph = FlowGraph::build(&[], &[]);
|
||||
assert!(graph.get_incoming_edges("nonexistent").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_edges_join_gateway() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("a", NodeType::UserTask),
|
||||
make_node("b", NodeType::ServiceTask),
|
||||
make_node("join", NodeType::ParallelGateway),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "a"),
|
||||
make_edge("e2", "start", "b"),
|
||||
make_edge("e3", "a", "join"),
|
||||
make_edge("e4", "b", "join"),
|
||||
make_edge("e5", "join", "end"),
|
||||
];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
let inc = graph.get_incoming_edges("join");
|
||||
assert_eq!(inc.len(), 2);
|
||||
let sources: Vec<&str> = inc.iter().map(|e| e.source.as_str()).collect();
|
||||
assert!(sources.contains(&"a"));
|
||||
assert!(sources.contains(&"b"));
|
||||
}
|
||||
|
||||
// ---- start/end 节点无入/出边 ----
|
||||
|
||||
#[test]
|
||||
fn start_node_has_no_incoming() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![make_edge("e1", "start", "end")];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
assert!(graph.get_incoming_edges("start").is_empty());
|
||||
assert_eq!(graph.get_outgoing_edges("start").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_node_has_no_outgoing() {
|
||||
let nodes = vec![
|
||||
make_node("start", NodeType::StartEvent),
|
||||
make_node("end", NodeType::EndEvent),
|
||||
];
|
||||
let edges = vec![make_edge("e1", "start", "end")];
|
||||
let graph = FlowGraph::build(&nodes, &edges);
|
||||
|
||||
assert!(graph.get_outgoing_edges("end").is_empty());
|
||||
assert_eq!(graph.get_incoming_edges("end").len(), 1);
|
||||
}
|
||||
}
|
||||
491
crates/erp-workflow/src/engine/parser.rs
Normal file
491
crates/erp-workflow/src/engine/parser.rs
Normal file
@@ -0,0 +1,491 @@
|
||||
use crate::dto::{EdgeDef, NodeDef, NodeType};
|
||||
use crate::engine::model::FlowGraph;
|
||||
use crate::error::{WorkflowError, WorkflowResult};
|
||||
|
||||
/// 解析节点和边列表为 FlowGraph 并验证合法性。
|
||||
pub fn parse_and_validate(nodes: &[NodeDef], edges: &[EdgeDef]) -> WorkflowResult<FlowGraph> {
|
||||
// 基本检查:至少有一个节点
|
||||
if nodes.is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram("流程图不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 检查恰好 1 个 StartEvent
|
||||
let start_count = nodes
|
||||
.iter()
|
||||
.filter(|n| n.node_type == NodeType::StartEvent)
|
||||
.count();
|
||||
if start_count == 0 {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"流程图必须包含一个开始事件".to_string(),
|
||||
));
|
||||
}
|
||||
if start_count > 1 {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"流程图只能包含一个开始事件".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 检查至少 1 个 EndEvent
|
||||
let end_count = nodes
|
||||
.iter()
|
||||
.filter(|n| n.node_type == NodeType::EndEvent)
|
||||
.count();
|
||||
if end_count == 0 {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"流程图必须包含至少一个结束事件".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 检查节点 ID 唯一性
|
||||
let node_ids: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
|
||||
if node_ids.len() != nodes.len() {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"节点 ID 不能重复".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 检查边引用的节点存在
|
||||
for e in edges {
|
||||
if !node_ids.contains(e.source.as_str()) {
|
||||
return Err(WorkflowError::InvalidDiagram(format!(
|
||||
"连线 {} 的源节点 {} 不存在",
|
||||
e.id, e.source
|
||||
)));
|
||||
}
|
||||
if !node_ids.contains(e.target.as_str()) {
|
||||
return Err(WorkflowError::InvalidDiagram(format!(
|
||||
"连线 {} 的目标节点 {} 不存在",
|
||||
e.id, e.target
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 构建图
|
||||
let graph = FlowGraph::build(nodes, edges);
|
||||
|
||||
// 检查 StartEvent 没有入边
|
||||
if let Some(start_id) = &graph.start_node_id {
|
||||
if !graph.get_incoming_edges(start_id).is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"开始事件不能有入边".to_string(),
|
||||
));
|
||||
}
|
||||
if graph.get_outgoing_edges(start_id).is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"开始事件必须有出边".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 EndEvent 没有出边
|
||||
for end_id in &graph.end_node_ids {
|
||||
if !graph.get_outgoing_edges(end_id).is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(
|
||||
"结束事件不能有出边".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 检查网关至少有一个入边和一个出边(排除 start/end)
|
||||
for node in nodes {
|
||||
match &node.node_type {
|
||||
NodeType::ExclusiveGateway | NodeType::ParallelGateway => {
|
||||
let inc = graph.get_incoming_edges(&node.id);
|
||||
let out = graph.get_outgoing_edges(&node.id);
|
||||
if inc.is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(format!(
|
||||
"网关 '{}' 必须有至少一条入边",
|
||||
node.name
|
||||
)));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(format!(
|
||||
"网关 '{}' 必须有至少一条出边",
|
||||
node.name
|
||||
)));
|
||||
}
|
||||
// 排他网关的出边应该有条件(第一条可以无条件作为默认分支)
|
||||
if node.node_type == NodeType::ExclusiveGateway && out.len() > 1 {
|
||||
let with_condition: Vec<_> =
|
||||
out.iter().filter(|e| e.condition.is_some()).collect();
|
||||
if with_condition.is_empty() {
|
||||
return Err(WorkflowError::InvalidDiagram(format!(
|
||||
"排他网关 '{}' 有多条出边但没有条件表达式",
|
||||
node.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::NodePosition;
|
||||
|
||||
fn make_start() -> NodeDef {
|
||||
NodeDef {
|
||||
id: "start".to_string(),
|
||||
node_type: NodeType::StartEvent,
|
||||
name: "开始".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: Some(NodePosition { x: 100.0, y: 100.0 }),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_end() -> NodeDef {
|
||||
NodeDef {
|
||||
id: "end".to_string(),
|
||||
node_type: NodeType::EndEvent,
|
||||
name: "结束".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: Some(NodePosition { x: 100.0, y: 300.0 }),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_user_task(id: &str, name: &str) -> NodeDef {
|
||||
NodeDef {
|
||||
id: id.to_string(),
|
||||
node_type: NodeType::UserTask,
|
||||
name: name.to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef {
|
||||
EdgeDef {
|
||||
id: id.to_string(),
|
||||
source: source.to_string(),
|
||||
target: target.to_string(),
|
||||
condition: None,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_linear_flow() {
|
||||
let nodes = vec![make_start(), make_user_task("task1", "审批"), make_end()];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "task1"),
|
||||
make_edge("e2", "task1", "end"),
|
||||
];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_ok());
|
||||
let graph = result.unwrap();
|
||||
assert_eq!(graph.start_node_id, Some("start".to_string()));
|
||||
assert_eq!(graph.end_node_ids, vec!["end".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_start_event() {
|
||||
let nodes = vec![make_user_task("task1", "审批"), make_end()];
|
||||
let edges = vec![make_edge("e1", "task1", "end")];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("开始事件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_end_event() {
|
||||
let nodes = vec![make_start(), make_user_task("task1", "审批")];
|
||||
let edges = vec![make_edge("e1", "start", "task1")];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("结束事件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_node_id() {
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "start".to_string(), // 重复 ID
|
||||
node_type: NodeType::EndEvent,
|
||||
name: "结束".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
];
|
||||
let edges = vec![];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_event_with_outgoing() {
|
||||
let nodes = vec![make_start(), make_end()];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "end"),
|
||||
make_edge("e2", "end", "start"), // 结束事件有出边
|
||||
];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exclusive_gateway_without_conditions() {
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "gw1".to_string(),
|
||||
node_type: NodeType::ExclusiveGateway,
|
||||
name: "判断".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
make_end(),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "gw1"),
|
||||
make_edge("e2", "gw1", "end"),
|
||||
make_edge("e3", "gw1", "end"), // 两条出边无条件
|
||||
];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---- 边界测试扩展 ----
|
||||
|
||||
#[test]
|
||||
fn test_empty_nodes_rejected() {
|
||||
let result = parse_and_validate(&[], &[]);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("不能为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_start_events_rejected() {
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "start2".to_string(),
|
||||
node_type: NodeType::StartEvent,
|
||||
name: "开始2".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
make_end(),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "end"),
|
||||
make_edge("e2", "start2", "end"),
|
||||
];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("只能包含一个开始事件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_references_nonexistent_source() {
|
||||
let nodes = vec![make_start(), make_end()];
|
||||
let edges = vec![make_edge("e1", "ghost", "end")];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("源节点") && msg.contains("不存在"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_references_nonexistent_target() {
|
||||
let nodes = vec![make_start(), make_end()];
|
||||
let edges = vec![make_edge("e1", "start", "ghost")];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("目标节点") && msg.contains("不存在"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_event_with_incoming_edge_rejected() {
|
||||
let nodes = vec![make_start(), make_end()];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "end"),
|
||||
make_edge("e2", "end", "start"), // start 有入边
|
||||
];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("入边"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_event_without_outgoing_edge_rejected() {
|
||||
let nodes = vec![make_start(), make_end()];
|
||||
let edges = vec![]; // start 没有出边
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("出边"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exclusive_gateway_single_outgoing_ok() {
|
||||
// 单条出边的排他网关不需要条件
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "gw".to_string(),
|
||||
node_type: NodeType::ExclusiveGateway,
|
||||
name: "判断".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
make_end(),
|
||||
];
|
||||
let edges = vec![make_edge("e1", "start", "gw"), make_edge("e2", "gw", "end")];
|
||||
assert!(parse_and_validate(&nodes, &edges).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exclusive_gateway_with_conditions_ok() {
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "gw".to_string(),
|
||||
node_type: NodeType::ExclusiveGateway,
|
||||
name: "金额判断".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
make_user_task("task_a", "小额审批"),
|
||||
make_user_task("task_b", "大额审批"),
|
||||
make_end(),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "gw"),
|
||||
EdgeDef {
|
||||
id: "e2".to_string(),
|
||||
source: "gw".to_string(),
|
||||
target: "task_a".to_string(),
|
||||
condition: Some("amount <= 1000".to_string()),
|
||||
label: None,
|
||||
},
|
||||
EdgeDef {
|
||||
id: "e3".to_string(),
|
||||
source: "gw".to_string(),
|
||||
target: "task_b".to_string(),
|
||||
condition: Some("amount > 1000".to_string()),
|
||||
label: None,
|
||||
},
|
||||
make_edge("e4", "task_a", "end"),
|
||||
make_edge("e5", "task_b", "end"),
|
||||
];
|
||||
assert!(parse_and_validate(&nodes, &edges).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gateway_without_incoming_rejected() {
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "gw".to_string(),
|
||||
node_type: NodeType::ParallelGateway,
|
||||
name: "并行".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
make_end(),
|
||||
];
|
||||
// gw 没有入边
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "end"),
|
||||
make_edge("e2", "gw", "end"),
|
||||
];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("入边"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gateway_without_outgoing_rejected() {
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "gw".to_string(),
|
||||
node_type: NodeType::ParallelGateway,
|
||||
name: "并行".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
make_end(),
|
||||
];
|
||||
// gw 没有出边
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "gw"),
|
||||
make_edge("e2", "start", "end"),
|
||||
];
|
||||
let result = parse_and_validate(&nodes, &edges);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("出边"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parallel_gateway_valid() {
|
||||
let nodes = vec![
|
||||
make_start(),
|
||||
NodeDef {
|
||||
id: "fork".to_string(),
|
||||
node_type: NodeType::ParallelGateway,
|
||||
name: "拆分".to_string(),
|
||||
assignee_id: None,
|
||||
candidate_groups: None,
|
||||
service_type: None,
|
||||
service_config: None,
|
||||
position: None,
|
||||
},
|
||||
make_user_task("a", "任务A"),
|
||||
make_user_task("b", "任务B"),
|
||||
make_end(),
|
||||
];
|
||||
let edges = vec![
|
||||
make_edge("e1", "start", "fork"),
|
||||
make_edge("e2", "fork", "a"),
|
||||
make_edge("e3", "fork", "b"),
|
||||
make_edge("e4", "a", "end"),
|
||||
make_edge("e5", "b", "end"),
|
||||
];
|
||||
assert!(parse_and_validate(&nodes, &edges).is_ok());
|
||||
}
|
||||
}
|
||||
77
crates/erp-workflow/src/engine/timeout.rs
Normal file
77
crates/erp-workflow/src/engine/timeout.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
// 超时检查框架
|
||||
//
|
||||
// TimeoutChecker 定期扫描 tasks 表中已超时但仍处于 pending 状态的任务,
|
||||
// 发布 task.timeout 事件用于升级通知。
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::task;
|
||||
use crate::error::WorkflowResult;
|
||||
|
||||
/// 超时检查服务。
|
||||
pub struct TimeoutChecker;
|
||||
|
||||
impl TimeoutChecker {
|
||||
/// 查询指定租户下已超时但未完成的任务列表。
|
||||
///
|
||||
/// 返回 due_date < now 且 status = 'pending' 的任务 ID。
|
||||
pub async fn find_overdue_tasks(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<Vec<Uuid>> {
|
||||
let now = Utc::now();
|
||||
let overdue = task::Entity::find()
|
||||
.filter(task::Column::TenantId.eq(tenant_id))
|
||||
.filter(task::Column::Status.eq("pending"))
|
||||
.filter(task::Column::DueDate.lt(now))
|
||||
.filter(task::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| crate::error::WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(overdue.iter().map(|t| t.id).collect())
|
||||
}
|
||||
|
||||
/// 查询所有租户中已超时但未完成的任务列表。
|
||||
///
|
||||
/// 返回 due_date < now 且 status = 'pending' 的任务 ID。
|
||||
/// 用于后台定时任务的全量扫描。
|
||||
pub async fn find_all_overdue_tasks(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<Vec<Uuid>> {
|
||||
let now = Utc::now();
|
||||
let overdue = task::Entity::find()
|
||||
.filter(task::Column::Status.eq("pending"))
|
||||
.filter(task::Column::DueDate.lt(now))
|
||||
.filter(task::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| crate::error::WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(overdue.iter().map(|t| t.id).collect())
|
||||
}
|
||||
|
||||
/// 查询所有租户中已超时的任务(含详细信息)。
|
||||
///
|
||||
/// 返回 (task_id, tenant_id, instance_id, assignee_id) 元组,
|
||||
/// 用于发布 task.timeout 事件。
|
||||
pub async fn find_all_overdue_tasks_with_details(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<Vec<(Uuid, Uuid, Uuid, Option<Uuid>)>> {
|
||||
let now = Utc::now();
|
||||
let overdue = task::Entity::find()
|
||||
.filter(task::Column::Status.eq("pending"))
|
||||
.filter(task::Column::DueDate.lt(now))
|
||||
.filter(task::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| crate::error::WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(overdue
|
||||
.iter()
|
||||
.map(|t| (t.id, t.tenant_id, t.instance_id, t.assignee_id))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
5
crates/erp-workflow/src/entity/mod.rs
Normal file
5
crates/erp-workflow/src/entity/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod process_definition;
|
||||
pub mod process_instance;
|
||||
pub mod process_variable;
|
||||
pub mod task;
|
||||
pub mod token;
|
||||
43
crates/erp-workflow/src/entity/process_definition.rs
Normal file
43
crates/erp-workflow/src/entity/process_definition.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "process_definitions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
/// 业务版本号(如同一 key 可存在多个版本),当前固定为 1,未来支持发布新版本时递增。
|
||||
pub version: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub nodes: serde_json::Value,
|
||||
pub edges: serde_json::Value,
|
||||
pub status: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
/// 乐观锁版本号(ERP 标准审计字段),每次更新递增。
|
||||
pub version_field: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::process_instance::Entity")]
|
||||
ProcessInstance,
|
||||
}
|
||||
|
||||
impl Related<super::process_instance::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ProcessInstance.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
59
crates/erp-workflow/src/entity/process_instance.rs
Normal file
59
crates/erp-workflow/src/entity/process_instance.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "process_instances")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub definition_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub business_key: Option<String>,
|
||||
pub status: String,
|
||||
pub started_by: Uuid,
|
||||
pub started_at: DateTimeUtc,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::process_definition::Entity",
|
||||
from = "Column::DefinitionId",
|
||||
to = "super::process_definition::Column::Id"
|
||||
)]
|
||||
ProcessDefinition,
|
||||
#[sea_orm(has_many = "super::token::Entity")]
|
||||
Token,
|
||||
#[sea_orm(has_many = "super::task::Entity")]
|
||||
Task,
|
||||
}
|
||||
|
||||
impl Related<super::process_definition::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ProcessDefinition.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::token::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Token.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::task::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Task.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
46
crates/erp-workflow/src/entity/process_variable.rs
Normal file
46
crates/erp-workflow/src/entity/process_variable.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "process_variables")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub instance_id: Uuid,
|
||||
pub name: String,
|
||||
pub var_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_string: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_number: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_boolean: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_date: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::process_instance::Entity",
|
||||
from = "Column::InstanceId",
|
||||
to = "super::process_instance::Column::Id"
|
||||
)]
|
||||
ProcessInstance,
|
||||
}
|
||||
|
||||
impl Related<super::process_instance::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ProcessInstance.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
65
crates/erp-workflow/src/entity/task.rs
Normal file
65
crates/erp-workflow/src/entity/task.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "tasks")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub instance_id: Uuid,
|
||||
pub token_id: Uuid,
|
||||
pub node_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assignee_id: Option<Uuid>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_groups: Option<serde_json::Value>,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outcome: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_data: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub due_date: Option<DateTimeUtc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::process_instance::Entity",
|
||||
from = "Column::InstanceId",
|
||||
to = "super::process_instance::Column::Id"
|
||||
)]
|
||||
ProcessInstance,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::token::Entity",
|
||||
from = "Column::TokenId",
|
||||
to = "super::token::Column::Id"
|
||||
)]
|
||||
Token,
|
||||
}
|
||||
|
||||
impl Related<super::process_instance::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ProcessInstance.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::token::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Token.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
40
crates/erp-workflow/src/entity/token.rs
Normal file
40
crates/erp-workflow/src/entity/token.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "tokens")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
pub instance_id: Uuid,
|
||||
pub node_id: String,
|
||||
pub status: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub created_by: Uuid,
|
||||
pub updated_by: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub consumed_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::process_instance::Entity",
|
||||
from = "Column::InstanceId",
|
||||
to = "super::process_instance::Column::Id"
|
||||
)]
|
||||
ProcessInstance,
|
||||
}
|
||||
|
||||
impl Related<super::process_instance::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ProcessInstance.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
128
crates/erp-workflow/src/error.rs
Normal file
128
crates/erp-workflow/src/error.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use erp_core::error::AppError;
|
||||
|
||||
/// Workflow module error types.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkflowError {
|
||||
#[error("验证失败: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("资源未找到: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("流程定义已存在: {0}")]
|
||||
DuplicateDefinition(String),
|
||||
|
||||
#[error("流程图无效: {0}")]
|
||||
InvalidDiagram(String),
|
||||
|
||||
#[error("流程状态错误: {0}")]
|
||||
InvalidState(String),
|
||||
|
||||
#[error("表达式求值失败: {0}")]
|
||||
ExpressionError(String),
|
||||
|
||||
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
|
||||
VersionMismatch,
|
||||
}
|
||||
|
||||
impl From<sea_orm::TransactionError<WorkflowError>> for WorkflowError {
|
||||
fn from(err: sea_orm::TransactionError<WorkflowError>) -> Self {
|
||||
match err {
|
||||
sea_orm::TransactionError::Connection(err) => {
|
||||
WorkflowError::Validation(err.to_string())
|
||||
}
|
||||
sea_orm::TransactionError::Transaction(inner) => inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkflowError> for AppError {
|
||||
fn from(err: WorkflowError) -> Self {
|
||||
match err {
|
||||
WorkflowError::Validation(s) => AppError::Validation(s),
|
||||
WorkflowError::NotFound(s) => AppError::NotFound(s),
|
||||
WorkflowError::DuplicateDefinition(s) => AppError::Conflict(s),
|
||||
WorkflowError::InvalidDiagram(s) => AppError::Validation(s),
|
||||
WorkflowError::InvalidState(s) => AppError::Validation(s),
|
||||
WorkflowError::ExpressionError(s) => AppError::Validation(s),
|
||||
WorkflowError::VersionMismatch => AppError::VersionMismatch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type WorkflowResult<T> = Result<T, WorkflowError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validation_maps_to_app_error_validation() {
|
||||
let err = WorkflowError::Validation("字段缺失".to_string());
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert!(msg.contains("字段缺失")),
|
||||
other => panic!("期望 AppError::Validation,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_maps_to_app_error_not_found() {
|
||||
let err = WorkflowError::NotFound("流程不存在".to_string());
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::NotFound(msg) => assert!(msg.contains("流程不存在")),
|
||||
other => panic!("期望 AppError::NotFound,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_definition_maps_to_app_error_conflict() {
|
||||
let err = WorkflowError::DuplicateDefinition("key 已存在".to_string());
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::Conflict(msg) => assert!(msg.contains("key 已存在")),
|
||||
other => panic!("期望 AppError::Conflict,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_diagram_maps_to_validation() {
|
||||
let err = WorkflowError::InvalidDiagram("缺少 StartEvent".to_string());
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert!(msg.contains("缺少 StartEvent")),
|
||||
other => panic!("期望 AppError::Validation,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_state_maps_to_validation() {
|
||||
let err = WorkflowError::InvalidState("流程已结束".to_string());
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert!(msg.contains("流程已结束")),
|
||||
other => panic!("期望 AppError::Validation,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_error_maps_to_validation() {
|
||||
let err = WorkflowError::ExpressionError("语法错误".to_string());
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::Validation(msg) => assert!(msg.contains("语法错误")),
|
||||
other => panic!("期望 AppError::Validation,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_maps_directly() {
|
||||
let err = WorkflowError::VersionMismatch;
|
||||
let app: AppError = err.into();
|
||||
match app {
|
||||
AppError::VersionMismatch => {}
|
||||
other => panic!("期望 AppError::VersionMismatch,得到 {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
200
crates/erp-workflow/src/handler/definition_handler.rs
Normal file
200
crates/erp-workflow/src/handler/definition_handler.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateProcessDefinitionReq, ProcessDefinitionResp, UpdateProcessDefinitionReq};
|
||||
use crate::service::definition_service::DefinitionService;
|
||||
use crate::workflow_state::WorkflowState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflow/definitions",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<ProcessDefinitionResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程定义"
|
||||
)]
|
||||
/// GET /api/v1/workflow/definitions
|
||||
pub async fn list_definitions<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<ProcessDefinitionResp>>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.list")?;
|
||||
|
||||
let (defs, total) = DefinitionService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: defs,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/definitions",
|
||||
request_body = CreateProcessDefinitionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ProcessDefinitionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程定义"
|
||||
)]
|
||||
/// POST /api/v1/workflow/definitions
|
||||
pub async fn create_definition<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateProcessDefinitionReq>,
|
||||
) -> Result<Json<ApiResponse<ProcessDefinitionResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.create")?;
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = DefinitionService::create(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflow/definitions/{id}",
|
||||
params(("id" = Uuid, Path, description = "流程定义ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ProcessDefinitionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "流程定义不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程定义"
|
||||
)]
|
||||
/// GET /api/v1/workflow/definitions/{id}
|
||||
pub async fn get_definition<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<ProcessDefinitionResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.read")?;
|
||||
|
||||
let resp = DefinitionService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/workflow/definitions/{id}",
|
||||
params(("id" = Uuid, Path, description = "流程定义ID")),
|
||||
request_body = UpdateProcessDefinitionReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ProcessDefinitionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "流程定义不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程定义"
|
||||
)]
|
||||
/// PUT /api/v1/workflow/definitions/{id}
|
||||
pub async fn update_definition<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateProcessDefinitionReq>,
|
||||
) -> Result<Json<ApiResponse<ProcessDefinitionResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.update")?;
|
||||
|
||||
let resp = DefinitionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/definitions/{id}/publish",
|
||||
params(("id" = Uuid, Path, description = "流程定义ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ProcessDefinitionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "流程定义不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程定义"
|
||||
)]
|
||||
/// POST /api/v1/workflow/definitions/{id}/publish
|
||||
pub async fn publish_definition<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<ProcessDefinitionResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.publish")?;
|
||||
|
||||
let resp =
|
||||
DefinitionService::publish(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
pub async fn deprecate_definition<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<ProcessDefinitionResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.publish")?;
|
||||
|
||||
let resp =
|
||||
DefinitionService::deprecate(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
206
crates/erp-workflow/src/handler/instance_handler.rs
Normal file
206
crates/erp-workflow/src/handler/instance_handler.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{ProcessInstanceResp, StartInstanceReq};
|
||||
use crate::service::instance_service::InstanceService;
|
||||
use crate::workflow_state::WorkflowState;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/instances",
|
||||
request_body = StartInstanceReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ProcessInstanceResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程实例"
|
||||
)]
|
||||
/// POST /api/v1/workflow/instances
|
||||
pub async fn start_instance<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<StartInstanceReq>,
|
||||
) -> Result<Json<ApiResponse<ProcessInstanceResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.start")?;
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = InstanceService::start(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflow/instances",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<ProcessInstanceResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程实例"
|
||||
)]
|
||||
/// GET /api/v1/workflow/instances
|
||||
pub async fn list_instances<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<ProcessInstanceResp>>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.list")?;
|
||||
|
||||
let (instances, total) = InstanceService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: instances,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflow/instances/{id}",
|
||||
params(("id" = Uuid, Path, description = "流程实例ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<ProcessInstanceResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "流程实例不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程实例"
|
||||
)]
|
||||
/// GET /api/v1/workflow/instances/{id}
|
||||
pub async fn get_instance<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<ProcessInstanceResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.read")?;
|
||||
|
||||
let resp = InstanceService::get_by_id(id, ctx.tenant_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/instances/{id}/suspend",
|
||||
params(("id" = Uuid, Path, description = "流程实例ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "流程实例不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程实例"
|
||||
)]
|
||||
/// POST /api/v1/workflow/instances/{id}/suspend
|
||||
pub async fn suspend_instance<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.update")?;
|
||||
|
||||
InstanceService::suspend(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/instances/{id}/terminate",
|
||||
params(("id" = Uuid, Path, description = "流程实例ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "流程实例不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程实例"
|
||||
)]
|
||||
/// POST /api/v1/workflow/instances/{id}/terminate
|
||||
pub async fn terminate_instance<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.update")?;
|
||||
|
||||
InstanceService::terminate(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/instances/{id}/resume",
|
||||
params(("id" = Uuid, Path, description = "流程实例ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "流程实例不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程实例"
|
||||
)]
|
||||
/// POST /api/v1/workflow/instances/{id}/resume
|
||||
pub async fn resume_instance<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.update")?;
|
||||
|
||||
InstanceService::resume(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
3
crates/erp-workflow/src/handler/mod.rs
Normal file
3
crates/erp-workflow/src/handler/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod definition_handler;
|
||||
pub mod instance_handler;
|
||||
pub mod task_handler;
|
||||
199
crates/erp-workflow/src/handler/task_handler.rs
Normal file
199
crates/erp-workflow/src/handler/task_handler.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use axum::Extension;
|
||||
use axum::extract::{FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use validator::Validate;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CompleteTaskReq, DelegateTaskReq, TaskResp};
|
||||
use crate::service::task_service::TaskService;
|
||||
use crate::workflow_state::WorkflowState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflow/tasks/pending",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<TaskResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程任务"
|
||||
)]
|
||||
/// GET /api/v1/workflow/tasks/pending
|
||||
pub async fn list_pending_tasks<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<TaskResp>>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.approve")?;
|
||||
|
||||
let (tasks, total) =
|
||||
TaskService::list_pending(ctx.tenant_id, ctx.user_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: tasks,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflow/tasks/completed",
|
||||
params(Pagination),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<TaskResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程任务"
|
||||
)]
|
||||
/// GET /api/v1/workflow/tasks/completed
|
||||
pub async fn list_completed_tasks<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<TaskResp>>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.approve")?;
|
||||
|
||||
let (tasks, total) =
|
||||
TaskService::list_completed(ctx.tenant_id, ctx.user_id, &pagination, &state.db).await?;
|
||||
|
||||
let page = pagination.page.unwrap_or(1);
|
||||
let page_size = pagination.limit();
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
|
||||
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||
data: tasks,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/tasks/{id}/complete",
|
||||
params(("id" = Uuid, Path, description = "任务ID")),
|
||||
request_body = CompleteTaskReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<TaskResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "任务不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程任务"
|
||||
)]
|
||||
/// POST /api/v1/workflow/tasks/{id}/complete
|
||||
pub async fn complete_task<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<CompleteTaskReq>,
|
||||
) -> Result<Json<ApiResponse<TaskResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.approve")?;
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = TaskService::complete(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&req,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflow/tasks/{id}/delegate",
|
||||
params(("id" = Uuid, Path, description = "任务ID")),
|
||||
request_body = DelegateTaskReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<TaskResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "任务不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程任务"
|
||||
)]
|
||||
/// POST /api/v1/workflow/tasks/{id}/delegate
|
||||
pub async fn delegate_task<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<DelegateTaskReq>,
|
||||
) -> Result<Json<ApiResponse<TaskResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.delegate")?;
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = TaskService::delegate(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/workflow/tasks/{id}/claim",
|
||||
params(("id" = Uuid, Path, description = "任务ID")),
|
||||
responses(
|
||||
(status = 200, description = "认领成功", body = ApiResponse<TaskResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "任务不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "流程任务"
|
||||
)]
|
||||
/// PUT /api/v1/workflow/tasks/{id}/claim
|
||||
pub async fn claim_task<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<TaskResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.approve")?;
|
||||
|
||||
let resp = TaskService::claim(id, ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
16
crates/erp-workflow/src/lib.rs
Normal file
16
crates/erp-workflow/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
// erp-workflow: 工作流引擎模块 (Phase 4)
|
||||
//
|
||||
// 提供流程定义、流程实例管理、任务审批、Token 驱动执行引擎
|
||||
// 和可视化流程设计器支持。
|
||||
|
||||
pub mod dto;
|
||||
pub mod engine;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
pub mod module;
|
||||
pub mod service;
|
||||
pub mod workflow_state;
|
||||
|
||||
pub use module::WorkflowModule;
|
||||
pub use workflow_state::WorkflowState;
|
||||
549
crates/erp-workflow/src/module.rs
Normal file
549
crates/erp-workflow/src/module.rs
Normal file
@@ -0,0 +1,549 @@
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post, put};
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppResult;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::module::{ErpModule, PermissionDescriptor};
|
||||
|
||||
use crate::handler::{definition_handler, instance_handler, task_handler};
|
||||
|
||||
/// Workflow module implementing the `ErpModule` trait.
|
||||
///
|
||||
/// Manages workflow definitions, process instances, tasks,
|
||||
/// and the token-driven execution engine.
|
||||
pub struct WorkflowModule;
|
||||
|
||||
impl WorkflowModule {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Build protected (authenticated) routes for the workflow module.
|
||||
pub fn protected_routes<S>() -> Router<S>
|
||||
where
|
||||
crate::workflow_state::WorkflowState: axum::extract::FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
// Definition routes
|
||||
.route(
|
||||
"/workflow/definitions",
|
||||
get(definition_handler::list_definitions)
|
||||
.post(definition_handler::create_definition),
|
||||
)
|
||||
.route(
|
||||
"/workflow/definitions/{id}",
|
||||
get(definition_handler::get_definition).put(definition_handler::update_definition),
|
||||
)
|
||||
.route(
|
||||
"/workflow/definitions/{id}/publish",
|
||||
post(definition_handler::publish_definition),
|
||||
)
|
||||
.route(
|
||||
"/workflow/definitions/{id}/deprecate",
|
||||
post(definition_handler::deprecate_definition),
|
||||
)
|
||||
// Instance routes
|
||||
.route(
|
||||
"/workflow/instances",
|
||||
post(instance_handler::start_instance).get(instance_handler::list_instances),
|
||||
)
|
||||
.route(
|
||||
"/workflow/instances/{id}",
|
||||
get(instance_handler::get_instance),
|
||||
)
|
||||
.route(
|
||||
"/workflow/instances/{id}/suspend",
|
||||
post(instance_handler::suspend_instance),
|
||||
)
|
||||
.route(
|
||||
"/workflow/instances/{id}/resume",
|
||||
post(instance_handler::resume_instance),
|
||||
)
|
||||
.route(
|
||||
"/workflow/instances/{id}/terminate",
|
||||
post(instance_handler::terminate_instance),
|
||||
)
|
||||
// Task routes
|
||||
.route(
|
||||
"/workflow/tasks/pending",
|
||||
get(task_handler::list_pending_tasks),
|
||||
)
|
||||
.route(
|
||||
"/workflow/tasks/completed",
|
||||
get(task_handler::list_completed_tasks),
|
||||
)
|
||||
.route(
|
||||
"/workflow/tasks/{id}/complete",
|
||||
post(task_handler::complete_task),
|
||||
)
|
||||
.route(
|
||||
"/workflow/tasks/{id}/delegate",
|
||||
post(task_handler::delegate_task),
|
||||
)
|
||||
.route("/workflow/tasks/{id}/claim", put(task_handler::claim_task))
|
||||
}
|
||||
|
||||
/// 启动超时检查后台任务。
|
||||
///
|
||||
/// 每 60 秒扫描一次 tasks 表,查找 due_date 已过期但仍处于 pending 状态的任务。
|
||||
/// 发现超时任务时发布 `task.timeout` 事件到事件总线,并记录 warning 日志。
|
||||
pub fn start_timeout_checker(db: sea_orm::DatabaseConnection, event_bus: EventBus) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
|
||||
// 首次跳过,等一个完整间隔再执行
|
||||
interval.tick().await;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match crate::engine::timeout::TimeoutChecker::find_all_overdue_tasks_with_details(
|
||||
&db,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(overdue) => {
|
||||
if !overdue.is_empty() {
|
||||
tracing::warn!(
|
||||
count = overdue.len(),
|
||||
"发现超时未完成的任务,发布 task.timeout 事件"
|
||||
);
|
||||
for (task_id, tenant_id, instance_id, assignee_id) in &overdue {
|
||||
// 发布超时事件
|
||||
let event = erp_core::events::DomainEvent::new(
|
||||
"task.timeout",
|
||||
*tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"instance_id": instance_id,
|
||||
"assignee_id": assignee_id,
|
||||
})),
|
||||
);
|
||||
event_bus.publish(event, &db).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "超时检查任务执行失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 AI 行动工作流启动请求
|
||||
async fn handle_ai_action_start(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
event: &erp_core::events::DomainEvent,
|
||||
) {
|
||||
let workflow_key = match event.payload.get("workflow_key").and_then(|v| v.as_str()) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
tracing::warn!("AI 行动工作流事件缺少 workflow_key,跳过");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tenant_id = event.tenant_id;
|
||||
|
||||
// 查找对应的流程定义
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
let def = crate::entity::process_definition::Entity::find()
|
||||
.filter(crate::entity::process_definition::Column::TenantId.eq(tenant_id))
|
||||
.filter(crate::entity::process_definition::Column::Key.eq(workflow_key))
|
||||
.filter(crate::entity::process_definition::Column::DeletedAt.is_null())
|
||||
.filter(crate::entity::process_definition::Column::Status.eq("published"))
|
||||
.one(db)
|
||||
.await;
|
||||
|
||||
let def = match def {
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
key = %workflow_key,
|
||||
tenant_id = %tenant_id,
|
||||
"AI 行动工作流定义未找到或未发布,跳过"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "查询工作流定义失败");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 构造启动变量
|
||||
let risk_level = event
|
||||
.payload
|
||||
.get("risk_level")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("medium")
|
||||
.to_string();
|
||||
|
||||
let variables = vec![
|
||||
crate::dto::SetVariableReq {
|
||||
name: "risk_level".into(),
|
||||
var_type: Some("string".into()),
|
||||
value: serde_json::Value::String(risk_level.clone()),
|
||||
},
|
||||
crate::dto::SetVariableReq {
|
||||
name: "patient_id".into(),
|
||||
var_type: Some("string".into()),
|
||||
value: event
|
||||
.payload
|
||||
.get("patient_id")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
crate::dto::SetVariableReq {
|
||||
name: "action_type".into(),
|
||||
var_type: Some("string".into()),
|
||||
value: event
|
||||
.payload
|
||||
.get("action_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| serde_json::Value::String(s.to_string()))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
crate::dto::SetVariableReq {
|
||||
name: "params".into(),
|
||||
var_type: Some("string".into()),
|
||||
value: event
|
||||
.payload
|
||||
.get("params")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
];
|
||||
|
||||
let req = crate::dto::StartInstanceReq {
|
||||
definition_id: def.id,
|
||||
business_key: Some(format!(
|
||||
"ai_action_{}",
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
)),
|
||||
variables: Some(variables),
|
||||
};
|
||||
|
||||
let system_id = Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap();
|
||||
|
||||
match crate::service::instance_service::InstanceService::start(
|
||||
tenant_id, system_id, &req, db, event_bus,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(instance) => {
|
||||
tracing::info!(
|
||||
key = %workflow_key,
|
||||
instance_id = %instance.id,
|
||||
tenant_id = %tenant_id,
|
||||
risk_level = %risk_level,
|
||||
"AI 行动工作流实例已启动"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
key = %workflow_key,
|
||||
error = %e,
|
||||
"AI 行动工作流实例启动失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkflowModule {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ErpModule for WorkflowModule {
|
||||
fn name(&self) -> &str {
|
||||
"workflow"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
fn dependencies(&self) -> Vec<&str> {
|
||||
vec!["auth"]
|
||||
}
|
||||
|
||||
fn register_event_handlers(&self, _bus: &EventBus) {
|
||||
// 事件处理器已迁移到 on_startup(需要 DB 连接),此处保留空实现以兼容 trait 签名
|
||||
}
|
||||
|
||||
async fn on_startup(
|
||||
&self,
|
||||
ctx: &erp_core::module::ModuleContext,
|
||||
) -> erp_core::error::AppResult<()> {
|
||||
let db = ctx.db.clone();
|
||||
let bus = ctx.event_bus.clone();
|
||||
|
||||
// 订阅 user. 前缀事件,处理 user.deleted
|
||||
let (mut receiver, _handle) = bus.subscribe_filtered("user.".to_string());
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Some(event) if event.event_type == "user.deleted" => {
|
||||
let user_id = match event.payload.get("user_id").and_then(|v| v.as_str()) {
|
||||
Some(id) => match Uuid::parse_str(id) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"user.deleted 事件的 user_id 解析失败,跳过"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
tracing::warn!("user.deleted 事件缺少 user_id 字段,跳过");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
user_id = %user_id,
|
||||
tenant_id = %event.tenant_id,
|
||||
"收到 user.deleted 事件,查找并终止相关流程实例"
|
||||
);
|
||||
|
||||
// 查找该用户有活跃任务的流程实例
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set,
|
||||
};
|
||||
|
||||
// 查找该用户作为 assignee 的 pending 任务
|
||||
let active_tasks = crate::entity::task::Entity::find()
|
||||
.filter(crate::entity::task::Column::TenantId.eq(event.tenant_id))
|
||||
.filter(crate::entity::task::Column::AssigneeId.eq(user_id))
|
||||
.filter(crate::entity::task::Column::Status.eq("pending"))
|
||||
.filter(crate::entity::task::Column::DeletedAt.is_null())
|
||||
.all(&db)
|
||||
.await;
|
||||
|
||||
match active_tasks {
|
||||
Ok(tasks) if tasks.is_empty() => {
|
||||
tracing::info!(
|
||||
user_id = %user_id,
|
||||
"该用户没有活跃的待办任务,无需终止流程"
|
||||
);
|
||||
}
|
||||
Ok(tasks) => {
|
||||
// 收集需要终止的实例 ID
|
||||
let instance_ids: std::collections::HashSet<Uuid> =
|
||||
tasks.iter().map(|t| t.instance_id).collect();
|
||||
|
||||
for instance_id in &instance_ids {
|
||||
// 将实例状态设置为 terminated
|
||||
let instance =
|
||||
crate::entity::process_instance::Entity::find_by_id(
|
||||
*instance_id,
|
||||
)
|
||||
.one(&db)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(inst)) = instance
|
||||
&& inst.tenant_id == event.tenant_id
|
||||
&& inst.deleted_at.is_none()
|
||||
&& inst.status == "running"
|
||||
{
|
||||
let ver = inst.version;
|
||||
let mut active: crate::entity::process_instance::ActiveModel = inst.into();
|
||||
active.status = Set("terminated".to_string());
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.version = Set(ver + 1);
|
||||
match active.update(&db).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
instance_id = %instance_id,
|
||||
"流程实例已终止(用户被删除)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
instance_id = %instance_id,
|
||||
error = %e,
|
||||
"终止流程实例失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
user_id = %user_id,
|
||||
instance_count = instance_ids.len(),
|
||||
task_count = tasks.len(),
|
||||
"用户删除事件处理完成"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"查询用户活跃任务失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(event) => {
|
||||
// 其他 user. 前缀事件,忽略
|
||||
tracing::debug!(
|
||||
event_type = %event.event_type,
|
||||
"忽略非 user.deleted 事件"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// 通道关闭,退出循环
|
||||
tracing::info!("Workflow 事件订阅通道已关闭");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
module = "workflow",
|
||||
"Workflow 事件处理器已注册(监听 user.deleted)"
|
||||
);
|
||||
|
||||
// 订阅 AI 行动工作流启动请求
|
||||
let (mut ai_rx, _ai_handle) = bus.subscribe_filtered("workflow.ai_action.".to_string());
|
||||
let ai_db = ctx.db.clone();
|
||||
let ai_bus = bus.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match ai_rx.recv().await {
|
||||
Some(event) if event.event_type == "workflow.ai_action.start_requested" => {
|
||||
handle_ai_action_start(&ai_db, &ai_bus, &event).await;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
tracing::info!("AI 行动工作流事件订阅通道已关闭");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_tenant_created(
|
||||
&self,
|
||||
_tenant_id: Uuid,
|
||||
_db: &sea_orm::DatabaseConnection,
|
||||
_event_bus: &EventBus,
|
||||
) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_tenant_deleted(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
// Delete in dependency order: variables → tasks → tokens → instances → definitions
|
||||
// process_variables
|
||||
crate::entity::process_variable::Entity::delete_many()
|
||||
.filter(crate::entity::process_variable::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// tasks
|
||||
crate::entity::task::Entity::delete_many()
|
||||
.filter(crate::entity::task::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// tokens
|
||||
crate::entity::token::Entity::delete_many()
|
||||
.filter(crate::entity::token::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// process_instances
|
||||
crate::entity::process_instance::Entity::delete_many()
|
||||
.filter(crate::entity::process_instance::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// process_definitions
|
||||
crate::entity::process_definition::Entity::delete_many()
|
||||
.filter(crate::entity::process_definition::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
tracing::info!(%tenant_id, "Workflow data cleaned up for deleted tenant");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<PermissionDescriptor> {
|
||||
vec![
|
||||
PermissionDescriptor {
|
||||
code: "workflow.create".into(),
|
||||
name: "创建流程".into(),
|
||||
description: "创建流程定义".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "workflow.list".into(),
|
||||
name: "查看流程".into(),
|
||||
description: "查看流程列表".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "workflow.read".into(),
|
||||
name: "查看流程详情".into(),
|
||||
description: "查看流程定义详情".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "workflow.update".into(),
|
||||
name: "编辑流程".into(),
|
||||
description: "编辑流程定义".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "workflow.publish".into(),
|
||||
name: "发布流程".into(),
|
||||
description: "发布流程定义".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "workflow.start".into(),
|
||||
name: "发起流程".into(),
|
||||
description: "发起流程实例".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "workflow.approve".into(),
|
||||
name: "审批任务".into(),
|
||||
description: "审批流程任务".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
PermissionDescriptor {
|
||||
code: "workflow.delegate".into(),
|
||||
name: "委派任务".into(),
|
||||
description: "委派流程任务".into(),
|
||||
module: "workflow".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
417
crates/erp-workflow/src/service/definition_service.rs
Normal file
417
crates/erp-workflow/src/service/definition_service.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateProcessDefinitionReq, ProcessDefinitionResp, UpdateProcessDefinitionReq};
|
||||
use crate::engine::parser;
|
||||
use crate::entity::process_definition;
|
||||
use crate::error::{WorkflowError, WorkflowResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::error::check_version;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// 流程定义 CRUD 服务。
|
||||
pub struct DefinitionService;
|
||||
|
||||
impl DefinitionService {
|
||||
/// 分页查询流程定义列表。
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<(Vec<ProcessDefinitionResp>, u64)> {
|
||||
let paginator = process_definition::Entity::find()
|
||||
.filter(process_definition::Column::TenantId.eq(tenant_id))
|
||||
.filter(process_definition::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let resps: Vec<ProcessDefinitionResp> = models.iter().map(Self::model_to_resp).collect();
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// 获取单个流程定义。
|
||||
pub async fn get_by_id(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<ProcessDefinitionResp> {
|
||||
let model = process_definition::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
||||
|
||||
Ok(Self::model_to_resp(&model))
|
||||
}
|
||||
|
||||
/// 创建流程定义。
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateProcessDefinitionReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> WorkflowResult<ProcessDefinitionResp> {
|
||||
// 验证流程图合法性
|
||||
parser::parse_and_validate(&req.nodes, &req.edges)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let nodes_json = serde_json::to_value(&req.nodes)
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
let edges_json = serde_json::to_value(&req.edges)
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let model = process_definition::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name.clone()),
|
||||
key: Set(req.key.clone()),
|
||||
version: Set(1),
|
||||
category: Set(req.category.clone()),
|
||||
description: Set(req.description.clone()),
|
||||
nodes: Set(nodes_json),
|
||||
edges: Set(edges_json),
|
||||
status: Set("draft".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id),
|
||||
updated_by: Set(operator_id),
|
||||
deleted_at: Set(None),
|
||||
version_field: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"process_definition.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "definition_id": id, "key": req.key }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"process_definition.create",
|
||||
"process_definition",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ProcessDefinitionResp {
|
||||
id,
|
||||
name: req.name.clone(),
|
||||
key: req.key.clone(),
|
||||
version: 1,
|
||||
category: req.category.clone(),
|
||||
description: req.description.clone(),
|
||||
nodes: serde_json::to_value(&req.nodes).unwrap_or_default(),
|
||||
edges: serde_json::to_value(&req.edges).unwrap_or_default(),
|
||||
status: "draft".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
lock_version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新流程定义(仅 draft 状态可编辑)。
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateProcessDefinitionReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<ProcessDefinitionResp> {
|
||||
let model = process_definition::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
||||
|
||||
if model.status != "draft" {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"只有 draft 状态的流程定义可以编辑".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version_field;
|
||||
let mut active: process_definition::ActiveModel = model.into();
|
||||
|
||||
if let Some(name) = &req.name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(category) = &req.category {
|
||||
active.category = Set(Some(category.clone()));
|
||||
}
|
||||
if let Some(description) = &req.description {
|
||||
active.description = Set(Some(description.clone()));
|
||||
}
|
||||
// 当 nodes 或 edges 任一存在时,取最终值验证流程图完整性
|
||||
let _final_nodes = req.nodes.as_ref().or_else(|| {
|
||||
serde_json::from_value::<Vec<crate::dto::NodeDef>>(active.nodes.as_ref().clone())
|
||||
.ok()
|
||||
.as_ref()
|
||||
.map(|_| unreachable!())
|
||||
});
|
||||
// 简化:如果提供了 nodes 或 edges,将两者合并后验证
|
||||
if req.nodes.is_some() || req.edges.is_some() {
|
||||
let nodes_val = req
|
||||
.nodes
|
||||
.as_ref()
|
||||
.map(|n| serde_json::to_value(n).unwrap_or_default())
|
||||
.unwrap_or(active.nodes.as_ref().clone());
|
||||
let edges_val = req
|
||||
.edges
|
||||
.as_ref()
|
||||
.map(|e| serde_json::to_value(e).unwrap_or_default())
|
||||
.unwrap_or(active.edges.as_ref().clone());
|
||||
let nodes: Vec<crate::dto::NodeDef> = serde_json::from_value(nodes_val)
|
||||
.map_err(|e| WorkflowError::Validation(format!("节点数据无效: {e}")))?;
|
||||
let edges: Vec<crate::dto::EdgeDef> = serde_json::from_value(edges_val)
|
||||
.map_err(|e| WorkflowError::Validation(format!("连线数据无效: {e}")))?;
|
||||
parser::parse_and_validate(&nodes, &edges)?;
|
||||
}
|
||||
if let Some(nodes) = &req.nodes {
|
||||
let nodes_json = serde_json::to_value(nodes)
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
active.nodes = Set(nodes_json);
|
||||
}
|
||||
if let Some(edges) = &req.edges {
|
||||
let edges_json = serde_json::to_value(edges)
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
active.edges = Set(edges_json);
|
||||
}
|
||||
|
||||
let next_ver = check_version(req.version, current_version)
|
||||
.map_err(|_| WorkflowError::VersionMismatch)?;
|
||||
active.version_field = Set(next_ver);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"process_definition.update",
|
||||
"process_definition",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 发布流程定义(draft → published)。
|
||||
pub async fn publish(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> WorkflowResult<ProcessDefinitionResp> {
|
||||
let model = process_definition::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
||||
|
||||
if model.status != "draft" {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"只有 draft 状态的流程定义可以发布".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证流程图
|
||||
let nodes: Vec<crate::dto::NodeDef> = serde_json::from_value(model.nodes.clone())
|
||||
.map_err(|e| WorkflowError::InvalidDiagram(format!("节点数据无效: {e}")))?;
|
||||
let edges: Vec<crate::dto::EdgeDef> = serde_json::from_value(model.edges.clone())
|
||||
.map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?;
|
||||
parser::parse_and_validate(&nodes, &edges)?;
|
||||
|
||||
let current_version = model.version_field;
|
||||
let mut active: process_definition::ActiveModel = model.into();
|
||||
active.status = Set("published".to_string());
|
||||
active.version_field = Set(current_version + 1);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"process_definition.published",
|
||||
tenant_id,
|
||||
serde_json::json!({ "definition_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"process_definition.publish",
|
||||
"process_definition",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 将已发布的流程定义标记为 deprecated。
|
||||
pub async fn deprecate(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> WorkflowResult<ProcessDefinitionResp> {
|
||||
let model = process_definition::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
||||
|
||||
if model.status != "published" {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"只有 published 状态的流程定义可以废弃".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version_field;
|
||||
let mut active: process_definition::ActiveModel = model.into();
|
||||
active.status = Set("deprecated".to_string());
|
||||
active.version_field = Set(current_version + 1);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"process_definition.deprecated",
|
||||
tenant_id,
|
||||
serde_json::json!({ "definition_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"process_definition.deprecate",
|
||||
"process_definition",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 软删除流程定义。
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<()> {
|
||||
let model = process_definition::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
||||
|
||||
let current_version = model.version_field;
|
||||
let mut active: process_definition::ActiveModel = model.into();
|
||||
active.version_field = Set(current_version + 1);
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"process_definition.delete",
|
||||
"process_definition",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn model_to_resp(m: &process_definition::Model) -> ProcessDefinitionResp {
|
||||
ProcessDefinitionResp {
|
||||
id: m.id,
|
||||
name: m.name.clone(),
|
||||
key: m.key.clone(),
|
||||
version: m.version,
|
||||
category: m.category.clone(),
|
||||
description: m.description.clone(),
|
||||
nodes: m.nodes.clone(),
|
||||
edges: m.edges.clone(),
|
||||
status: m.status.clone(),
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
lock_version: m.version_field,
|
||||
}
|
||||
}
|
||||
}
|
||||
424
crates/erp-workflow/src/service/instance_service.rs
Normal file
424
crates/erp-workflow/src/service/instance_service.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
TransactionTrait,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{ProcessInstanceResp, StartInstanceReq, TokenResp};
|
||||
use crate::engine::executor::FlowExecutor;
|
||||
use crate::engine::parser;
|
||||
use crate::entity::{process_definition, process_instance, process_variable, token};
|
||||
use crate::error::{WorkflowError, WorkflowResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// 流程实例服务。
|
||||
pub struct InstanceService;
|
||||
|
||||
impl InstanceService {
|
||||
/// 启动流程实例。
|
||||
pub async fn start(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &StartInstanceReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> WorkflowResult<ProcessInstanceResp> {
|
||||
// 查找流程定义
|
||||
let definition = process_definition::Entity::find_by_id(req.definition_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| {
|
||||
WorkflowError::NotFound(format!("流程定义不存在: {}", req.definition_id))
|
||||
})?;
|
||||
|
||||
if definition.status != "published" {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"只能启动已发布的流程定义".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 解析流程图
|
||||
let nodes: Vec<crate::dto::NodeDef> = serde_json::from_value(definition.nodes.clone())
|
||||
.map_err(|e| WorkflowError::InvalidDiagram(format!("节点数据无效: {e}")))?;
|
||||
let edges: Vec<crate::dto::EdgeDef> = serde_json::from_value(definition.edges.clone())
|
||||
.map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?;
|
||||
let graph = parser::parse_and_validate(&nodes, &edges)?;
|
||||
|
||||
// 准备流程变量
|
||||
let mut variables = HashMap::new();
|
||||
if let Some(vars) = &req.variables {
|
||||
for v in vars {
|
||||
let _var_type = v.var_type.as_deref().unwrap_or("string");
|
||||
variables.insert(v.name.clone(), v.value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let instance_id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
// 在事务中创建实例、变量和 token
|
||||
let instance_id_clone = instance_id;
|
||||
let tenant_id_clone = tenant_id;
|
||||
let operator_id_clone = operator_id;
|
||||
let business_key = req.business_key.clone();
|
||||
let definition_id = definition.id;
|
||||
let definition_name = definition.name.clone();
|
||||
let vars_to_save = req.variables.clone();
|
||||
|
||||
db.transaction::<_, (), WorkflowError>(|txn| {
|
||||
let graph = graph.clone();
|
||||
let variables = variables.clone();
|
||||
Box::pin(async move {
|
||||
// 创建流程实例
|
||||
let instance = process_instance::ActiveModel {
|
||||
id: Set(instance_id_clone),
|
||||
tenant_id: Set(tenant_id_clone),
|
||||
definition_id: Set(definition_id),
|
||||
business_key: Set(business_key),
|
||||
status: Set("running".to_string()),
|
||||
started_by: Set(operator_id_clone),
|
||||
started_at: Set(now),
|
||||
completed_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(operator_id_clone),
|
||||
updated_by: Set(operator_id_clone),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
instance
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 保存初始变量
|
||||
if let Some(vars) = vars_to_save {
|
||||
for v in vars {
|
||||
Self::save_variable(
|
||||
instance_id_clone,
|
||||
tenant_id_clone,
|
||||
&v.name,
|
||||
v.var_type.as_deref().unwrap_or("string"),
|
||||
&v.value,
|
||||
txn,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动执行引擎
|
||||
FlowExecutor::start(instance_id_clone, tenant_id_clone, &graph, &variables, txn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"process_instance.started",
|
||||
tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({ "instance_id": instance_id, "definition_id": definition.id, "started_by": operator_id })),
|
||||
), db).await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"process_instance.start",
|
||||
"process_instance",
|
||||
)
|
||||
.with_resource_id(instance_id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 查询创建后的实例(包含 token)
|
||||
let instance = process_instance::Entity::find_by_id(instance_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {instance_id}")))?;
|
||||
|
||||
let active_tokens = Self::get_active_tokens(instance_id, db).await?;
|
||||
|
||||
Ok(ProcessInstanceResp {
|
||||
id: instance.id,
|
||||
definition_id: instance.definition_id,
|
||||
definition_name: Some(definition_name),
|
||||
business_key: instance.business_key,
|
||||
status: instance.status,
|
||||
started_by: instance.started_by,
|
||||
started_at: instance.started_at,
|
||||
completed_at: instance.completed_at,
|
||||
created_at: instance.created_at,
|
||||
active_tokens,
|
||||
version: instance.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// 分页查询流程实例。
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<(Vec<ProcessInstanceResp>, u64)> {
|
||||
let paginator = process_instance::Entity::find()
|
||||
.filter(process_instance::Column::TenantId.eq(tenant_id))
|
||||
.filter(process_instance::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let mut resps = Vec::new();
|
||||
for m in &models {
|
||||
let active_tokens = Self::get_active_tokens(m.id, db).await.unwrap_or_default();
|
||||
let def_name = process_definition::Entity::find_by_id(m.definition_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|d| d.name);
|
||||
resps.push(ProcessInstanceResp {
|
||||
id: m.id,
|
||||
definition_id: m.definition_id,
|
||||
definition_name: def_name,
|
||||
business_key: m.business_key.clone(),
|
||||
status: m.status.clone(),
|
||||
started_by: m.started_by,
|
||||
started_at: m.started_at,
|
||||
completed_at: m.completed_at,
|
||||
created_at: m.created_at,
|
||||
active_tokens,
|
||||
version: m.version,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// 获取单个流程实例详情。
|
||||
pub async fn get_by_id(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<ProcessInstanceResp> {
|
||||
let instance = process_instance::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {id}")))?;
|
||||
|
||||
let def_name = process_definition::Entity::find_by_id(instance.definition_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|d| d.name);
|
||||
|
||||
let active_tokens = Self::get_active_tokens(id, db).await?;
|
||||
|
||||
Ok(ProcessInstanceResp {
|
||||
id: instance.id,
|
||||
definition_id: instance.definition_id,
|
||||
definition_name: def_name,
|
||||
business_key: instance.business_key,
|
||||
status: instance.status,
|
||||
started_by: instance.started_by,
|
||||
started_at: instance.started_at,
|
||||
completed_at: instance.completed_at,
|
||||
created_at: instance.created_at,
|
||||
active_tokens,
|
||||
version: instance.version,
|
||||
})
|
||||
}
|
||||
|
||||
/// 挂起流程实例。
|
||||
pub async fn suspend(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<()> {
|
||||
Self::change_status(id, tenant_id, operator_id, "running", "suspended", db).await
|
||||
}
|
||||
|
||||
/// 终止流程实例。
|
||||
pub async fn terminate(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<()> {
|
||||
Self::change_status(id, tenant_id, operator_id, "running", "terminated", db).await
|
||||
}
|
||||
|
||||
/// 恢复已挂起的流程实例。
|
||||
pub async fn resume(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<()> {
|
||||
Self::change_status(id, tenant_id, operator_id, "suspended", "running", db).await
|
||||
}
|
||||
|
||||
async fn change_status(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
from_status: &str,
|
||||
to_status: &str,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<()> {
|
||||
let instance = process_instance::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {id}")))?;
|
||||
|
||||
if instance.status != from_status {
|
||||
return Err(WorkflowError::InvalidState(format!(
|
||||
"流程实例状态不是 {},无法变更为 {}",
|
||||
from_status, to_status
|
||||
)));
|
||||
}
|
||||
|
||||
let current_version = instance.version;
|
||||
let mut active: process_instance::ActiveModel = instance.into();
|
||||
active.status = Set(to_status.to_string());
|
||||
active.version = Set(current_version + 1);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 发布状态变更领域事件(通过 outbox 模式,由 relay 广播)
|
||||
let event_type = format!("process_instance.{}", to_status);
|
||||
let event_id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
let outbox_event = erp_core::entity::domain_event::ActiveModel {
|
||||
id: Set(event_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
event_type: Set(event_type),
|
||||
payload: Set(Some(erp_core::events::build_event_payload(
|
||||
serde_json::json!({ "instance_id": id, "changed_by": operator_id }),
|
||||
))),
|
||||
correlation_id: Set(Some(Uuid::now_v7())),
|
||||
status: Set("pending".to_string()),
|
||||
attempts: Set(0),
|
||||
last_error: Set(None),
|
||||
created_at: Set(now),
|
||||
published_at: Set(None),
|
||||
};
|
||||
match outbox_event.insert(db).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "领域事件持久化失败"),
|
||||
}
|
||||
|
||||
let action = format!("process_instance.{}", to_status);
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), action, "process_instance")
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取实例的活跃 token 列表。
|
||||
pub async fn get_active_tokens(
|
||||
instance_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<Vec<TokenResp>> {
|
||||
let tokens = token::Entity::find()
|
||||
.filter(token::Column::InstanceId.eq(instance_id))
|
||||
.filter(token::Column::Status.eq("active"))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(tokens
|
||||
.iter()
|
||||
.map(|t| TokenResp {
|
||||
id: t.id,
|
||||
node_id: t.node_id.clone(),
|
||||
status: t.status.clone(),
|
||||
created_at: t.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 保存流程变量。
|
||||
pub async fn save_variable(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
name: &str,
|
||||
var_type: &str,
|
||||
value: &serde_json::Value,
|
||||
txn: &impl ConnectionTrait,
|
||||
) -> WorkflowResult<()> {
|
||||
let id = Uuid::now_v7();
|
||||
|
||||
let (value_string, value_number, value_boolean, _value_date): (
|
||||
Option<String>,
|
||||
Option<f64>,
|
||||
Option<bool>,
|
||||
Option<chrono::DateTime<Utc>>,
|
||||
) = match var_type {
|
||||
"string" => (value.as_str().map(|s| s.to_string()), None, None, None),
|
||||
"number" => (None, value.as_f64(), None, None),
|
||||
"boolean" => (None, None, value.as_bool(), None),
|
||||
_ => (Some(value.to_string()), None, None, None),
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let system_user = uuid::Uuid::nil();
|
||||
|
||||
let model = process_variable::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
name: Set(name.to_string()),
|
||||
var_type: Set(var_type.to_string()),
|
||||
value_string: Set(value_string),
|
||||
value_number: Set(value_number),
|
||||
value_boolean: Set(value_boolean),
|
||||
value_date: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user),
|
||||
updated_by: Set(system_user),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
3
crates/erp-workflow/src/service/mod.rs
Normal file
3
crates/erp-workflow/src/service/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod definition_service;
|
||||
pub mod instance_service;
|
||||
pub mod task_service;
|
||||
445
crates/erp-workflow/src/service/task_service.rs
Normal file
445
crates/erp-workflow/src/service/task_service.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait,
|
||||
QueryFilter, Set, Statement, TransactionTrait,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CompleteTaskReq, DelegateTaskReq, TaskResp};
|
||||
use crate::engine::executor::FlowExecutor;
|
||||
use crate::engine::parser;
|
||||
use crate::entity::{process_definition, process_instance, task};
|
||||
use crate::error::{WorkflowError, WorkflowResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// 任务服务。
|
||||
pub struct TaskService;
|
||||
|
||||
impl TaskService {
|
||||
/// 查询当前用户的待办任务。
|
||||
pub async fn list_pending(
|
||||
tenant_id: Uuid,
|
||||
assignee_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<(Vec<TaskResp>, u64)> {
|
||||
let paginator = task::Entity::find()
|
||||
.filter(task::Column::TenantId.eq(tenant_id))
|
||||
.filter(task::Column::AssigneeId.eq(assignee_id))
|
||||
.filter(task::Column::Status.eq("pending"))
|
||||
.filter(task::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let mut resps = Vec::new();
|
||||
for m in &models {
|
||||
let mut resp = Self::model_to_resp(m);
|
||||
// 附加实例信息
|
||||
if let Some(inst) = process_instance::Entity::find_by_id(m.instance_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
resp.business_key = inst.business_key;
|
||||
if let Some(def) = process_definition::Entity::find_by_id(inst.definition_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
resp.definition_name = Some(def.name);
|
||||
}
|
||||
}
|
||||
resps.push(resp);
|
||||
}
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// 查询当前用户的已办任务。
|
||||
pub async fn list_completed(
|
||||
tenant_id: Uuid,
|
||||
assignee_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<(Vec<TaskResp>, u64)> {
|
||||
let paginator = task::Entity::find()
|
||||
.filter(task::Column::TenantId.eq(tenant_id))
|
||||
.filter(task::Column::AssigneeId.eq(assignee_id))
|
||||
.filter(task::Column::Status.is_in(["completed", "approved", "rejected", "delegated"]))
|
||||
.filter(task::Column::DeletedAt.is_null())
|
||||
.paginate(db, pagination.limit());
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let page_index = pagination.page.unwrap_or(1).saturating_sub(1);
|
||||
let models = paginator
|
||||
.fetch_page(page_index)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let mut resps = Vec::new();
|
||||
for m in &models {
|
||||
let mut resp = Self::model_to_resp(m);
|
||||
if let Some(inst) = process_instance::Entity::find_by_id(m.instance_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
resp.business_key = inst.business_key;
|
||||
if let Some(def) = process_definition::Entity::find_by_id(inst.definition_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
resp.definition_name = Some(def.name);
|
||||
}
|
||||
}
|
||||
resps.push(resp);
|
||||
}
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// 完成任务:更新任务状态 + 推进 token。
|
||||
pub async fn complete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CompleteTaskReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> WorkflowResult<TaskResp> {
|
||||
let task_model = task::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|t| t.tenant_id == tenant_id && t.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?;
|
||||
|
||||
if task_model.status != "pending" {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"任务状态不是 pending,无法完成".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证操作者是当前处理人
|
||||
if task_model.assignee_id != Some(operator_id) {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"只有当前处理人才能完成任务".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let instance_id = task_model.instance_id;
|
||||
let token_id = task_model.token_id;
|
||||
|
||||
// 获取流程定义和流程图
|
||||
let instance = process_instance::Entity::find_by_id(instance_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {instance_id}")))?;
|
||||
|
||||
let definition = process_definition::Entity::find_by_id(instance.definition_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||
.ok_or_else(|| {
|
||||
WorkflowError::NotFound(format!("流程定义不存在: {}", instance.definition_id))
|
||||
})?;
|
||||
|
||||
if instance.status != "running" {
|
||||
return Err(WorkflowError::InvalidState(format!(
|
||||
"流程实例状态不是 running: {}",
|
||||
instance.status
|
||||
)));
|
||||
}
|
||||
|
||||
let nodes: Vec<crate::dto::NodeDef> = serde_json::from_value(definition.nodes.clone())
|
||||
.map_err(|e| WorkflowError::InvalidDiagram(format!("节点数据无效: {e}")))?;
|
||||
let edges: Vec<crate::dto::EdgeDef> = serde_json::from_value(definition.edges.clone())
|
||||
.map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?;
|
||||
let graph = parser::parse_and_validate(&nodes, &edges)?;
|
||||
|
||||
// 准备变量(从 req.form_data 中提取)
|
||||
let mut variables = HashMap::new();
|
||||
if let Some(form) = &req.form_data
|
||||
&& let Some(obj) = form.as_object()
|
||||
{
|
||||
for (k, v) in obj {
|
||||
variables.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 在事务中更新任务 + 推进 token
|
||||
let now = Utc::now();
|
||||
let outcome = req.outcome.clone();
|
||||
let form_data = req.form_data.clone();
|
||||
db.transaction::<_, (), WorkflowError>(|txn| {
|
||||
let graph = graph.clone();
|
||||
let variables = variables.clone();
|
||||
let task_model = task_model.clone();
|
||||
Box::pin(async move {
|
||||
// 更新任务状态
|
||||
let current_version = task_model.version;
|
||||
let mut active: task::ActiveModel = task_model.clone().into();
|
||||
active.status = Set("completed".to_string());
|
||||
active.outcome = Set(Some(outcome));
|
||||
active.form_data = Set(form_data);
|
||||
active.completed_at = Set(Some(now));
|
||||
active.version = Set(current_version + 1);
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
// 推进 token
|
||||
FlowExecutor::advance(token_id, instance_id, tenant_id, &graph, &variables, txn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"task.completed",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"task_id": id,
|
||||
"instance_id": instance_id,
|
||||
"started_by": instance.started_by,
|
||||
"outcome": req.outcome,
|
||||
}),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "task.complete", "task")
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 重新查询任务
|
||||
let updated = task::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 委派任务给其他人。
|
||||
pub async fn delegate(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &DelegateTaskReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<TaskResp> {
|
||||
let task_model = task::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|t| t.tenant_id == tenant_id && t.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?;
|
||||
|
||||
if task_model.status != "pending" {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"任务状态不是 pending,无法委派".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证操作者是当前处理人
|
||||
if task_model.assignee_id != Some(operator_id) {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"只有当前处理人才能委派任务".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 验证目标用户属于同一租户(使用 raw SQL 避免跨模块依赖 erp-auth)
|
||||
let result = db.query_one(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL AND status = 'active') AS ok",
|
||||
[req.delegate_to.into(), tenant_id.into()],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
let target_ok = result
|
||||
.and_then(|r| r.try_get::<bool>("", "ok").ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !target_ok {
|
||||
return Err(WorkflowError::Validation(
|
||||
"委派目标用户不存在或不属于当前租户".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = task_model.version;
|
||||
let mut active: task::ActiveModel = task_model.into();
|
||||
active.assignee_id = Set(Some(req.delegate_to));
|
||||
active.version = Set(current_version + 1);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(operator_id), "task.delegate", "task")
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 创建任务记录(由执行引擎调用)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_task(
|
||||
instance_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
token_id: Uuid,
|
||||
node_id: &str,
|
||||
node_name: Option<&str>,
|
||||
assignee_id: Option<Uuid>,
|
||||
candidate_groups: Option<Vec<String>>,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<Uuid> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
let system_user = Uuid::nil();
|
||||
|
||||
let model = task::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
instance_id: Set(instance_id),
|
||||
token_id: Set(token_id),
|
||||
node_id: Set(node_id.to_string()),
|
||||
node_name: Set(node_name.map(|s| s.to_string())),
|
||||
assignee_id: Set(assignee_id),
|
||||
candidate_groups: Set(
|
||||
candidate_groups.map(|g| serde_json::to_value(g).unwrap_or_default())
|
||||
),
|
||||
status: Set("pending".to_string()),
|
||||
outcome: Set(None),
|
||||
form_data: Set(None),
|
||||
due_date: Set(None),
|
||||
completed_at: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user),
|
||||
updated_by: Set(system_user),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 认领任务:将 pending 状态的任务分配给当前用户。
|
||||
///
|
||||
/// 适用于 candidate_groups 群组任务池中的任务,用户主动认领后
|
||||
/// 任务状态变为 in_progress,assignee_id 设置为认领用户。
|
||||
pub async fn claim(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> WorkflowResult<TaskResp> {
|
||||
let task_model = task::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|t| t.tenant_id == tenant_id && t.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?;
|
||||
|
||||
if task_model.status != "pending" {
|
||||
return Err(WorkflowError::InvalidState(format!(
|
||||
"任务状态不是 pending(当前状态: {}),无法认领",
|
||||
task_model.status
|
||||
)));
|
||||
}
|
||||
|
||||
let current_version = task_model.version;
|
||||
let mut active: task::ActiveModel = task_model.into();
|
||||
active.assignee_id = Set(Some(user_id));
|
||||
active.status = Set("in_progress".to_string());
|
||||
active.version = Set(current_version + 1);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(user_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(user_id), "task.claim", "task").with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
fn model_to_resp(m: &task::Model) -> TaskResp {
|
||||
TaskResp {
|
||||
id: m.id,
|
||||
instance_id: m.instance_id,
|
||||
token_id: m.token_id,
|
||||
node_id: m.node_id.clone(),
|
||||
node_name: m.node_name.clone(),
|
||||
assignee_id: m.assignee_id,
|
||||
candidate_groups: m.candidate_groups.clone(),
|
||||
status: m.status.clone(),
|
||||
outcome: m.outcome.clone(),
|
||||
form_data: m.form_data.clone(),
|
||||
due_date: m.due_date,
|
||||
completed_at: m.completed_at,
|
||||
created_at: m.created_at,
|
||||
definition_name: None,
|
||||
business_key: None,
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/erp-workflow/src/workflow_state.rs
Normal file
11
crates/erp-workflow/src/workflow_state.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use erp_core::events::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
/// Workflow-specific state extracted from the server's AppState via `FromRef`.
|
||||
///
|
||||
/// Contains the database connection and event bus needed by workflow handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct WorkflowState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
}
|
||||
Reference in New Issue
Block a user