
Frappe Impl Workflow
- 58 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Implement Frappe document workflows and approval chains with state transitions, allowed roles, and conditions to avoid stuck documents.
About
Guides implementing document workflows and approval chains in Frappe using Workflow, Workflow State, and Workflow Action. A developer uses it when adding state-based transitions or multi-step approvals to a DocType.
- Implement document Workflows, states, and approval chains
- Covers transition rules, allowed roles, conditions, and apply_workflow
Frappe Impl Workflow by the numbers
- 58 all-time installs (skills.sh)
- Ranked #3,178 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-impl-workflowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Implement Frappe document workflows and approval chains with state transitions, allowed roles, and conditions to avoid stuck documents.
Files
Frappe Workflow Implementation
Step-by-step guide for implementing document workflows in Frappe. Covers design, setup, testing, and common approval chain patterns.
Quick Reference: Implementation Checklist
1. □ Design states and transitions on paper/diagram first
2. □ Create Workflow State records (master list)
3. □ Create Workflow Action Master records (Approve, Reject, etc.)
4. □ Create the Workflow DocType record
5. □ Add states with correct doc_status values
6. □ Add transitions with roles, actions, and conditions
7. □ Set allow_edit roles per state
8. □ Configure email notifications (optional)
9. □ Test every transition path with test users
10. □ Verify self-approval blocking works as expectedStep 1: Design Your Workflow
Before touching the UI, map out your workflow on paper.
Identify States
ALWAYS start by listing every distinct document stage:
Example — Purchase Order Approval:
Draft → Pending Review → Pending Approval → Approved → Submitted → CancelledMap DocStatus to States
For submittable DocTypes, ALWAYS assign doc_status correctly:
| Stage | doc_status | Meaning |
|---|---|---|
| All "in-progress" states | 0 | Document is Draft, editable |
| Final approved/active state | 1 | Document is Submitted, locked |
| Cancelled state | 2 | Document is Cancelled |
NEVER assign doc_status = 1 to intermediate approval states. A submitted document cannot return to draft. Once submitted, the only forward path is another submitted state or cancellation.
Map Transitions
For each state, define: What actions are possible? Who can perform them? Any conditions?
Draft →[Submit for Review / Creator]→ Pending Review
Pending Review →[Approve / Reviewer]→ Pending Approval
Pending Review →[Reject / Reviewer]→ Draft
Pending Approval →[Approve / Manager]→ Approved
Pending Approval →[Reject / Manager]→ Draft
Approved →[Submit / Manager]→ Submitted (doc_status=1)
Submitted →[Cancel / Manager]→ Cancelled (doc_status=2)Step 2: Create Prerequisite Records
2a. Create Workflow States
Navigate to Workflow State list or create via API:
# Create states with appropriate styles
states = [
{"workflow_state_name": "Draft", "style": ""},
{"workflow_state_name": "Pending Review", "style": "Primary"},
{"workflow_state_name": "Pending Approval", "style": "Warning"},
{"workflow_state_name": "Approved", "style": "Success"},
{"workflow_state_name": "Submitted", "style": "Info"},
{"workflow_state_name": "Rejected", "style": "Danger"},
{"workflow_state_name": "Cancelled", "style": "Inverse"},
]
for s in states:
if not frappe.db.exists("Workflow State", s["workflow_state_name"]):
frappe.get_doc({"doctype": "Workflow State", **s}).insert()Available styles: Primary, Success, Warning, Danger, Info, Inverse (or empty for default).
2b. Create Workflow Action Masters
actions = ["Submit for Review", "Approve", "Reject", "Send Back", "Cancel"]
for action in actions:
if not frappe.db.exists("Workflow Action Master", action):
frappe.get_doc({
"doctype": "Workflow Action Master",
"workflow_action_name": action
}).insert()Step 3: Create the Workflow
Via UI
Navigate to Setup > Workflow > New Workflow:
1. Set Workflow Name (e.g., "Purchase Order Approval") 2. Set Document Type (e.g., "Purchase Order") 3. Check Is Active 4. Add states in the States table 5. Add transitions in the Transitions table
Via Python
workflow = frappe.get_doc({
"doctype": "Workflow",
"workflow_name": "Purchase Order Approval",
"document_type": "Purchase Order",
"is_active": 1,
"send_email_alert": 1,
"states": [
{"state": "Draft", "doc_status": "0", "allow_edit": "Purchase User"},
{"state": "Pending Approval", "doc_status": "0", "allow_edit": "Purchase Manager"},
{"state": "Approved", "doc_status": "1", "allow_edit": "Purchase Manager"},
{"state": "Rejected", "doc_status": "0", "allow_edit": "Purchase User"},
{"state": "Cancelled", "doc_status": "2"},
],
"transitions": [
{
"state": "Draft",
"action": "Submit for Review",
"next_state": "Pending Approval",
"allowed": "Purchase User",
"allow_self_approval": 1,
},
{
"state": "Pending Approval",
"action": "Approve",
"next_state": "Approved",
"allowed": "Purchase Manager",
"allow_self_approval": 0,
},
{
"state": "Pending Approval",
"action": "Reject",
"next_state": "Rejected",
"allowed": "Purchase Manager",
},
{
"state": "Rejected",
"action": "Submit for Review",
"next_state": "Pending Approval",
"allowed": "Purchase User",
},
{
"state": "Approved",
"action": "Cancel",
"next_state": "Cancelled",
"allowed": "Purchase Manager",
},
],
})
workflow.insert()Step 4: Configure Advanced Features
Conditional Transitions
Add Python conditions to show transitions only when criteria are met:
# Only allow approval for orders above 50000 by Senior Manager
{
"state": "Pending Approval",
"action": "Approve",
"next_state": "Approved",
"allowed": "Senior Manager",
"condition": "doc.grand_total > 50000",
}
# Standard approval for orders up to 50000
{
"state": "Pending Approval",
"action": "Approve",
"next_state": "Approved",
"allowed": "Purchase Manager",
"condition": "doc.grand_total <= 50000",
}ALWAYS use doc.fieldname syntax in conditions (the document is exposed as a dict).
Available in conditions: frappe.db.get_value(), frappe.db.get_list(), frappe.session.user, frappe.utils.now_datetime(), frappe.utils.add_to_date(), frappe.utils.get_datetime().
Self-Approval Blocking
Set allow_self_approval = 0 on approval transitions. This means:
- The document owner (creator) CANNOT perform this action
- Administrator is ALWAYS exempt from this restriction
- Other users with the required role CAN perform the action
Email Notifications
1. Set send_email_alert = 1 on the Workflow 2. On each state row, set send_email = 1 (default) 3. Optionally link an Email Template via next_action_email_template 4. Add a custom message on the state row for inline notification text
Update Fields on State Change
Use update_field and update_value on state rows to automatically set document fields:
# Set approval_status when entering "Approved" state
{"state": "Approved", "doc_status": "1",
"update_field": "approval_status", "update_value": "Approved"}
# Use expression to set approval date dynamically
{"state": "Approved", "doc_status": "1",
"update_field": "custom_approved_on", "update_value": "frappe.utils.now()",
"evaluate_as_expression": 1}Step 5: Test Your Workflow
Manual Testing Checklist
1. Create a test document — verify it starts in the first state (Draft) 2. Check available actions — only roles with transitions from Draft should see buttons 3. Perform each transition — verify state changes correctly 4. Test rejection paths — verify documents return to correct state 5. Test self-approval — log in as document owner, verify blocked transitions 6. Test conditions — create documents that meet/fail conditions, verify button visibility 7. Test email notifications — verify emails sent on state changes 8. Test with non-submittable DocType — verify all doc_status = 0
Programmatic Testing
# Get available transitions for a document
from frappe.model.workflow import get_transitions, apply_workflow
doc = frappe.get_doc("Purchase Order", "PO-00001")
transitions = get_transitions(doc)
# Returns list of dicts with action, next_state, allowed, etc.
# Apply a workflow action
updated_doc = apply_workflow(doc, "Approve")
# Returns the updated document after state changeCommon Workflow Patterns
Pattern 1: Sequential Approval Chain
Draft → Level 1 Review → Level 2 Review → Approved → SubmittedEach level has its own role. Document moves linearly through approvals.
Pattern 2: Conditional Routing by Amount
Draft → Pending Approval
├─[amount <= 10000 / Team Lead]─→ Approved
├─[amount <= 50000 / Manager]──→ Approved
└─[amount > 50000 / Director]──→ ApprovedUse condition on each transition to route based on document values.
Pattern 3: Review with Rejection Loop
Draft ←──[Reject]── Pending Review ──[Approve]──→ Approved
└──[Submit for Review]──→ Pending ReviewRejected documents return to Draft for revision. Creator resubmits. This is the most common approval pattern.
Pattern 4: Leave Approval
Applied (doc_status=0, allow_edit=Employee)
└─[Approve / Leave Approver]─→ Approved (doc_status=1)
└─[Reject / Leave Approver]─→ Rejected (doc_status=0)
Approved
└─[Cancel / HR Manager]─→ Cancelled (doc_status=2)Pattern 5: Document Review (Non-Submittable)
For non-submittable DocTypes, ALL states MUST have doc_status = 0:
Draft → Under Review → Reviewed → Published
(all doc_status = 0)Migrating from Manual DocStatus to Workflow
If your DocType currently uses manual Submit/Cancel buttons and you want to add a workflow:
1. Map existing documents — The workflow engine auto-maps existing documents to states based on their docstatus when the workflow is created 2. Define a state for each docstatus — ALWAYS have at least one state per docstatus value your documents currently use 3. Test with existing data — Verify that existing submitted documents show the correct workflow state 4. Update list views — If using override_status, the workflow state replaces the Status column
NEVER activate a workflow without a state for docstatus=0. New documents would have no valid initial state.
Decision Tree
Starting a new workflow implementation?
│
├── What type of DocType?
│ ├── Submittable → can use doc_status 0, 1, 2
│ └── Non-submittable → ALL states must be doc_status = 0
│
├── How many approval levels?
│ ├── Single → Two-state: Draft → Approved
│ ├── Sequential → Chain: Draft → L1 → L2 → Approved
│ └── Conditional → Route by field values using conditions
│
├── Need self-approval blocking?
│ └── Set allow_self_approval = 0 on approval transitions
│
├── Need rejection/revision loop?
│ └── Add Reject transition back to Draft or previous state
│
├── Need email notifications?
│ ├── Enable send_email_alert on Workflow
│ └── Link Email Template on each state
│
└── Need automated field updates?
└── Use update_field + update_value on target stateTroubleshooting
| Problem | Cause | Solution |
|---|---|---|
| No action buttons visible | User lacks required role | Add role to user OR add transition for user's role |
| "Self approval is not allowed" | User is doc owner + allow_self_approval=0 | Have different user approve, or set flag to 1 |
| "Workflow State not set" | Document created before workflow activation | Run update_default_workflow_status or manually set state |
| Document stuck in state | No outgoing transition defined for current state + user role | Add missing transition |
| "Cannot cancel before submitting" | Transition goes from doc_status=0 to doc_status=2 | Add intermediate submitted state |
| Actions show for wrong users | Role assignment too broad | Use more specific roles or add conditions |
See Also
- Workflow Patterns — Detailed step-by-step workflow examples
- Decision Tree — Extended decision tree for workflow design
- Examples — Code examples for common scenarios
- Anti-Patterns — Mistakes to avoid
frappe-core-workflow— Workflow engine internals and API reference
Workflow Implementation Anti-Patterns
Anti-Pattern 1: Starting Implementation Without State Diagram
NEVER start building a workflow directly in the UI without designing the state machine first.
WRONG:
Open Workflow form → start adding states randomly → add transitions → debug
CORRECT:
1. Draw state diagram on paper/whiteboard
2. List all states with doc_status values
3. List all transitions with roles and conditions
4. Verify all paths have entry AND exit
5. Then implementWhy: Ad-hoc workflow design leads to dead-end states, missing transitions, and circular paths that trap documents.
Anti-Pattern 2: Using Submitted State for Intermediate Approvals
NEVER set doc_status = 1 on an intermediate approval state.
# WRONG — once submitted, document cannot return to draft
{"state": "L1 Approved", "doc_status": "1"}, # Too early!
{"state": "L2 Approved", "doc_status": "1"},
# If L2 rejects, document is stuck — cannot go back to draft
# CORRECT — keep intermediate states at doc_status 0
{"state": "L1 Approved", "doc_status": "0"},
{"state": "L2 Approved", "doc_status": "0"},
{"state": "Final Approved", "doc_status": "1"}, # Only final state is submittedAnti-Pattern 3: Missing Rejection Path
ALWAYS provide a rejection/send-back path from every approval state.
# WRONG — no way to reject after reaching Pending Approval
transitions = [
{"state": "Draft", "action": "Submit", "next_state": "Pending Approval", ...},
{"state": "Pending Approval", "action": "Approve", "next_state": "Approved", ...},
]
# If the approver wants to reject, they have no action available
# CORRECT — add rejection path
transitions = [
{"state": "Draft", "action": "Submit", "next_state": "Pending Approval", ...},
{"state": "Pending Approval", "action": "Approve", "next_state": "Approved", ...},
{"state": "Pending Approval", "action": "Reject", "next_state": "Draft", ...},
]Anti-Pattern 4: Overlapping Role-Based Conditions Without Clear Precedence
NEVER create transitions where the same role has multiple actions with overlapping conditions.
# WRONG — Manager sees both Approve options when amount is exactly 50000
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Manager", "condition": "doc.amount <= 50000"},
{"state": "Pending", "action": "Approve", "next_state": "Escalated",
"allowed": "Manager", "condition": "doc.amount >= 50000"},
# amount == 50000 matches BOTH — user sees duplicate buttons
# CORRECT — mutually exclusive conditions
{"condition": "doc.amount <= 50000"},
{"condition": "doc.amount > 50000"},Anti-Pattern 5: Testing Only the Happy Path
ALWAYS test rejection loops, self-approval blocking, edge-case conditions, and concurrent user scenarios.
WRONG: Test only: Draft → Approve → Submitted. Ship it.
CORRECT test plan:
□ Happy path: Draft → Pending → Approved → Submitted
□ Rejection loop: Draft → Pending → Rejected → Draft → Pending → Approved
□ Self-approval: Creator tries to approve their own doc (should fail)
□ Wrong role: User without approval role tries to approve (should see no button)
□ Condition boundaries: Test with exact boundary values
□ Concurrent: Two approvers act on same doc simultaneously
□ Existing documents: Check docs created before workflow activationAnti-Pattern 6: Not Setting allow_edit on States
ALWAYS set allow_edit to restrict who can modify documents in each state.
# WRONG — anyone can edit in any state
{"state": "Pending Approval", "doc_status": "0"}, # No allow_edit set
# CORRECT — only the approver can edit during their review
{"state": "Pending Approval", "doc_status": "0", "allow_edit": "Approver Role"},Why: Without allow_edit, any user with DocType write permission can modify the document while it awaits approval.
Anti-Pattern 7: Activating Workflow Without Checking Existing Documents
NEVER activate a workflow on a DocType with existing documents without verifying state mapping.
# When activated, the engine runs update_default_workflow_status:
# - Docs with docstatus=0 get mapped to first state with doc_status=0
# - Docs with docstatus=1 get mapped to first state with doc_status=1
# - Docs with docstatus=2 get mapped to first state with doc_status=2
# WRONG — workflow has no state with doc_status=1, but submitted POs exist
# Submitted POs get empty workflow_state → stuck
# CORRECT — ensure a state exists for every docstatus your existing docs haveAnti-Pattern 8: Using Workflow for Simple Status Tracking
NEVER use a Workflow when a simple Select field would suffice.
WRONG: Single-user status tracking (Open → In Progress → Done)
→ No approval, no role restrictions, no conditions
→ A workflow adds unnecessary overhead
CORRECT use cases for Workflow:
✓ Multi-user approval chains
✓ Role-based state transitions
✓ Conditional routing by document values
✓ Self-approval prevention
✓ Controlling when docstatus changesAnti-Pattern 9: Circular Workflows Without Exit
NEVER create cycles that have no terminal state reachable from the cycle.
# WRONG — infinite loop with no way to finish
{"state": "Review", "action": "Send Back", "next_state": "Revision"},
{"state": "Revision", "action": "Resubmit", "next_state": "Review"},
# No "Approve" transition → document loops forever
# CORRECT — cycle with exit
{"state": "Review", "action": "Send Back", "next_state": "Revision"},
{"state": "Review", "action": "Approve", "next_state": "Approved"}, # Exit!
{"state": "Revision", "action": "Resubmit", "next_state": "Review"},Anti-Pattern 10: Ignoring Email Template Setup
NEVER enable send_email_alert without configuring proper email templates.
# WRONG — emails enabled but no template
{"send_email_alert": 1}
# States have send_email=1 (default) but no next_action_email_template
# Users get generic/empty notifications
# CORRECT — configure templates
{"state": "Pending Approval", "send_email": 1,
"next_action_email_template": "Workflow Approval Request",
"message": "Document {{ doc.name }} requires your approval."}Workflow Implementation Decision Tree
Use this decision tree when designing a new Frappe workflow. Work through each decision point in order.
Decision 1: Do You Need a Workflow?
Does the document require...
├── Multi-user approval chain? → YES, use Workflow
├── Role-based state transitions? → YES, use Workflow
├── Conditional routing by field values? → YES, use Workflow
├── Self-approval prevention? → YES, use Workflow
├── Controlled docstatus transitions? → YES, use Workflow
├── Just status tracking (no approval)? → NO, use Select field
├── Just notifications (no state machine)? → NO, use Notification DocType
└── Just permission control (no states)? → NO, use Permission Level / User PermissionDecision 2: DocType Characteristics
Is the DocType submittable?
├── YES (has is_submittable = 1)
│ ├── States CAN use doc_status 0, 1, and 2
│ ├── MUST have at least one state with doc_status = 1 (for submission)
│ ├── doc_status transitions: 0→0, 0→1, 1→1, 1→2 (ONLY these are valid)
│ └── NEVER go backwards: 1→0 or 2→anything
│
└── NO (regular DocType)
├── ALL states MUST have doc_status = 0
├── States represent logical stages, not Frappe docstatus
└── Use update_field to set a status-like field if neededDecision 3: Approval Structure
How many approval levels?
│
├── Single approver
│ ├── States: Draft → Pending → Approved [→ Submitted → Cancelled]
│ ├── Simple, most common pattern
│ └── Add Rejected state for rejection loop
│
├── Sequential multi-level (L1 → L2 → L3)
│ ├── States: Draft → L1 → L2 → L3 → Approved [→ Submitted]
│ ├── ALL intermediate states: doc_status = 0
│ ├── ONLY final approval state: doc_status = 1
│ └── Rejection can go to any previous state or to "Rejected"
│
├── Conditional routing (amount/department-based)
│ ├── Single "Pending" state with multiple transitions
│ ├── Each transition has a condition expression
│ ├── MUST cover all cases (no gaps in conditions)
│ └── Conditions MUST be mutually exclusive (no overlaps)
│
└── Hybrid (conditional + sequential)
├── Route by condition to different approval paths
├── Each path may have its own approval chain
└── All paths converge to single "Approved" stateDecision 4: Self-Approval Policy
Should document creators be able to approve their own documents?
│
├── YES (default behavior)
│ └── Leave allow_self_approval = 1 (or omit — default is 1)
│
├── NO — for specific transitions
│ ├── Set allow_self_approval = 0 on approval transitions
│ ├── Creator will NOT see the Approve button on their own documents
│ ├── Administrator is ALWAYS exempt
│ └── Other users with the role CAN still approve
│
└── MIXED — different rules per level
├── L1: allow_self_approval = 0 (creator cannot approve)
├── L2: allow_self_approval = 1 (L2 reviewer may also be creator)
└── Set per transition rowDecision 5: Rejection Handling
What happens when a document is rejected?
│
├── Return to Draft (most common)
│ ├── Creator can edit and resubmit
│ └── Full revision cycle
│
├── Return to previous approval level
│ ├── L3 rejects → back to L2 (not all the way to Draft)
│ └── Useful for minor corrections at higher levels
│
├── Go to dedicated "Rejected" state
│ ├── Creator must explicitly "Revise" to return to Draft
│ ├── Rejection is logged as a distinct state
│ └── Useful for audit trails
│
└── Terminal rejection (document is dead)
├── No outgoing transitions from Rejected
├── Creator must create a new document
└── Use sparingly — most workflows need revision loopsDecision 6: Notification Strategy
How should users be notified?
│
├── Email on every state change
│ ├── Set send_email_alert = 1 on Workflow
│ ├── Set send_email = 1 on each state (default)
│ └── Link Email Template for customized messages
│
├── Email on specific states only
│ ├── Set send_email = 0 on states that should NOT notify
│ ├── Set send_email = 1 on states that SHOULD notify
│ └── Useful to avoid noise on internal transitions
│
├── No workflow emails (use separate Notification)
│ ├── Set send_email_alert = 0 on Workflow
│ └── Create Notification DocType records for custom logic
│
└── Combination
├── Workflow emails for approvers (via send_email on states)
└── Notification DocType for FYI recipientsDecision 7: Field Updates on State Change
Should fields auto-update when entering a state?
│
├── YES — static value
│ ├── Set update_field and update_value on state row
│ ├── Example: update_field = "approval_status", update_value = "Approved"
│ └── evaluate_as_expression = 0 (default)
│
├── YES — dynamic value (expression)
│ ├── Set evaluate_as_expression = 1
│ ├── update_value is Python expression
│ ├── Example: "frappe.session.user" or "frappe.utils.now()"
│ └── Available globals: frappe.db, frappe.session, frappe.utils, doc
│
├── YES — multiple fields
│ ├── State row only supports ONE update_field per state
│ ├── For multiple fields: use Server Script on workflow state change
│ └── Or use Workflow Transition Tasks (v15+)
│
└── NO — no auto-updates needed
└── Leave update_field emptyDecision 8: Testing Strategy
How to validate the workflow?
│
├── Manual testing (ALWAYS required)
│ ├── Create test users with each required role
│ ├── Test every transition path (happy + rejection)
│ ├── Test self-approval blocking
│ ├── Test condition boundaries
│ └── Test with existing documents (if migration)
│
├── Automated testing
│ ├── Use frappe.set_user() to simulate different users
│ ├── Use get_transitions() to verify available actions
│ ├── Use apply_workflow() to test state changes
│ ├── Assert on workflow_state after each action
│ └── ALWAYS call frappe.set_user("Administrator") after tests
│
└── Load testing (for high-volume workflows)
├── Use bulk_workflow_approval() for batch testing
├── Test with > 20 documents (background job path)
└── Monitor background job queue for failuresQuick Decision Matrix
| Scenario | Pattern | Key Settings |
|---|---|---|
| Simple approval | A (single approver) | 2-3 states, 1 approval transition |
| Financial approval | C (amount-based) | Conditions on transitions |
| HR/Leave | A + self-approval block | allow_self_approval = 0 |
| Multi-department | D (department-based) | Conditions with department field |
| Compliance/Audit | B (multi-level) | Sequential states, all blocking |
| Document review | Non-submittable pattern | All doc_status = 0 |
| Migration | Pattern E | Audit existing data first |
Workflow Implementation Examples
Example 1: Leave Approval Workflow
A standard HR leave approval pattern with employee submission, approver review, and HR cancellation capability.
State Design
| State | doc_status | allow_edit | Style |
|---|---|---|---|
| Applied | 0 | Employee | Primary |
| Approved | 1 | Leave Approver | Success |
| Rejected | 0 | Employee | Danger |
| Cancelled | 2 | — | Inverse |
Implementation
workflow = frappe.get_doc({
"doctype": "Workflow",
"workflow_name": "Leave Approval",
"document_type": "Leave Application",
"is_active": 1,
"send_email_alert": 1,
"states": [
{"state": "Applied", "doc_status": "0", "allow_edit": "Employee",
"send_email": 1, "message": "Your leave application has been submitted."},
{"state": "Approved", "doc_status": "1", "allow_edit": "Leave Approver",
"update_field": "status", "update_value": "Approved"},
{"state": "Rejected", "doc_status": "0", "allow_edit": "Employee",
"update_field": "status", "update_value": "Rejected"},
{"state": "Cancelled", "doc_status": "2"},
],
"transitions": [
{"state": "Applied", "action": "Approve", "next_state": "Approved",
"allowed": "Leave Approver", "allow_self_approval": 0},
{"state": "Applied", "action": "Reject", "next_state": "Rejected",
"allowed": "Leave Approver", "allow_self_approval": 0},
{"state": "Rejected", "action": "Submit for Review", "next_state": "Applied",
"allowed": "Employee"},
{"state": "Approved", "action": "Cancel", "next_state": "Cancelled",
"allowed": "HR Manager"},
],
})
workflow.insert()Example 2: Purchase Order Multi-Level Approval
Amount-based routing with two approval tiers.
State Design
Draft → Pending L1 Approval → Pending L2 Approval (if > 50000) → Approved → Cancelled
└→ Approved (if <= 50000)Implementation
workflow = frappe.get_doc({
"doctype": "Workflow",
"workflow_name": "PO Multi-Level Approval",
"document_type": "Purchase Order",
"is_active": 1,
"states": [
{"state": "Draft", "doc_status": "0", "allow_edit": "Purchase User"},
{"state": "Pending L1 Approval", "doc_status": "0", "allow_edit": "Purchase Manager"},
{"state": "Pending L2 Approval", "doc_status": "0", "allow_edit": "Director"},
{"state": "Approved", "doc_status": "1", "allow_edit": "Purchase Manager"},
{"state": "Rejected", "doc_status": "0", "allow_edit": "Purchase User"},
{"state": "Cancelled", "doc_status": "2"},
],
"transitions": [
{"state": "Draft", "action": "Submit for Review", "next_state": "Pending L1 Approval",
"allowed": "Purchase User"},
# L1 approves small orders directly
{"state": "Pending L1 Approval", "action": "Approve", "next_state": "Approved",
"allowed": "Purchase Manager", "allow_self_approval": 0,
"condition": "doc.grand_total <= 50000"},
# L1 escalates large orders to L2
{"state": "Pending L1 Approval", "action": "Escalate", "next_state": "Pending L2 Approval",
"allowed": "Purchase Manager",
"condition": "doc.grand_total > 50000"},
# L2 approves large orders
{"state": "Pending L2 Approval", "action": "Approve", "next_state": "Approved",
"allowed": "Director", "allow_self_approval": 0},
# Rejection at any level goes back to draft
{"state": "Pending L1 Approval", "action": "Reject", "next_state": "Rejected",
"allowed": "Purchase Manager"},
{"state": "Pending L2 Approval", "action": "Reject", "next_state": "Rejected",
"allowed": "Director"},
{"state": "Rejected", "action": "Revise", "next_state": "Draft",
"allowed": "Purchase User"},
{"state": "Approved", "action": "Cancel", "next_state": "Cancelled",
"allowed": "Purchase Manager"},
],
})
workflow.insert()Example 3: Document Review Workflow (Non-Submittable)
For non-submittable DocTypes like custom document management.
# ALL states MUST have doc_status = 0 for non-submittable DocTypes
workflow = frappe.get_doc({
"doctype": "Workflow",
"workflow_name": "Document Review",
"document_type": "Custom Document",
"is_active": 1,
"states": [
{"state": "Draft", "doc_status": "0", "allow_edit": "All"},
{"state": "Under Review", "doc_status": "0", "allow_edit": "Reviewer"},
{"state": "Changes Requested", "doc_status": "0", "allow_edit": "All"},
{"state": "Reviewed", "doc_status": "0", "allow_edit": "Reviewer"},
{"state": "Published", "doc_status": "0", "allow_edit": "System Manager"},
],
"transitions": [
{"state": "Draft", "action": "Submit for Review", "next_state": "Under Review",
"allowed": "All"},
{"state": "Under Review", "action": "Request Changes", "next_state": "Changes Requested",
"allowed": "Reviewer"},
{"state": "Under Review", "action": "Approve", "next_state": "Reviewed",
"allowed": "Reviewer", "allow_self_approval": 0},
{"state": "Changes Requested", "action": "Resubmit", "next_state": "Under Review",
"allowed": "All"},
{"state": "Reviewed", "action": "Publish", "next_state": "Published",
"allowed": "System Manager"},
],
})
workflow.insert()Example 4: Expense Claim with Department-Based Routing
transitions = [
# Finance department — approved by Finance Manager
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Finance Manager", "allow_self_approval": 0,
"condition": "doc.department == 'Finance'"},
# HR department — approved by HR Manager
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "HR Manager", "allow_self_approval": 0,
"condition": "doc.department == 'HR'"},
# All other departments — approved by Department Head
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Department Head", "allow_self_approval": 0,
"condition": "doc.department not in ('Finance', 'HR')"},
]Example 5: Programmatic Workflow Status Check
def get_pending_approvals(doctype, role):
"""Get all documents pending approval for a specific role."""
from frappe.model.workflow import get_workflow_name, get_workflow
workflow_name = get_workflow_name(doctype)
if not workflow_name:
return []
workflow = get_workflow(doctype)
# Find states that have outgoing transitions for this role
pending_states = set()
for t in workflow.transitions:
if t.allowed == role:
pending_states.add(t.state)
if not pending_states:
return []
return frappe.get_all(doctype,
filters={workflow.workflow_state_field: ["in", list(pending_states)]},
fields=["name", workflow.workflow_state_field, "owner", "creation"])Example 6: Workflow with Auto-Set Fields
states = [
{"state": "Draft", "doc_status": "0", "allow_edit": "Employee"},
{"state": "Approved", "doc_status": "1",
"update_field": "custom_approved_by", "update_value": "frappe.session.user",
"evaluate_as_expression": 1},
{"state": "Approved", "doc_status": "1",
"update_field": "custom_approval_date", "update_value": "frappe.utils.now()",
"evaluate_as_expression": 1},
]Note: Each state row can only update ONE field. To update multiple fields on the same state, use a Server Script triggered by workflow state change instead.
Step-by-Step Workflow Patterns
Detailed walkthroughs for common Frappe workflow implementations.
Pattern A: Simple Single-Approver Workflow
Use case: Document needs one person's approval before submission.
Step 1: Prerequisites
# Ensure states exist
for state in ["Draft", "Pending Approval", "Approved", "Rejected", "Cancelled"]:
if not frappe.db.exists("Workflow State", state):
frappe.get_doc({"doctype": "Workflow State",
"workflow_state_name": state}).insert()
# Ensure actions exist
for action in ["Submit for Approval", "Approve", "Reject", "Cancel"]:
if not frappe.db.exists("Workflow Action Master", action):
frappe.get_doc({"doctype": "Workflow Action Master",
"workflow_action_name": action}).insert()Step 2: Create Workflow
workflow = frappe.get_doc({
"doctype": "Workflow",
"workflow_name": "Simple Approval",
"document_type": "Purchase Order",
"is_active": 1,
"states": [
{"state": "Draft", "doc_status": "0", "allow_edit": "Purchase User"},
{"state": "Pending Approval", "doc_status": "0", "allow_edit": "Purchase Manager"},
{"state": "Approved", "doc_status": "1", "allow_edit": "Purchase Manager"},
{"state": "Rejected", "doc_status": "0", "allow_edit": "Purchase User"},
{"state": "Cancelled", "doc_status": "2"},
],
"transitions": [
{"state": "Draft", "action": "Submit for Approval",
"next_state": "Pending Approval", "allowed": "Purchase User"},
{"state": "Pending Approval", "action": "Approve",
"next_state": "Approved", "allowed": "Purchase Manager",
"allow_self_approval": 0},
{"state": "Pending Approval", "action": "Reject",
"next_state": "Rejected", "allowed": "Purchase Manager"},
{"state": "Rejected", "action": "Submit for Approval",
"next_state": "Pending Approval", "allowed": "Purchase User"},
{"state": "Approved", "action": "Cancel",
"next_state": "Cancelled", "allowed": "Purchase Manager"},
],
})
workflow.insert()Step 3: Verify
# Create test document
doc = frappe.get_doc({"doctype": "Purchase Order", "supplier": "Test Supplier"})
doc.insert()
assert doc.workflow_state == "Draft"
# Test transitions
from frappe.model.workflow import get_transitions
frappe.set_user("purchase_user@example.com")
transitions = get_transitions(doc)
assert len(transitions) == 1
assert transitions[0]["action"] == "Submit for Approval"
frappe.set_user("Administrator")---
Pattern B: Multi-Level Sequential Approval
Use case: Document passes through multiple approval levels before final submission.
State Flow
Draft → L1 Review → L2 Review → L3 Review → Final Approved (submitted)
↑ ↓ ↓ ↓
└── Rejected ←────────┴────────────┘Key Design Decisions
- ALL intermediate states have
doc_status = 0(draft) - ONLY "Final Approved" has
doc_status = 1(submitted) - ALL rejections return to "Rejected" state (not directly to Draft)
- From "Rejected", the creator revises and resubmits to L1
states = [
{"state": "Draft", "doc_status": "0", "allow_edit": "Creator Role"},
{"state": "L1 Review", "doc_status": "0", "allow_edit": "L1 Reviewer"},
{"state": "L2 Review", "doc_status": "0", "allow_edit": "L2 Reviewer"},
{"state": "L3 Review", "doc_status": "0", "allow_edit": "L3 Reviewer"},
{"state": "Final Approved", "doc_status": "1"},
{"state": "Rejected", "doc_status": "0", "allow_edit": "Creator Role"},
{"state": "Cancelled", "doc_status": "2"},
]
transitions = [
{"state": "Draft", "action": "Submit", "next_state": "L1 Review",
"allowed": "Creator Role"},
{"state": "L1 Review", "action": "Approve", "next_state": "L2 Review",
"allowed": "L1 Reviewer", "allow_self_approval": 0},
{"state": "L1 Review", "action": "Reject", "next_state": "Rejected",
"allowed": "L1 Reviewer"},
{"state": "L2 Review", "action": "Approve", "next_state": "L3 Review",
"allowed": "L2 Reviewer", "allow_self_approval": 0},
{"state": "L2 Review", "action": "Reject", "next_state": "Rejected",
"allowed": "L2 Reviewer"},
{"state": "L3 Review", "action": "Approve", "next_state": "Final Approved",
"allowed": "L3 Reviewer", "allow_self_approval": 0},
{"state": "L3 Review", "action": "Reject", "next_state": "Rejected",
"allowed": "L3 Reviewer"},
{"state": "Rejected", "action": "Revise", "next_state": "Draft",
"allowed": "Creator Role"},
{"state": "Final Approved", "action": "Cancel", "next_state": "Cancelled",
"allowed": "L3 Reviewer"},
]---
Pattern C: Conditional Amount-Based Routing
Use case: Low-value documents skip higher approval levels.
State Flow
Draft → Pending Approval
├─ amount <= 10K → [Team Lead approves] → Approved
├─ amount <= 100K → [Manager approves] → Approved
└─ amount > 100K → [Director approves] → ApprovedImplementation
transitions = [
{"state": "Draft", "action": "Submit", "next_state": "Pending Approval",
"allowed": "Employee"},
# Tier 1: Team Lead for small amounts
{"state": "Pending Approval", "action": "Approve", "next_state": "Approved",
"allowed": "Team Lead", "allow_self_approval": 0,
"condition": "doc.total_amount <= 10000"},
# Tier 2: Manager for medium amounts
{"state": "Pending Approval", "action": "Approve", "next_state": "Approved",
"allowed": "Department Manager", "allow_self_approval": 0,
"condition": "doc.total_amount > 10000 and doc.total_amount <= 100000"},
# Tier 3: Director for large amounts
{"state": "Pending Approval", "action": "Approve", "next_state": "Approved",
"allowed": "Director", "allow_self_approval": 0,
"condition": "doc.total_amount > 100000"},
# Universal reject
{"state": "Pending Approval", "action": "Reject", "next_state": "Rejected",
"allowed": "Team Lead"},
{"state": "Pending Approval", "action": "Reject", "next_state": "Rejected",
"allowed": "Department Manager"},
{"state": "Pending Approval", "action": "Reject", "next_state": "Rejected",
"allowed": "Director"},
]CRITICAL: Ensure conditions are mutually exclusive — no gaps, no overlaps.
---
Pattern D: Department-Based Routing
Use case: Different departments have different approvers.
transitions = [
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Finance Manager",
"condition": "doc.department == 'Finance'"},
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "HR Manager",
"condition": "doc.department == 'Human Resources'"},
{"state": "Pending", "action": "Approve", "next_state": "Approved",
"allowed": "Operations Manager",
"condition": "doc.department not in ('Finance', 'Human Resources')"},
]ALWAYS include a catch-all condition for departments not explicitly listed.
---
Pattern E: Workflow Migration for Existing DocType
Use case: Adding a workflow to a DocType that already has documents.
Step 1: Audit Existing Data
# Check current docstatus distribution
counts = frappe.db.sql("""
SELECT docstatus, COUNT(*) as cnt
FROM `tabPurchase Order`
GROUP BY docstatus
""", as_dict=True)
print(counts)
# Example: [{docstatus: 0, cnt: 50}, {docstatus: 1, cnt: 200}, {docstatus: 2, cnt: 30}]Step 2: Ensure State Coverage
Create states that cover ALL existing docstatus values:
states = [
{"state": "Draft", "doc_status": "0"}, # Maps to existing docstatus=0
{"state": "Approved", "doc_status": "1"}, # Maps to existing docstatus=1
{"state": "Cancelled", "doc_status": "2"}, # Maps to existing docstatus=2
]Step 3: Activate and Verify
# After workflow activation, verify mapping
unmapped = frappe.db.sql("""
SELECT name, docstatus
FROM `tabPurchase Order`
WHERE IFNULL(workflow_state, '') = ''
""", as_dict=True)
if unmapped:
print(f"WARNING: {len(unmapped)} documents without workflow state")Step 4: Handle Edge Cases
# Manually set state for documents that did not auto-map
for doc_name in unmapped:
frappe.db.set_value("Purchase Order", doc_name["name"],
"workflow_state", "Draft")