Claude
Skills
Sign in
Back

workflow-patterns

Included with Lifetime
$97 forever

Frappe Workflow design — multi-state document lifecycles, role-based transitions, state-scoped field permissions, workflow handlers, and the team's pragmatic deviations (Custom Field for workflow_state, db_set over apply_workflow, custom workflow DocTypes). Use when designing or modifying an approval flow, a state machine on a DocType, or anything involving Workflow / Workflow State / Workflow Transition.

Design

What this skill does


# Frappe Workflow Patterns

Reference for designing Frappe workflows, aligned with the team's edu_quality conventions: `workflow_state` added via Custom Field in the customizations sub-app (not source DocType edits), pragmatic use of `db_set` over `apply_workflow` for programmatic transitions, and willingness to build a custom DocType + Page when stock Workflow doesn't fit.

## Team conventions

These are the rules the existing codebase follows. New workflows should follow them; deviations need a reason.

1. **Add `workflow_state` via Custom Field** in `sc_customizations` (or equivalent customizations sub-app), not by editing the source DocType JSON. Keeps customizations isolated from upstream apps.
2. **`Custom Field` JSON for `workflow_state`** uses these settings: `fieldtype: "Link"`, `options: "Workflow State"`, `default: "Approved"` (or your default state), `hidden: 1`, `no_copy: 1`, `allow_on_submit: 1`. See the example in `sc_customizations/custom_field/Fees-workflow_state.json`.
3. **Use `db_set("workflow_state", "X")` for programmatic transitions** in most code paths. Pragmatic — bypasses transition validation, runs no `condition`, fires no email — but matches what the codebase does today (e.g. `refund_request.py:208`, `scan_receipts.py:17`). Use `apply_workflow` only when you specifically want the role check + condition + notification.
4. **Always wrap `db_set` calls with `hasattr(doc, "workflow_state")`** as a defensive check. The Custom Field may not be applied in every site; the code should still work.
5. **When stock Workflow doesn't fit, build a custom workflow DocType** plus a Frappe Page for the UI. We have precedent: `Funnel Workflow` for student application funnel, `fee_workflow` Page for fee operations. Don't try to bend Workflow to fit funnels or multi-doc orchestration.

## When to use a stock Workflow

A stock Frappe Workflow is the right tool when **all** of these are true:
- The document moves through a fixed sequence of named states (Draft → Pending → Approved → ...).
- Different roles can move it between specific pairs of states.
- The "current state" is meaningful in the UI and reports.

Use a plain `Select` status field instead when:
- States are advisory rather than gating.
- Transitions don't need permission checks.
- You don't care who moved it from one state to another.

Build a **custom workflow DocType + Page** when:
- The workflow involves multiple linked documents (a funnel through Application → Enrollment → Fee Setup → Payment).
- The state transitions need a richer UI than the standard "Action" buttons (drag-and-drop, multi-select bulk actions, custom validation per stage).
- You need to track state changes as their own queryable records.

Don't use a Workflow alongside `is_submittable: 1` unless you understand both will run — the workflow controls visible state, but Frappe still tracks `docstatus` (0 / 1 / 2) underneath.

## Adding workflow_state via Custom Field

Create a Custom Field JSON in the customizations sub-app:

```json
{
    "doctype": "Custom Field",
    "name": "Fees-workflow_state",
    "dt": "Fees",
    "fieldname": "workflow_state",
    "label": "Workflow State",
    "fieldtype": "Link",
    "options": "Workflow State",
    "default": "Approved",
    "hidden": 1,
    "no_copy": 1,
    "allow_on_submit": 1,
    "module": "Sc Customizations",
    "creation": "2023-10-03 17:26:45.823538",
    "modified": "2023-10-03 07:22:09.967376",
    "modified_by": "Administrator"
}
```

File location: `sc_customizations/custom_field/{DocType}-workflow_state.json`. Register via fixtures in the customizations sub-app's `hooks.py`:

```python
fixtures = [
    {"dt": "Custom Field", "filters": [["module", "=", "Sc Customizations"]]}
]
```

