Get the FREE Ultimate OpenClaw Setup Guide →

erpnext-syntax-controllers

npx machina-cli add skill OpenAEC-Foundation/ERPNext_Anthropic_Claude_Development_Skill_Package/erpnext-syntax-controllers --openclaw
Files (1)
SKILL.md
10.3 KB

ERPNext 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

DocTypeClassFile
Sales OrderSalesOrderselling/doctype/sales_order/sales_order.py
Custom DocCustomDocmodule/doctype/custom_doc/custom_doc.py

Rule: DocType name → PascalCase (remove spaces) → snake_case filename


Most Used Hooks

HookWhenTypical Use
validateBefore every saveValidation, calculations
on_updateAfter every saveNotifications, linked docs
after_insertAfter new docCreation-only actions
on_submitAfter submitLedger entries, stock
on_cancelAfter cancelReverse ledger entries
on_trashBefore deleteCleanup related data
autonameOn namingCustom 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

OptionExampleResultVersion
field:fieldnamefield:customer_nameABC CompanyAll
naming_series:naming_series:SO-2024-00001All
format:PREFIX-{##}format:INV-{YYYY}-{####}INV-2024-0001All
hashhasha1b2c3d4e5All
PromptPromptUser enters nameAll
UUIDUUID01948d5f-...v16+
Custom methodController autoname()Any patternAll

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

PatternDescriptionExample
{#}Counter1, 2, 3
{##}Zero-padded counter01, 02, 03
{####}4-digit counter0001, 0002
{YYYY}Full year2024
{YY}2-digit year24
{MM}Month01-12
{DD}Day01-31
{fieldname}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:

docstatusStatusEditableCan go to
0Draft✅ Yes1 (Submit)
1Submitted❌ No2 (Cancel)
2Cancelled❌ 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

FileContents
lifecycle-methods.mdAll hooks, execution order, examples
methods.mdAll doc.* methods with signatures
flags.mdFlags system documentation
examples.mdComplete working controller examples
anti-patterns.mdCommon mistakes and corrections

Version Differences

Featurev14v15v16
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

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

  • erpnext-syntax-serverscripts – Server Scripts (sandbox alternative)
  • erpnext-syntax-hooks – hooks.py configuration
  • erpnext-impl-controllers – Implementation workflows

Source

git clone https://github.com/OpenAEC-Foundation/ERPNext_Anthropic_Claude_Development_Skill_Package/blob/main/skills/source/syntax/erpnext-syntax-controllers/SKILL.mdView on GitHub

Overview

This skill provides a deterministic Python-based syntax for ERPNext Document Controllers. It covers lifecycle hooks (validate, on_update, on_submit, etc.), document methods, controller overrides, and naming/UUID patterns, with emphasis on the flags system. It helps you generate robust, repeatable controller structures.

How This Skill Works

Document Controllers are Python classes that extend Document and implement server-side logic for a DocType. Frappe calls hooks like validate, on_update, on_submit at the appropriate lifecycle events, enabling custom behavior. Follow practices such as avoiding manual commits, using frappe.db.set_value to persist changes, and using flags to prevent recursion.

When to Use It

  • When you need a DocType controller with lifecycle hooks (validate, on_update, on_submit)
  • When adding custom document methods or overriding default behavior
  • When implementing autoname, naming_series, or UUID naming (v16)
  • When questions concern controller structure, naming conventions, or the flags system
  • When building submittable documents that require post-submit actions

Quick Start

  1. Step 1: Identify required hooks (validate, on_update, on_submit) for your DocType
  2. Step 2: Create a Python class that extends Document and implement the hooks
  3. Step 3: Place the file under the DocType path, then test in the Frappe bench and fix issues

Best Practices

  • Avoid making commits; rely on Frappe’s automatic commit and use db_set/update patterns
  • Always call super() when overriding lifecycle methods
  • Use flags to prevent recursive updates across linked docs
  • Follow the DocType naming rule: PascalCase class name and snake_case file path
  • Test hooks in a safe environment before deploying to production

Example Use Cases

  • validate hook enforcing required items and calculating totals
  • on_update to update linked documents after a save
  • autoname using a naming_series for predictable doc names
  • using flags in on_update to avoid recursive saves
  • on_submit to create ledger entries or trigger downstream processes

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers