feat(workflow): add workflow engine module (Phase 4)

Implement complete workflow engine with BPMN subset support:

Backend (erp-workflow crate):
- Token-driven execution engine with exclusive/parallel gateway support
- BPMN parser with flow graph validation
- Expression evaluator for conditional branching
- Process definition CRUD with draft/publish lifecycle
- Process instance management (start, suspend, terminate)
- Task service (pending, complete, delegate)
- PostgreSQL advisory locks for concurrent safety
- 5 database tables: process_definitions, process_instances,
  tokens, tasks, process_variables
- 13 API endpoints with RBAC protection
- Timeout checker framework (placeholder)

Frontend:
- Workflow page with 4 tabs (definitions, pending, completed, monitor)
- React Flow visual process designer (@xyflow/react)
- Process viewer with active node highlighting
- 3 API client modules for workflow endpoints
- Sidebar menu integration
This commit is contained in:
iven
2026-04-11 09:54:02 +08:00
parent 0cbd08eb78
commit 91ecaa3ed7
51 changed files with 4826 additions and 12 deletions

View File

@@ -25,5 +25,6 @@ serde.workspace = true
erp-server-migration = { path = "migration" }
erp-auth.workspace = true
erp-config.workspace = true
erp-workflow.workspace = true
anyhow.workspace = true
uuid.workspace = true

View File

@@ -17,6 +17,11 @@ mod m20260412_000014_create_menus;
mod m20260412_000015_create_menu_roles;
mod m20260412_000016_create_settings;
mod m20260412_000017_create_numbering_rules;
mod m20260412_000018_create_process_definitions;
mod m20260412_000019_create_process_instances;
mod m20260412_000020_create_tokens;
mod m20260412_000021_create_tasks;
mod m20260412_000022_create_process_variables;
pub struct Migrator;
@@ -41,6 +46,11 @@ impl MigratorTrait for Migrator {
Box::new(m20260412_000015_create_menu_roles::Migration),
Box::new(m20260412_000016_create_settings::Migration),
Box::new(m20260412_000017_create_numbering_rules::Migration),
Box::new(m20260412_000018_create_process_definitions::Migration),
Box::new(m20260412_000019_create_process_instances::Migration),
Box::new(m20260412_000020_create_tokens::Migration),
Box::new(m20260412_000021_create_tasks::Migration),
Box::new(m20260412_000022_create_process_variables::Migration),
]
}
}

View File

@@ -0,0 +1,122 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ProcessDefinitions::Table)
.if_not_exists()
.col(
ColumnDef::new(ProcessDefinitions::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(ProcessDefinitions::TenantId).uuid().not_null())
.col(ColumnDef::new(ProcessDefinitions::Name).string().not_null())
.col(ColumnDef::new(ProcessDefinitions::Key).string().not_null())
.col(
ColumnDef::new(ProcessDefinitions::Version)
.integer()
.not_null()
.default(1),
)
.col(ColumnDef::new(ProcessDefinitions::Category).string().null())
.col(ColumnDef::new(ProcessDefinitions::Description).text().null())
.col(
ColumnDef::new(ProcessDefinitions::Nodes)
.json_binary()
.not_null()
.default(Expr::val("[]")),
)
.col(
ColumnDef::new(ProcessDefinitions::Edges)
.json_binary()
.not_null()
.default(Expr::val("[]")),
)
.col(
ColumnDef::new(ProcessDefinitions::Status)
.string()
.not_null()
.default("draft"),
)
.col(
ColumnDef::new(ProcessDefinitions::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(ProcessDefinitions::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(ProcessDefinitions::CreatedBy).uuid().not_null())
.col(ColumnDef::new(ProcessDefinitions::UpdatedBy).uuid().not_null())
.col(
ColumnDef::new(ProcessDefinitions::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(ProcessDefinitions::VersionField)
.integer()
.not_null()
.default(1),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_process_definitions_tenant_id")
.table(ProcessDefinitions::Table)
.col(ProcessDefinitions::TenantId)
.to_owned(),
)
.await?;
manager.get_connection().execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
"CREATE UNIQUE INDEX idx_process_definitions_key_version ON process_definitions (tenant_id, key, version) WHERE deleted_at IS NULL".to_string(),
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ProcessDefinitions::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ProcessDefinitions {
Table,
Id,
TenantId,
Name,
Key,
Version,
Category,
Description,
Nodes,
Edges,
Status,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
VersionField,
}

View File

@@ -0,0 +1,124 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ProcessInstances::Table)
.if_not_exists()
.col(
ColumnDef::new(ProcessInstances::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(ProcessInstances::TenantId).uuid().not_null())
.col(ColumnDef::new(ProcessInstances::DefinitionId).uuid().not_null())
.col(ColumnDef::new(ProcessInstances::BusinessKey).string().null())
.col(
ColumnDef::new(ProcessInstances::Status)
.string()
.not_null()
.default("running"),
)
.col(ColumnDef::new(ProcessInstances::StartedBy).uuid().not_null())
.col(
ColumnDef::new(ProcessInstances::StartedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(ProcessInstances::CompletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(ProcessInstances::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(ProcessInstances::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(ProcessInstances::CreatedBy).uuid().not_null())
.col(ColumnDef::new(ProcessInstances::UpdatedBy).uuid().not_null())
.col(
ColumnDef::new(ProcessInstances::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(ProcessInstances::Version)
.integer()
.not_null()
.default(1),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_instances_tenant_status")
.table(ProcessInstances::Table)
.col(ProcessInstances::TenantId)
.col(ProcessInstances::Status)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_instances_definition")
.from(ProcessInstances::Table, ProcessInstances::DefinitionId)
.to(ProcessDefinitions::Table, ProcessDefinitions::Id)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ProcessInstances::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ProcessInstances {
Table,
Id,
TenantId,
DefinitionId,
BusinessKey,
Status,
StartedBy,
StartedAt,
CompletedAt,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
#[derive(DeriveIden)]
enum ProcessDefinitions {
Table,
Id,
}

View File

@@ -0,0 +1,90 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Tokens::Table)
.if_not_exists()
.col(
ColumnDef::new(Tokens::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Tokens::TenantId).uuid().not_null())
.col(ColumnDef::new(Tokens::InstanceId).uuid().not_null())
.col(ColumnDef::new(Tokens::NodeId).string().not_null())
.col(
ColumnDef::new(Tokens::Status)
.string()
.not_null()
.default("active"),
)
.col(
ColumnDef::new(Tokens::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Tokens::ConsumedAt)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_tokens_instance")
.table(Tokens::Table)
.col(Tokens::InstanceId)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_tokens_instance")
.from(Tokens::Table, Tokens::InstanceId)
.to(ProcessInstances::Table, ProcessInstances::Id)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Tokens::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Tokens {
Table,
Id,
TenantId,
InstanceId,
NodeId,
Status,
CreatedAt,
ConsumedAt,
}
#[derive(DeriveIden)]
enum ProcessInstances {
Table,
Id,
}

View File

@@ -0,0 +1,160 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Tasks::Table)
.if_not_exists()
.col(
ColumnDef::new(Tasks::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Tasks::TenantId).uuid().not_null())
.col(ColumnDef::new(Tasks::InstanceId).uuid().not_null())
.col(ColumnDef::new(Tasks::TokenId).uuid().not_null())
.col(ColumnDef::new(Tasks::NodeId).string().not_null())
.col(ColumnDef::new(Tasks::NodeName).string().null())
.col(ColumnDef::new(Tasks::AssigneeId).uuid().null())
.col(ColumnDef::new(Tasks::CandidateGroups).json_binary().null())
.col(
ColumnDef::new(Tasks::Status)
.string()
.not_null()
.default("pending"),
)
.col(ColumnDef::new(Tasks::Outcome).string().null())
.col(ColumnDef::new(Tasks::FormData).json_binary().null())
.col(
ColumnDef::new(Tasks::DueDate)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(Tasks::CompletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(Tasks::CreatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(Tasks::UpdatedAt)
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(Tasks::CreatedBy).uuid().not_null())
.col(ColumnDef::new(Tasks::UpdatedBy).uuid().not_null())
.col(
ColumnDef::new(Tasks::DeletedAt)
.timestamp_with_time_zone()
.null(),
)
.col(
ColumnDef::new(Tasks::Version)
.integer()
.not_null()
.default(1),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_tasks_assignee")
.table(Tasks::Table)
.col(Tasks::TenantId)
.col(Tasks::AssigneeId)
.col(Tasks::Status)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_tasks_instance")
.table(Tasks::Table)
.col(Tasks::InstanceId)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_tasks_instance")
.from(Tasks::Table, Tasks::InstanceId)
.to(ProcessInstances::Table, ProcessInstances::Id)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_tasks_token")
.from(Tasks::Table, Tasks::TokenId)
.to(WfTokens::Table, WfTokens::Id)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Tasks::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Tasks {
Table,
Id,
TenantId,
InstanceId,
TokenId,
NodeId,
NodeName,
AssigneeId,
CandidateGroups,
Status,
Outcome,
FormData,
DueDate,
CompletedAt,
CreatedAt,
UpdatedAt,
CreatedBy,
UpdatedBy,
DeletedAt,
Version,
}
#[derive(DeriveIden)]
enum ProcessInstances {
Table,
Id,
}
#[derive(DeriveIden)]
enum WfTokens {
Table,
Id,
}

View File

@@ -0,0 +1,84 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ProcessVariables::Table)
.if_not_exists()
.col(
ColumnDef::new(ProcessVariables::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(ProcessVariables::TenantId).uuid().not_null())
.col(ColumnDef::new(ProcessVariables::InstanceId).uuid().not_null())
.col(ColumnDef::new(ProcessVariables::Name).string().not_null())
.col(
ColumnDef::new(ProcessVariables::VarType)
.string()
.not_null()
.default("string"),
)
.col(ColumnDef::new(ProcessVariables::ValueString).text().null())
.col(ColumnDef::new(ProcessVariables::ValueNumber).double().null())
.col(ColumnDef::new(ProcessVariables::ValueBoolean).boolean().null())
.col(
ColumnDef::new(ProcessVariables::ValueDate)
.timestamp_with_time_zone()
.null(),
)
.to_owned(),
)
.await?;
manager.get_connection().execute(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
"CREATE UNIQUE INDEX idx_process_variables_instance_name ON process_variables (instance_id, name)".to_string(),
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_variables_instance")
.from(ProcessVariables::Table, ProcessVariables::InstanceId)
.to(ProcessInstances::Table, ProcessInstances::Id)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ProcessVariables::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ProcessVariables {
Table,
Id,
TenantId,
InstanceId,
Name,
VarType,
ValueString,
ValueNumber,
ValueBoolean,
ValueDate,
}
#[derive(DeriveIden)]
enum ProcessInstances {
Table,
Id,
}

View File

@@ -105,10 +105,15 @@ async fn main() -> anyhow::Result<()> {
let config_module = erp_config::ConfigModule::new();
tracing::info!(module = config_module.name(), version = config_module.version(), "Config module initialized");
// Initialize workflow module
let workflow_module = erp_workflow::WorkflowModule::new();
tracing::info!(module = workflow_module.name(), version = workflow_module.version(), "Workflow module initialized");
// Initialize module registry and register modules
let registry = ModuleRegistry::new()
.register(auth_module)
.register(config_module);
.register(config_module)
.register(workflow_module);
tracing::info!(module_count = registry.modules().len(), "Modules registered");
// Register event handlers
@@ -146,6 +151,7 @@ async fn main() -> anyhow::Result<()> {
// Protected routes (JWT authentication required)
let protected_routes = erp_auth::AuthModule::protected_routes()
.merge(erp_config::ConfigModule::protected_routes())
.merge(erp_workflow::WorkflowModule::protected_routes())
.layer(middleware::from_fn(move |req, next| {
let secret = jwt_secret.clone();
async move { jwt_auth_middleware_fn(secret, req, next).await }

View File

@@ -60,3 +60,13 @@ impl FromRef<AppState> for erp_config::ConfigState {
}
}
}
/// Allow erp-workflow handlers to extract their required state without depending on erp-server.
impl FromRef<AppState> for erp_workflow::WorkflowState {
fn from_ref(state: &AppState) -> Self {
Self {
db: state.db.clone(),
event_bus: state.event_bus.clone(),
}
}
}