**Why hidden by default:** the workflow state is shown by the workflow timeline UI; the field itself doesn't need to be visible on the form.

**Why `no_copy: 1`:** a copied document should start at the default state, not inherit the source's state.

**Why `allow_on_submit: 1`:** the field can be updated on submitted docs (otherwise approval transitions on a submitted doc would fail).

## Workflow anatomy

Workflows themselves are DocTypes — `Workflow`, `Workflow State`, `Workflow Action Master`, `Workflow Transition`. The Workflow doc lives in the database (created via the desk), exported with:

```bash
bench --site <site> export-fixtures
```

A workflow has these key fields:

| Field | What it does |
|-------|--------------|
| `document_type` | The DocType this workflow applies to |
| `workflow_state_field` | Fieldname holding the current state — the team uses `workflow_state` consistently |
| `is_active` | `1` to enable. Only one active workflow per DocType. |
| `send_email_alert` | `1` sends notifications to the next actor on transition |
| `states` | Child table of `Workflow Document State` |
| `transitions` | Child table of `Workflow Transition` |

### States — `Workflow Document State`

```json
{
  "state": "Pending Approval",
  "doc_status": "0",
  "allow_edit": "Manager",
  "update_field": "status",
  "update_value": "Pending"
}
```

| Field | Use |
|-------|-----|
| `state` | Name of the state — must match a `Workflow State` row (define those first) |
| `doc_status` | `"0"` (draft), `"1"` (submitted), `"2"` (cancelled) |
| `allow_edit` | Role allowed to edit fields while in this state. Other roles see read-only. |
| `update_field` / `update_value` | Optional: when the doc enters this state, set this field on the doc to this value. Useful for keeping a denormalized `status` field in sync with the workflow. |

### Transitions — `Workflow Transition`

```json
{
  "state": "Draft",
  "action": "Submit for Approval",
  "next_state": "Pending Approval",
  "allowed": "Employee",
  "condition": "doc.amount > 0",
  "allow_self_approval": 0
}
```

| Field | Use |
|-------|-----|
| `state` | Source state |
| `action` | Button label the user clicks |
| `next_state` | Target state |
| `allowed` | Role(s) that can perform this transition. Multiple roles → comma-separated. |
| `condition` | Optional Python expression evaluated with `doc` in scope. Empty = always allowed. |
| `allow_self_approval` | If `0`, blocks the user who created/last-edited the doc from performing this transition. **Always set to 0 on approval transitions.** |

`condition` runs in a restricted context. Keep it simple: `doc.amount > 1000`, `doc.school == "Pune"`. Function calls are limited; complex logic belongs in `validate()`.

## State design rules

1. **Terminal states are idempotent.** "Approved" and "Rejected" should have no outgoing transitions.
2. **Sequential states should be linearly ordered in the `states[]` table.** The order is what the timeline UI shows.
3. **Avoid more than 7 states** — past that, users get lost. Split into two workflows on related DocTypes if needed.
4. **Match `doc_status` to lifecycle.** Submitted (`"1"`) means the doc is locked from edits except `on_update_after_submit`. Cancelled (`"2"`) means archived.
5. **Default state must be reachable on insert.** Frappe sets `workflow_state` to the first state in the table on new docs. The Custom Field `default` should match.

## Programmatic transitions: `db_set` vs `apply_workflow`

There are two ways to move a doc between states programmatically. The team predominantly uses `db_set`.

### `db_set` (team default)

```python
# refund_request.py — pattern from edu_quality
bank_account.save(ignore_permissions=True)
if hasattr(bank_account, "workflow_state"):
    bank_account.db_set("workflow_state", "Approved")
```

```python
# scan_receipts.py — pattern from edu_quality
receipt_doc.workflow_state = "Received"
receipt_doc.save(ignore_permissions=True)
```

What this does:
- Sets the field directly in the DB (or in memory + on next save).
- Skips workflow validation: no `condition` check, no role check, no notification.
- Doesn't fire `update_field` / `update_value` from the state definition.
- Fast, predictable, but bypasses the audit t

Related in Design