$npx -y skills add Impertio-Studio/Frappe_Claude_Skill_Package --skill frappe-impl-controllersUse when building Document Controllers in a custom Frappe app: file creation, lifecycle hooks, validation, autoname, submittable workflows, controller override, child table controllers, flags system, migration from hooks.py and Server Scripts. Keywords: how to implement controlle
| 1 | # Document Controllers — Implementation Workflows |
| 2 | |
| 3 | Step-by-step workflows for building server-side DocType logic with full Python power. For exact syntax, see `frappe-syntax-controllers`. |
| 4 | |
| 5 | **Version**: v14/v15/v16 | **v15+**: Supports auto-generated type annotations |
| 6 | |
| 7 | ## Quick Decision: Controller vs Server Script? |
| 8 | |
| 9 | ``` |
| 10 | NEED full Python (imports, classes, generators)? → Controller |
| 11 | NEED external libraries (requests, pandas)? → Controller |
| 12 | NEED try/except with rollback? → Controller |
| 13 | NEED frappe.enqueue() for background jobs? → Controller |
| 14 | NEED to extend standard ERPNext DocType? → Controller |
| 15 | Quick validation without custom app? → Server Script |
| 16 | Simple auto-fill or notification? → Server Script |
| 17 | ``` |
| 18 | |
| 19 | **Rule**: ALWAYS use Controllers when you need a custom app. ALWAYS use Server Scripts for no-code prototyping. |
| 20 | |
| 21 | ## Workflow 1: Create a New Controller |
| 22 | |
| 23 | **Step 1**: Create DocType via Frappe UI or `bench new-doctype` |
| 24 | |
| 25 | **Step 2**: File is auto-generated at: |
| 26 | ``` |
| 27 | apps/myapp/myapp/{module}/doctype/{doctype_name}/{doctype_name}.py |
| 28 | ``` |
| 29 | |
| 30 | **Step 3**: Implement the controller class: |
| 31 | |
| 32 | ```python |
| 33 | import frappe |
| 34 | from frappe import _ |
| 35 | from frappe.model.document import Document |
| 36 | |
| 37 | class MyDocType(Document): |
| 38 | def validate(self): |
| 39 | self.validate_dates() |
| 40 | self.calculate_totals() |
| 41 | |
| 42 | def validate_dates(self): |
| 43 | if self.from_date and self.to_date and self.from_date > self.to_date: |
| 44 | frappe.throw(_("From Date cannot be after To Date")) |
| 45 | |
| 46 | def calculate_totals(self): |
| 47 | self.total = sum(item.amount for item in self.items) |
| 48 | ``` |
| 49 | |
| 50 | **Step 4**: Run `bench restart` (or `bench watch` for hot-reload in dev) |
| 51 | |
| 52 | **Naming convention**: DocType "Sales Order" → class `SalesOrder`, file `sales_order.py` |
| 53 | |
| 54 | ## Workflow 2: Choose the Right Hook |
| 55 | |
| 56 | ``` |
| 57 | WHAT DO YOU WANT? |
| 58 | ├── Validate data / calculate fields before save? |
| 59 | │ └── validate — changes to self ARE saved |
| 60 | │ |
| 61 | ├── Action AFTER save (emails, linked docs, logs)? |
| 62 | │ └── on_update — changes to self NOT saved (use db_set) |
| 63 | │ |
| 64 | ├── Only for NEW documents? |
| 65 | │ └── after_insert |
| 66 | │ |
| 67 | ├── Before/after SUBMIT? |
| 68 | │ ├── Check before submit → before_submit |
| 69 | │ └── Ledger entries after → on_submit |
| 70 | │ |
| 71 | ├── Before/after CANCEL? |
| 72 | │ ├── Prevent cancel → before_cancel |
| 73 | │ └── Reverse entries → on_cancel |
| 74 | │ |
| 75 | ├── Before DELETE? |
| 76 | │ └── on_trash (throw to prevent) |
| 77 | │ |
| 78 | ├── Custom document naming? |
| 79 | │ └── autoname |
| 80 | │ |
| 81 | └── Detect ANY change (including db_set)? |
| 82 | └── on_change |
| 83 | ``` |
| 84 | |
| 85 | > See [references/decision-tree.md](references/decision-tree.md) for all hooks with execution order. |
| 86 | |
| 87 | ## CRITICAL: validate vs on_update |
| 88 | |
| 89 | | Aspect | `validate` | `on_update` | |
| 90 | |--------|-----------|-------------| |
| 91 | | When | Before DB write | After DB write | |
| 92 | | `self.x = y` saved? | YES | **NO** — use `db_set` | |
| 93 | | Can abort with throw? | YES | Already saved | |
| 94 | | `get_doc_before_save()` | Available | Available | |
| 95 | | Use for | Validation, calculations | Notifications, linked docs | |
| 96 | |
| 97 | ```python |
| 98 | # WRONG — changes in on_update are NOT saved |
| 99 | def on_update(self): |
| 100 | self.status = "Completed" # LOST! |
| 101 | |
| 102 | # CORRECT — use db_set |
| 103 | def on_update(self): |
| 104 | frappe.db.set_value(self.doctype, self.name, "status", "Completed") |
| 105 | ``` |
| 106 | |
| 107 | ## Workflow 3: Validation with Error Collection |
| 108 | |
| 109 | ```python |
| 110 | def validate(self): |
| 111 | errors = [] |
| 112 | if not self.items: |
| 113 | errors.append(_("At least one item is required")) |
| 114 | for item in self.items: |
| 115 | if item.qty <= 0: |
| 116 | errors.append(_("Row {0}: Qty must be positive").format(item.idx)) |
| 117 | if self.from_date > self.to_date: |
| 118 | errors.append(_("From Date cannot be after To Date")) |
| 119 | if errors: |
| 120 | frappe.throw("<br>".join(errors)) |
| 121 | ``` |
| 122 | |
| 123 | ## Workflow 4: Detect Field Changes |
| 124 | |
| 125 | ```python |
| 126 | def validate(self): |
| 127 | old = self.get_doc_before_save() |
| 128 | if old and old.status != self.status: |
| 129 | self.flags.status_changed = True |
| 130 | self.status_changed_on = frappe.utils.now() |
| 131 | |
| 132 | def on_update(self): |
| 133 | if self.flags.get('status_changed'): |
| 134 | self.notify_status_change() |
| 135 | ``` |
| 136 | |
| 137 | **Rule**: ALWAYS use `self.flags` to pass data between hooks. NEVER rely on external state. |
| 138 | |
| 139 | ## Workflow 5: Custom Naming (autoname) |
| 140 | |
| 141 | ```python |
| 142 | from frappe.model.naming import getseries |
| 143 | |
| 144 | def autoname(se |