--- name: erpnext-syntax-controllers description: "Deterministic syntax for Frappe Document Controllers (Python server-side). Use when Claude needs to generate code for DocType controllers, lifecycle hooks (validate, on_update, on_submit, etc.), document methods, controller override, submittable documents, or when questions concern controller structure, naming conventions, autoname patterns, UUID naming (v16), or the flags system. Triggers: document controller, controller hook, validate, on_update, on_submit, autoname, naming series, UUID naming, flags system." --- # ERPNext Syntax: Document Controllers Document Controllers are Python classes that implement the server-side logic of a DocType. ## Quick Reference ### Controller Basic Structure ```python import frappe from frappe.model.document import Document class SalesOrder(Document): def validate(self): """Main validation - runs on every save.""" if not self.items: frappe.throw(_("Items are required")) self.total = sum(item.amount for item in self.items) def on_update(self): """After save - changes to self are NOT saved.""" self.update_linked_docs() ``` ### Location and Naming | DocType | Class | File | |---------|-------|------| | Sales Order | `SalesOrder` | `selling/doctype/sales_order/sales_order.py` | | Custom Doc | `CustomDoc` | `module/doctype/custom_doc/custom_doc.py` | **Rule**: DocType name → PascalCase (remove spaces) → snake_case filename --- ## Most Used Hooks | Hook | When | Typical Use | |------|------|-------------| | `validate` | Before every save | Validation, calculations | | `on_update` | After every save | Notifications, linked docs | | `after_insert` | After new doc | Creation-only actions | | `on_submit` | After submit | Ledger entries, stock | | `on_cancel` | After cancel | Reverse ledger entries | | `on_trash` | Before delete | Cleanup related data | | `autoname` | On naming | Custom document name | **Complete list and execution order**: See [lifecycle-methods.md](references/lifecycle-methods.md) --- ## Hook Selection Decision Tree ``` What do you want to do? │ ├─► Validate or calculate fields? │ └─► validate │ ├─► Action after save (emails, linked docs)? │ └─► on_update │ ├─► Only for NEW docs? │ └─► after_insert │ ├─► On SUBMIT? │ ├─► Check beforehand? → before_submit │ └─► Action afterwards? → on_submit │ ├─► On CANCEL? │ ├─► Check beforehand? → before_cancel │ └─► Cleanup? → on_cancel │ ├─► Custom document name? │ └─► autoname │ └─► Cleanup before delete? └─► on_trash ``` --- ## Critical Rules ### 1. Changes after on_update are NOT saved ```python # ❌ WRONG - change is lost def on_update(self): self.status = "Completed" # NOT saved # ✅ CORRECT - use db_set def on_update(self): frappe.db.set_value(self.doctype, self.name, "status", "Completed") ``` ### 2. No commits in controllers ```python # ❌ WRONG - Frappe handles commits def on_update(self): frappe.db.commit() # DON'T DO THIS # ✅ CORRECT - no commit needed def on_update(self): self.update_related() # Frappe commits automatically ``` ### 3. Always call super() when overriding ```python # ❌ WRONG - parent logic is skipped def validate(self): self.custom_check() # ✅ CORRECT - parent logic is preserved def validate(self): super().validate() self.custom_check() ``` ### 4. Use flags for recursion prevention ```python def on_update(self): if self.flags.get('from_linked_doc'): return linked = frappe.get_doc("Linked Doc", self.linked_doc) linked.flags.from_linked_doc = True linked.save() ``` --- ## Document Naming (autoname) ### Available Naming Options | Option | Example | Result | Version | |--------|---------|--------|---------| | `field:fieldname` | `field:customer_name` | `ABC Company` | All | | `naming_series:` | `naming_series:` | `SO-2024-00001` | All | | `format:PREFIX-{##}` | `format:INV-{YYYY}-{####}` | `INV-2024-0001` | All | | `hash` | `hash` | `a1b2c3d4e5` | All | | `Prompt` | `Prompt` | User enters name | All | | **`UUID`** | `UUID` | `01948d5f-...` | **v16+** | | Custom method | Controller autoname() | Any pattern | All | ### UUID Naming (v16+) New in v16: UUID-based naming for globally unique identifiers. ```json { "doctype": "DocType", "autoname": "UUID" } ``` **Benefits:** - Globally unique across systems - Better data integrity and traceability - Reduced database storage - Faster bulk record creation - Link fields store UUID in native format **Implementation:** ```python # Frappe automatically generates UUID7 # In naming.py: if meta.autoname == "UUID": doc.name = str(uuid_utils.uuid7()) ``` **Validation:** ```python # UUID names are validated on import from uuid import UUID try: UUID(doc.name) except ValueError: frappe.throw(_("Invalid UUID: {}").format(doc.name)) ``` ### Custom autoname Method ```python from frappe.model.naming import getseries class Project(Document): def autoname(self): # Custom naming based on customer prefix = f"P-{self.customer}-" self.name = getseries(prefix, 3) # Result: P-ACME-001, P-ACME-002, etc. ``` ### Format Patterns | Pattern | Description | Example | |---------|-------------|---------| | `{#}` | Counter | 1, 2, 3 | | `{##}` | Zero-padded counter | 01, 02, 03 | | `{####}` | 4-digit counter | 0001, 0002 | | `{YYYY}` | Full year | 2024 | | `{YY}` | 2-digit year | 24 | | `{MM}` | Month | 01-12 | | `{DD}` | Day | 01-31 | | `{fieldname}` | Field value | (value) | --- ## Controller Override ### Via hooks.py (override_doctype_class) ```python # hooks.py override_doctype_class = { "Sales Order": "custom_app.overrides.CustomSalesOrder" } # custom_app/overrides.py from erpnext.selling.doctype.sales_order.sales_order import SalesOrder class CustomSalesOrder(SalesOrder): def validate(self): super().validate() self.custom_validation() ``` ### Via doc_events (hooks.py) ```python # hooks.py doc_events = { "Sales Order": { "validate": "custom_app.events.validate_sales_order", "on_submit": "custom_app.events.on_submit_sales_order" } } # custom_app/events.py def validate_sales_order(doc, method): if doc.total > 100000: doc.requires_approval = 1 ``` **Choice**: `override_doctype_class` for full control, `doc_events` for individual hooks. --- ## Submittable Documents Documents with `is_submittable = 1` have a docstatus lifecycle: | docstatus | Status | Editable | Can go to | |-----------|--------|----------|-----------| | 0 | Draft | ✅ Yes | 1 (Submit) | | 1 | Submitted | ❌ No | 2 (Cancel) | | 2 | Cancelled | ❌ No | - | ```python class StockEntry(Document): def on_submit(self): """After submit - create stock ledger entries.""" self.update_stock_ledger() def on_cancel(self): """After cancel - reverse the entries.""" self.reverse_stock_ledger() ``` --- ## Virtual DocTypes For external data sources (no database table): ```python class ExternalCustomer(Document): @staticmethod def get_list(args): return external_api.get_customers(args.get("filters")) @staticmethod def get_count(args): return external_api.count_customers(args.get("filters")) @staticmethod def get_stats(args): return {} ``` --- ## Inheritance Patterns ### Standard Controller ```python from frappe.model.document import Document class MyDocType(Document): pass ``` ### Tree DocType ```python from frappe.utils.nestedset import NestedSet class Department(NestedSet): pass ``` ### Extend Existing Controller ```python from erpnext.selling.doctype.sales_order.sales_order import SalesOrder class CustomSalesOrder(SalesOrder): def validate(self): super().validate() self.custom_validation() ``` --- ## Type Annotations (v15+) ```python class Person(Document): if TYPE_CHECKING: from frappe.types import DF first_name: DF.Data last_name: DF.Data birth_date: DF.Date ``` Enable in `hooks.py`: ```python export_python_type_annotations = True ``` --- ## Reference Files | File | Contents | |------|----------| | [lifecycle-methods.md](references/lifecycle-methods.md) | All hooks, execution order, examples | | [methods.md](references/methods.md) | All doc.* methods with signatures | | [flags.md](references/flags.md) | Flags system documentation | | [examples.md](references/examples.md) | Complete working controller examples | | [anti-patterns.md](references/anti-patterns.md) | Common mistakes and corrections | --- ## Version Differences | Feature | v14 | v15 | v16 | |---------|-----|-----|-----| | Type annotations | ❌ | ✅ Auto-generated | ✅ | | `before_discard` hook | ❌ | ✅ | ✅ | | `on_discard` hook | ❌ | ✅ | ✅ | | `flags.notify_update` | ❌ | ✅ | ✅ | | **UUID autoname** | ❌ | ❌ | ✅ | | **UUID in Link fields (native)** | ❌ | ❌ | ✅ | ### v16-Specific Notes **UUID Naming:** - Set `autoname = "UUID"` in DocType definition - Uses `uuid7()` for time-ordered UUIDs - Link fields store UUIDs in native format (not text) - Improves performance for bulk operations **Choosing UUID vs Traditional Naming:** ``` When to use UUID: ├── Cross-system data synchronization ├── Bulk record creation ├── Global uniqueness required └── No human-readable name needed When to use traditional naming: ├── User-facing document references (SO-00001) ├── Sequential numbering required ├── Auditing requires readable names └── Integration with legacy systems ``` --- ## Anti-Patterns ### ❌ Direct field change after on_update ```python def on_update(self): self.status = "Done" # Will be lost! ``` ### ❌ frappe.db.commit() in controller ```python def validate(self): frappe.db.commit() # Breaks transaction! ``` ### ❌ Forgetting to call super() ```python def validate(self): self.my_check() # Parent validate is skipped ``` → See [anti-patterns.md](references/anti-patterns.md) for complete list. --- ## Related Skills - `erpnext-syntax-serverscripts` – Server Scripts (sandbox alternative) - `erpnext-syntax-hooks` – hooks.py configuration - `erpnext-impl-controllers` – Implementation workflows