Loading...
Loading...
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.
npx skill4agent add openaec-foundation/erpnext_anthropic_claude_development_skill_package erpnext-syntax-controllersimport 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()| DocType | Class | File |
|---|---|---|
| Sales Order | | |
| Custom Doc | | |
| Hook | When | Typical Use |
|---|---|---|
| Before every save | Validation, calculations |
| After every save | Notifications, linked docs |
| After new doc | Creation-only actions |
| After submit | Ledger entries, stock |
| After cancel | Reverse ledger entries |
| Before delete | Cleanup related data |
| On naming | Custom document name |
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# ❌ 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")# ❌ 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# ❌ WRONG - parent logic is skipped
def validate(self):
self.custom_check()
# ✅ CORRECT - parent logic is preserved
def validate(self):
super().validate()
self.custom_check()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()| Option | Example | Result | Version |
|---|---|---|---|
| | | All |
| | | All |
| | | All |
| | | All |
| | User enters name | All |
| | | v16+ |
| Custom method | Controller autoname() | Any pattern | All |
{
"doctype": "DocType",
"autoname": "UUID"
}# Frappe automatically generates UUID7
# In naming.py:
if meta.autoname == "UUID":
doc.name = str(uuid_utils.uuid7())# UUID names are validated on import
from uuid import UUID
try:
UUID(doc.name)
except ValueError:
frappe.throw(_("Invalid UUID: {}").format(doc.name))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.| Pattern | Description | Example |
|---|---|---|
| Counter | 1, 2, 3 |
| Zero-padded counter | 01, 02, 03 |
| 4-digit counter | 0001, 0002 |
| Full year | 2024 |
| 2-digit year | 24 |
| Month | 01-12 |
| Day | 01-31 |
| Field value | (value) |
# 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()# 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 = 1override_doctype_classdoc_eventsis_submittable = 1| docstatus | Status | Editable | Can go to |
|---|---|---|---|
| 0 | Draft | ✅ Yes | 1 (Submit) |
| 1 | Submitted | ❌ No | 2 (Cancel) |
| 2 | Cancelled | ❌ No | - |
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()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 {}from frappe.model.document import Document
class MyDocType(Document):
passfrom frappe.utils.nestedset import NestedSet
class Department(NestedSet):
passfrom erpnext.selling.doctype.sales_order.sales_order import SalesOrder
class CustomSalesOrder(SalesOrder):
def validate(self):
super().validate()
self.custom_validation()class Person(Document):
if TYPE_CHECKING:
from frappe.types import DF
first_name: DF.Data
last_name: DF.Data
birth_date: DF.Datehooks.pyexport_python_type_annotations = True| File | Contents |
|---|---|
| lifecycle-methods.md | All hooks, execution order, examples |
| methods.md | All doc.* methods with signatures |
| flags.md | Flags system documentation |
| examples.md | Complete working controller examples |
| anti-patterns.md | Common mistakes and corrections |
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Type annotations | ❌ | ✅ Auto-generated | ✅ |
| ❌ | ✅ | ✅ |
| ❌ | ✅ | ✅ |
| ❌ | ✅ | ✅ |
| UUID autoname | ❌ | ❌ | ✅ |
| UUID in Link fields (native) | ❌ | ❌ | ✅ |
autoname = "UUID"uuid7()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 systemsdef on_update(self):
self.status = "Done" # Will be lost!def validate(self):
frappe.db.commit() # Breaks transaction!def validate(self):
self.my_check() # Parent validate is skippederpnext-syntax-serverscriptserpnext-syntax-hookserpnext-impl-controllers