Multi-Level Approval Workflow
A complete example of a dynamic approval chain workflow.
Overview
This workflow demonstrates:
- Dynamic approval chain building based on business rules
- Loop pattern for sequential approvals
- Early exit on rejection
- Escalation to next level on approval
Workflow Diagram
Complete Implementation
dart
import 'package:vyuh_workflow_engine/vyuh_workflow_engine.dart';
final multiLevelApprovalWorkflow = WorkflowBuilder(
'MLA',
'Multi-Level Approval',
description: 'Dynamic approval chain based on amount thresholds',
)
.start('begin')
// Step 1: Build approval chain based on business rules
.task('buildApprovalChain',
execute: (ctx) async {
// Use getInitial<T> for original workflow input
final amount = ctx.getInitialRequired<num>('amount').toDouble();
final category = ctx.getInitialRequired<String>('category');
final entityId = ctx.getInitialRequired<String>('entityId');
final submittedBy = ctx.getInitialRequired<String>('submittedBy');
// Fetch entity details
final entity = await entityService.getById(entityId);
// Build approval chain based on amount thresholds
List<Map<String, dynamic>> chain = [];
// Level 1: Supervisor (always required)
chain.add({
'level': 1,
'role': 'supervisor',
'name': 'Supervisor',
'threshold': 0,
});
// Level 2: Manager (> $5,000)
if (amount > 5000) {
chain.add({
'level': 2,
'role': 'manager',
'name': 'Manager',
'threshold': 5000,
});
}
// Level 3: Director (> $25,000)
if (amount > 25000) {
chain.add({
'level': 3,
'role': 'director',
'name': 'Director',
'threshold': 25000,
});
}
// Level 4: VP (> $100,000)
if (amount > 100000) {
chain.add({
'level': 4,
'role': 'vp',
'name': 'Vice President',
'threshold': 100000,
});
}
// Level 5: CFO (> $500,000)
if (amount > 500000) {
chain.add({
'level': 5,
'role': 'cfo',
'name': 'CFO',
'threshold': 500000,
});
}
// Return Map - builder wraps in TaskSuccess
return {
'entityId': entityId,
'entityName': 'Sample Entity', // entity.name
'amount': amount,
'category': category,
'submittedBy': submittedBy,
'submittedAt': DateTime.now().toIso8601String(),
'approvalChain': chain,
'currentLevelIndex': 0,
'totalLevels': chain.length,
'hasMoreLevels': chain.isNotEmpty,
'approvalHistory': <Map<String, dynamic>>[],
};
},
)
// Step 2: Gateway - Check if more levels to process
.oneOf('checkHasMoreLevels', [
Branch.whenFn(
(o) => o['hasMoreLevels'] == true,
then: 'startLevel',
),
Branch.otherwise(then: 'makeEffective'),
])
// Step 3: Prepare current approval level
.task('startLevel',
execute: (ctx) async {
// Use getAny<T> for accumulated output (chain built earlier, index updated in loop)
final chain = ctx.getAny<List>('approvalChain')!;
final levelIndex = ctx.getAny<int>('currentLevelIndex')!;
final currentLevel = chain[levelIndex] as Map<String, dynamic>;
return {
'currentLevel': currentLevel,
'currentLevelNumber': currentLevel['level'],
'currentLevelName': currentLevel['name'],
'currentLevelRole': currentLevel['role'],
'levelStartedAt': DateTime.now().toIso8601String(),
};
},
)
// Step 4: User task for current level approval
.userTask('levelApproval',
signal: 'level_approval_decision',
schemaType: 'userTask.approval',
title: 'Level {{currentLevelNumber}} Approval: {{entityName}}',
description: 'Please review this request for {{entityName}}. Amount: \${{amount}}. This is level {{currentLevelNumber}} of {{totalLevels}}.',
assignToRole: '{{currentLevelRole}}',
storeAs: 'levelDecision',
)
// Step 5: Route based on level decision
.oneOf('routeLevelDecision', [
Branch.whenFn(
(o) => o['levelDecision']?['decision'] == 'approved',
then: 'recordApprovalAndIncrement',
),
Branch.whenFn(
(o) => o['levelDecision']?['decision'] == 'rejected',
then: 'handleRejection',
),
Branch.whenFn(
(o) => o['levelDecision']?['decision'] == 'revision',
then: 'requestRevision',
),
Branch.otherwise(then: 'handleRejection'),
])
// Step 6a: Record approval and increment level
.task('recordApprovalAndIncrement',
execute: (ctx) async {
// Use getAny<T> for accumulated output (from loop iterations)
final currentIndex = ctx.getAny<int>('currentLevelIndex')!;
final totalLevels = ctx.getAny<int>('totalLevels')!;
// Use get<T> for previous node output (user task result)
final decision = ctx.get<Map<String, dynamic>>('levelDecision')!;
final history = List<Map<String, dynamic>>.from(
ctx.getAny<List>('approvalHistory') ?? [],
);
// Record this approval
history.add({
'level': ctx.getAny<int>('currentLevelNumber'),
'levelName': ctx.getAny<String>('currentLevelName'),
'decision': 'approved',
'approvedBy': decision['completedBy'],
'approvedAt': DateTime.now().toIso8601String(),
'comments': decision['comments'],
});
// Calculate next level
final nextIndex = currentIndex + 1;
final hasMore = nextIndex < totalLevels;
return {
'currentLevelIndex': nextIndex,
'hasMoreLevels': hasMore,
'approvalHistory': history,
};
},
)
// Step 6b: Handle rejection
.task('handleRejection',
execute: (ctx) async {
// Use get<T> for previous node output (user task result)
final decision = ctx.get<Map<String, dynamic>>('levelDecision')!;
final history = List<Map<String, dynamic>>.from(
ctx.getAny<List>('approvalHistory') ?? [],
);
// Record rejection
history.add({
'level': ctx.getAny<int>('currentLevelNumber'),
'levelName': ctx.getAny<String>('currentLevelName'),
'decision': 'rejected',
'rejectedBy': decision['completedBy'],
'rejectedAt': DateTime.now().toIso8601String(),
'reason': decision['reason'],
});
// Update entity status (simulated)
// await entityService.updateStatus(entityId, 'rejected');
return {
'rejectedBy': decision['completedBy'],
'rejectedAt': DateTime.now().toIso8601String(),
'rejectedAtLevel': ctx.getAny<int>('currentLevelNumber'),
'reason': decision['reason'],
'approvalHistory': history,
};
},
)
// Step 6c: Request revision
.task('requestRevision',
execute: (ctx) async {
// Use get<T> for previous node output (user task result)
final decision = ctx.get<Map<String, dynamic>>('levelDecision')!;
// Send notification (simulated)
// await notificationService.send(...);
return {
'revisionRequested': true,
'requestedBy': decision['completedBy'],
'feedback': decision['feedback'],
};
},
)
// Step 6c continued: Wait for submitter to revise
.userTask('revisionTask',
signal: 'revision_submitted',
schemaType: 'userTask.revision',
title: 'Revision Required: {{entityName}}',
description: 'Revision has been requested for {{entityName}}. Please make the necessary changes and resubmit.',
assignToUser: '{{submittedBy}}',
storeAs: 'revisionResult',
)
// After revision, restart from first level
.task('resetAfterRevision',
execute: (ctx) async {
// Use getAny<T> for accumulated output
final revisionCount = ctx.getAny<int>('revisionCount') ?? 0;
return {
'currentLevelIndex': 0,
'hasMoreLevels': true,
'approvalHistory': <Map<String, dynamic>>[],
'revisionCount': revisionCount + 1,
};
},
)
// Step 7: Make effective after all approvals
.task('makeEffective',
execute: (ctx) async {
// Update entity status (simulated)
// await entityService.updateStatus(entityId, 'effective');
return {
'effectiveAt': DateTime.now().toIso8601String(),
'finalStatus': 'effective',
};
},
)
// End nodes
.end('approved', name: 'Approved')
.end('rejected', name: 'Rejected')
// Flow edges
.connect('begin', 'buildApprovalChain')
.connect('buildApprovalChain', 'checkHasMoreLevels')
.connect('startLevel', 'levelApproval')
.connect('levelApproval', 'routeLevelDecision')
.connect('recordApprovalAndIncrement', 'checkHasMoreLevels') // Loop back
.connect('handleRejection', 'rejected')
.connect('requestRevision', 'revisionTask')
.connect('revisionTask', 'resetAfterRevision')
.connect('resetAfterRevision', 'checkHasMoreLevels') // Restart approval
.connect('makeEffective', 'approved')
.build();Approval Chain Configuration
Customize the approval chain logic:
dart
class ApprovalChainBuilder {
static List<Map<String, dynamic>> buildChain({
required double amount,
required String category,
required String department,
}) {
final chain = <Map<String, dynamic>>[];
// Base configuration
final thresholds = _getThresholds(category, department);
for (final threshold in thresholds) {
if (amount > threshold['minAmount']) {
chain.add({
'level': chain.length + 1,
'role': threshold['role'],
'name': threshold['name'],
'threshold': threshold['minAmount'],
});
}
}
return chain;
}
static List<Map<String, dynamic>> _getThresholds(
String category,
String department,
) {
// Different thresholds per category
if (category == 'CAPITAL_EXPENSE') {
return [
{'minAmount': 0, 'role': 'supervisor', 'name': 'Supervisor'},
{'minAmount': 10000, 'role': 'manager', 'name': 'Manager'},
{'minAmount': 50000, 'role': 'director', 'name': 'Director'},
{'minAmount': 200000, 'role': 'vp', 'name': 'VP'},
{'minAmount': 1000000, 'role': 'cfo', 'name': 'CFO'},
];
} else {
return [
{'minAmount': 0, 'role': 'supervisor', 'name': 'Supervisor'},
{'minAmount': 5000, 'role': 'manager', 'name': 'Manager'},
{'minAmount': 25000, 'role': 'director', 'name': 'Director'},
];
}
}
}Tracking Approval Progress
dart
// Query workflow for approval status
final instance = await engine.getWorkflowInstance(instanceId);
final output = instance.output;
print('Entity: ${output['entityName']}');
print('Amount: \$${output['amount']}');
print('Current Level: ${output['currentLevelIndex'] + 1} of ${output['totalLevels']}');
print('Approval History:');
for (final approval in output['approvalHistory']) {
print(' Level ${approval['level']} (${approval['levelName']}): '
'${approval['decision']} by ${approval['approvedBy'] ?? approval['rejectedBy']}');
}Testing
dart
group('Multi-Level Approval', () {
test('small amount requires only supervisor approval', () async {
final instance = await engine.startWorkflow(
workflowId: 'multi-level-approval',
input: {
'entityId': 'PO-001',
'amount': 1000, // Below $5,000
'category': 'OFFICE_SUPPLIES',
'submittedBy': '[email protected]',
},
);
// Only 1 level in chain
expect(instance.output['totalLevels'], 1);
// Complete supervisor approval
await completeApproval(instance.id, 'supervisor', 'approved');
final completed = await engine.getWorkflowInstance(instance.id);
expect(completed.status, WorkflowStatus.completed);
expect(completed.output['finalStatus'], 'effective');
});
test('large amount requires multiple approvals', () async {
final instance = await engine.startWorkflow(
workflowId: 'multi-level-approval',
input: {
'entityId': 'PO-002',
'amount': 150000, // > $100,000
'category': 'EQUIPMENT',
'submittedBy': '[email protected]',
},
);
// 4 levels required: supervisor, manager, director, VP
expect(instance.output['totalLevels'], 4);
// Complete each level
await completeApproval(instance.id, 'supervisor', 'approved');
await completeApproval(instance.id, 'manager', 'approved');
await completeApproval(instance.id, 'director', 'approved');
await completeApproval(instance.id, 'vp', 'approved');
final completed = await engine.getWorkflowInstance(instance.id);
expect(completed.status, WorkflowStatus.completed);
expect(completed.output['approvalHistory'].length, 4);
});
test('rejection at any level stops workflow', () async {
final instance = await engine.startWorkflow(
workflowId: 'multi-level-approval',
input: {
'entityId': 'PO-003',
'amount': 30000,
'category': 'EQUIPMENT',
'submittedBy': '[email protected]',
},
);
// Approve first level
await completeApproval(instance.id, 'supervisor', 'approved');
// Reject at second level
await completeApproval(instance.id, 'manager', 'rejected',
reason: 'Budget exceeded for this quarter',
);
final completed = await engine.getWorkflowInstance(instance.id);
expect(completed.status, WorkflowStatus.completed);
expect(completed.output['rejectedAtLevel'], 2);
});
});
Future<void> completeApproval(
String instanceId,
String role,
String decision, {
String? reason,
}) async {
final tasks = await engine.getUserTasks(
workflowInstanceId: instanceId,
assignedToRole: role,
status: UserTaskStatus.pending,
);
await engine.completeUserTask(
taskId: tasks.first.id,
completedBy: '$role@company.com',
response: {
'decision': decision,
if (reason != null) 'reason': reason,
},
);
}Next Steps
- Parallel Tasks - Concurrent execution
- Loop Patterns - Loop implementation details
- Approval Workflows - More approval patterns