Claude-skill-registry erpnext-syntax-controllers
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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/erpnext-syntax-controllers" ~/.claude/skills/majiayu000-claude-skill-registry-erpnext-syntax-controllers && rm -rf "$T"
skills/data/erpnext-syntax-controllers/SKILL.mdERPNext Syntax: Document Controllers
Document Controllers are Python classes that implement the server-side logic of a DocType.
Quick Reference
Controller Basic Structure
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 | | |
| Custom Doc | | |
Rule: DocType name → PascalCase (remove spaces) → snake_case filename
Most Used Hooks
| 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 |
Complete list and execution order: See 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
# ❌ 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
# ❌ 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
# ❌ 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
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 |
|---|---|---|---|
| | | All |
| | | All |
| | | All |
| | | All |
| | User enters name | All |
| | | v16+ |
| Custom method | Controller autoname() | Any pattern | All |
UUID Naming (v16+)
New in v16: UUID-based naming for globally unique identifiers.
{ "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:
# Frappe automatically generates UUID7 # In naming.py: if meta.autoname == "UUID": doc.name = str(uuid_utils.uuid7())
Validation:
# 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
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 |
| Full year | 2024 |
| 2-digit year | 24 |
| Month | 01-12 |
| Day | 01-31 |
| Field value | (value) |
Controller Override
Via hooks.py (override_doctype_class)
# 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)
# 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 | - |
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):
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
from frappe.model.document import Document class MyDocType(Document): pass
Tree DocType
from frappe.utils.nestedset import NestedSet class Department(NestedSet): pass
Extend Existing Controller
from erpnext.selling.doctype.sales_order.sales_order import SalesOrder class CustomSalesOrder(SalesOrder): def validate(self): super().validate() self.custom_validation()
Type Annotations (v15+)
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:
export_python_type_annotations = True
Reference Files
| 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 |
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Type annotations | ❌ | ✅ Auto-generated | ✅ |
hook | ❌ | ✅ | ✅ |
hook | ❌ | ✅ | ✅ |
| ❌ | ✅ | ✅ |
| UUID autoname | ❌ | ❌ | ✅ |
| UUID in Link fields (native) | ❌ | ❌ | ✅ |
v16-Specific Notes
UUID Naming:
- Set
in DocType definitionautoname = "UUID" - Uses
for time-ordered UUIDsuuid7() - 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
def on_update(self): self.status = "Done" # Will be lost!
❌ frappe.db.commit() in controller
def validate(self): frappe.db.commit() # Breaks transaction!
❌ Forgetting to call super()
def validate(self): self.my_check() # Parent validate is skipped
→ See anti-patterns.md for complete list.
Related Skills
– Server Scripts (sandbox alternative)erpnext-syntax-serverscripts
– hooks.py configurationerpnext-syntax-hooks
– Implementation workflowserpnext-impl-controllers