
Frappe Impl Controllers
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Provides workflows for building Frappe Document Controllers including lifecycle hooks, validation, autoname, submittable documents, and controller overrides.
About
An implementation skill with step-by-step workflows for building Frappe Document Controllers in a custom app. A developer uses it to implement lifecycle hooks, validation, autoname, and submittable workflows.
- Workflows for lifecycle hooks, validation, autoname, and submittable documents
- Controller override, child table controllers, and migration from hooks.py/Server Scripts
Frappe Impl Controllers by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,834 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-impl-controllersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Provides workflows for building Frappe Document Controllers including lifecycle hooks, validation, autoname, submittable documents, and controller overrides.
Files
Document Controllers — Implementation Workflows
Step-by-step workflows for building server-side DocType logic with full Python power. For exact syntax, see frappe-syntax-controllers.
Version: v14/v15/v16 | v15+: Supports auto-generated type annotations
Quick Decision: Controller vs Server Script?
NEED full Python (imports, classes, generators)? → Controller
NEED external libraries (requests, pandas)? → Controller
NEED try/except with rollback? → Controller
NEED frappe.enqueue() for background jobs? → Controller
NEED to extend standard ERPNext DocType? → Controller
Quick validation without custom app? → Server Script
Simple auto-fill or notification? → Server ScriptRule: ALWAYS use Controllers when you need a custom app. ALWAYS use Server Scripts for no-code prototyping.
Workflow 1: Create a New Controller
Step 1: Create DocType via Frappe UI or bench new-doctype
Step 2: File is auto-generated at:
apps/myapp/myapp/{module}/doctype/{doctype_name}/{doctype_name}.pyStep 3: Implement the controller class:
import frappe
from frappe import _
from frappe.model.document import Document
class MyDocType(Document):
def validate(self):
self.validate_dates()
self.calculate_totals()
def validate_dates(self):
if self.from_date and self.to_date and self.from_date > self.to_date:
frappe.throw(_("From Date cannot be after To Date"))
def calculate_totals(self):
self.total = sum(item.amount for item in self.items)Step 4: Run bench restart (or bench watch for hot-reload in dev)
Naming convention: DocType "Sales Order" → class SalesOrder, file sales_order.py
Workflow 2: Choose the Right Hook
WHAT DO YOU WANT?
├── Validate data / calculate fields before save?
│ └── validate — changes to self ARE saved
│
├── Action AFTER save (emails, linked docs, logs)?
│ └── on_update — changes to self NOT saved (use db_set)
│
├── Only for NEW documents?
│ └── after_insert
│
├── Before/after SUBMIT?
│ ├── Check before submit → before_submit
│ └── Ledger entries after → on_submit
│
├── Before/after CANCEL?
│ ├── Prevent cancel → before_cancel
│ └── Reverse entries → on_cancel
│
├── Before DELETE?
│ └── on_trash (throw to prevent)
│
├── Custom document naming?
│ └── autoname
│
└── Detect ANY change (including db_set)?
└── on_changeSee references/decision-tree.md for all hooks with execution order.
CRITICAL: validate vs on_update
| Aspect | validate | on_update |
|---|---|---|
| When | Before DB write | After DB write |
self.x = y saved? | YES | NO — use db_set |
| Can abort with throw? | YES | Already saved |
get_doc_before_save() | Available | Available |
| Use for | Validation, calculations | Notifications, linked docs |
# WRONG — changes in on_update are NOT saved
def on_update(self):
self.status = "Completed" # LOST!
# CORRECT — use db_set
def on_update(self):
frappe.db.set_value(self.doctype, self.name, "status", "Completed")Workflow 3: Validation with Error Collection
def validate(self):
errors = []
if not self.items:
errors.append(_("At least one item is required"))
for item in self.items:
if item.qty <= 0:
errors.append(_("Row {0}: Qty must be positive").format(item.idx))
if self.from_date > self.to_date:
errors.append(_("From Date cannot be after To Date"))
if errors:
frappe.throw("<br>".join(errors))Workflow 4: Detect Field Changes
def validate(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
self.flags.status_changed = True
self.status_changed_on = frappe.utils.now()
def on_update(self):
if self.flags.get('status_changed'):
self.notify_status_change()Rule: ALWAYS use self.flags to pass data between hooks. NEVER rely on external state.
Workflow 5: Custom Naming (autoname)
from frappe.model.naming import getseries
def autoname(self):
# Format: PRJ-CUST-2025-001
code = (self.customer or "GEN")[:4].upper()
year = frappe.utils.getdate(self.start_date or frappe.utils.today()).year
prefix = f"PRJ-{code}-{year}-"
self.name = getseries(prefix, 3)Alternative — before_naming:
def before_naming(self):
if self.is_priority:
self.naming_series = "PRIORITY-.#####"
else:
self.naming_series = "STD-.#####"Workflow 6: Submittable Document
DRAFT (docstatus=0) → submit() → SUBMITTED (docstatus=1) → cancel() → CANCELLED (docstatus=2)
submit(): validate → before_submit → [DB: docstatus=1] → on_update → on_submit
cancel(): before_cancel → [DB: docstatus=2] → on_cancelclass PurchaseOrder(Document):
def validate(self):
self.validate_items()
self.calculate_totals()
def before_submit(self):
# ONLY submit-specific checks here
if self.total > 100000 and not self.manager_approval:
frappe.throw(_("Manager approval required for POs over 100,000"))
def on_submit(self):
self.update_ordered_qty()
self.create_purchase_receipt_draft()
def before_cancel(self):
if frappe.db.exists("Purchase Invoice",
{"purchase_order": self.name, "docstatus": 1}):
frappe.throw(_("Cancel linked invoices first"))
def on_cancel(self):
self.reverse_ordered_qty()Rule: NEVER duplicate validation between validate and before_submit. validate ALWAYS runs before before_submit.
Workflow 7: Override Standard ERPNext Controller
Method A: Full Override (hooks.py)
# hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSalesInvoice"
}
# myapp/overrides.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call parent first
self.custom_validation()Method B: Event Handler (Safer, no class override)
# hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.validate_sales_invoice",
}
}
# myapp/events.py
def validate_sales_invoice(doc, method=None):
if doc.grand_total < 0:
frappe.throw(_("Invalid total"))Method C: extend_doctype_class (v16+)
# hooks.py
extend_doctype_class = {
"Sales Invoice": "myapp.extends.SalesInvoiceExtend"
}
# myapp/extends.py — Only methods to add/override
class SalesInvoiceExtend:
def custom_method(self):
passRule: ALWAYS call super().validate() in override. NEVER skip parent methods — standard ERPNext logic depends on it.
Workflow 8: Whitelisted Methods (Client-Callable)
class Quotation(Document):
@frappe.whitelist()
def apply_discount(self, discount_percent):
if discount_percent < 0 or discount_percent > 100:
frappe.throw(_("Discount must be 0-100"))
self.discount_amount = self.total * (discount_percent / 100)
self.grand_total = self.total - self.discount_amount
self.save()
return {"grand_total": self.grand_total}Client-side call:
frm.call('apply_discount', { discount_percent: 10 }).then(r => {
frm.reload_doc();
});Workflow 9: Flags System
# Document-level flags (built-in)
doc.flags.ignore_permissions = True # Bypass permission checks
doc.flags.ignore_validate = True # Skip validate() hook
doc.flags.ignore_mandatory = True # Skip required field check
# Custom flags for inter-hook communication
def validate(self):
if self.is_urgent:
self.flags.needs_notification = True
def on_update(self):
if self.flags.get('needs_notification'):
self.notify_team()Workflow 10: Testing Controllers
# tests/test_my_doctype.py
import frappe
from frappe.tests.utils import FrappeTestCase
class TestMyDocType(FrappeTestCase):
def test_validate_dates(self):
doc = frappe.get_doc({
"doctype": "My DocType",
"from_date": "2025-01-10",
"to_date": "2025-01-01" # Before from_date
})
self.assertRaises(frappe.ValidationError, doc.insert)
def test_calculate_totals(self):
doc = frappe.get_doc({
"doctype": "My DocType",
"items": [
{"item": "A", "qty": 2, "rate": 100},
{"item": "B", "qty": 3, "rate": 50}
]
})
doc.insert()
self.assertEqual(doc.total, 350)Run: bench run-tests --module myapp.module.doctype.my_doctype.test_my_doctype
Execution Order Reference
INSERT
before_insert → before_naming → autoname → before_validate →
validate → before_save → [DB INSERT] → after_insert →
on_update → on_changeSAVE (existing)
before_validate → validate → before_save → [DB UPDATE] →
on_update → on_changeSUBMIT
validate → before_submit → [DB: docstatus=1] →
on_update → on_submit → on_changeAnti-Pattern Quick Check
| Do NOT | Do Instead |
|---|---|
self.x = y in on_update | frappe.db.set_value(...) |
self.save() in on_update | Causes infinite loop |
frappe.db.commit() in hooks | Let framework handle |
| Heavy ops in validate | Use frappe.enqueue() in on_update |
Skip super().validate() | ALWAYS call parent first |
frappe.get_doc() in loops | Use frappe.get_cached_doc() |
| Hardcoded thresholds | Use Settings DocType |
See references/anti-patterns.md for complete list.
Related Skills
frappe-syntax-controllers— Exact hook signatures and APIfrappe-errors-controllers— Error handling patternsfrappe-impl-serverscripts— When Server Script sufficesfrappe-syntax-hooks— hooks.py configurationfrappe-core-database—frappe.db.*operations
See references/decision-tree.md for all hooks.
See references/workflows.md for extended patterns.
See references/examples.md for complete working examples.
Controller Anti-Patterns
AP-1: Modifying self After on_update
# WRONG — Changes are NOT saved after on_update
def on_update(self):
self.status = "Completed" # LOST
self.processed_date = frappe.utils.today() # LOST# CORRECT — Use db_set or set_value
def on_update(self):
frappe.db.set_value(self.doctype, self.name, {
"status": "Completed",
"processed_date": frappe.utils.today()
})AP-2: Calling self.save() in on_update
# WRONG — Infinite loop: save → on_update → save → on_update...
def on_update(self):
self.counter = (self.counter or 0) + 1
self.save()# CORRECT — db_set does NOT trigger hooks
def on_update(self):
new_counter = (self.counter or 0) + 1
frappe.db.set_value(self.doctype, self.name, "counter", new_counter,
update_modified=False)AP-3: Manual Commits in Hooks
# WRONG — Breaks transaction management
def validate(self):
self.do_something()
frappe.db.commit() # Breaks rollback on error# CORRECT — Let framework handle transactions
def validate(self):
self.do_something()
# No commit — Frappe commits after successful saveAP-4: Heavy Operations in validate
# WRONG — Blocks user UI
def validate(self):
self.process_large_dataset() # 30 seconds
self.sync_to_external_api() # Network call# CORRECT — Queue heavy work
def validate(self):
self.validate_fields() # Quick checks only
self.calculate_totals()
def on_update(self):
if self.needs_processing:
frappe.enqueue('myapp.tasks.process_document',
queue='long', timeout=300, doc_name=self.name)AP-5: Not Calling super() in Override
# WRONG — Skips ALL standard ERPNext validation
class CustomSalesInvoice(SalesInvoice):
def validate(self):
self.custom_validation() # Parent validate never runs!# CORRECT — ALWAYS call parent first
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate()
self.custom_validation()AP-6: Assuming Hook Order Across Documents
# WRONG — Nested hook cycles are unpredictable
def on_update(self):
other = frappe.get_doc("Other", self.link)
other.field = "value"
other.save() # Triggers Other's full hook cycle
# Assuming Other's on_update has completed here# CORRECT — Use db_set or flags
def on_update(self):
frappe.db.set_value("Other", self.link, "field", "value")
# OR with flags to prevent recursion
def on_update(self):
other = frappe.get_doc("Other", self.link)
other.flags.from_parent_update = True
other.field = "value"
other.save()AP-7: Bypassing Permissions Without Reason
# WRONG — Security hole
def after_insert(self):
doc = frappe.get_doc({"doctype": "Task", "subject": "Test"})
doc.flags.ignore_permissions = True # Always bypassing
doc.insert()# CORRECT — Only bypass when justified
def after_insert(self):
doc = frappe.get_doc({"doctype": "Task", "subject": "Test"})
# System-generated docs need permission bypass
doc.flags.ignore_permissions = True
doc.insert()
# Document reason: auto-created by systemAP-8: get_doc in Loops
# WRONG — N database queries for same document
def validate(self):
for item in self.items:
customer = frappe.get_doc("Customer", self.customer) # Same query N times
item.credit_limit = customer.credit_limit# CORRECT — Cache or fetch once
def validate(self):
customer = frappe.get_cached_doc("Customer", self.customer)
for item in self.items:
item.credit_limit = customer.credit_limit
# Or for single values
def validate(self):
limit = frappe.db.get_value("Customer", self.customer, "credit_limit")
for item in self.items:
item.credit_limit = limitAP-9: Silent Error Swallowing
# WRONG — Errors hidden, impossible to debug
def on_update(self):
try:
self.send_notification()
self.update_external()
except:
pass# CORRECT — Log non-critical, fail loudly for critical
def on_update(self):
try:
self.send_notification() # Non-critical
except Exception:
frappe.log_error(f"Notification failed: {self.name}")
self.update_ledger() # Critical — let it throwAP-10: Duplicate Logic in validate and before_submit
# WRONG — validate ALREADY runs before before_submit
def validate(self):
if not self.items:
frappe.throw(_("Items required"))
self.total = sum(item.amount for item in self.items)
def before_submit(self):
if not self.items: # Duplicate!
frappe.throw(_("Items required"))
self.total = sum(...) # Duplicate!# CORRECT — Put common in validate, submit-only in before_submit
def validate(self):
self.validate_items()
self.calculate_totals()
def before_submit(self):
# ONLY submit-specific checks
if self.total > 50000 and not self.approval:
frappe.throw(_("Approval required"))AP-11: Using datetime Instead of frappe.utils
# WRONG — Timezone issues, format incompatibilities
from datetime import datetime, timedelta
self.due_date = datetime.now() + timedelta(days=30)# CORRECT — Uses Frappe's timezone and format handling
self.due_date = frappe.utils.add_days(frappe.utils.today(), 30)AP-12: Hardcoded Values
# WRONG — Requires code changes for different values
if self.amount > 50000:
self.requires_approval = 1
self.tax_rate = 0.18# CORRECT — Configurable via Settings
settings = frappe.get_cached_doc("My Settings", "My Settings")
if self.amount > settings.approval_threshold:
self.requires_approval = 1
self.tax_rate = settings.default_tax_rateAP-13: Synchronous Emails in Bulk
# WRONG — Blocks until all sent, fails if email server down
def on_submit(self):
for recipient in self.get_all_recipients():
frappe.sendmail(recipients=[recipient], subject=..., message=...)# CORRECT — Queue for background
def on_submit(self):
frappe.sendmail(
recipients=self.get_all_recipients(),
subject=f"Document {self.name} submitted",
message="Submitted.",
now=False) # Queue for background sendingQuick Reference
| Do NOT | Do Instead |
|---|---|
self.x = y in on_update | frappe.db.set_value(...) |
self.save() in on_update | frappe.db.set_value(...) |
frappe.db.commit() in hooks | Let framework handle |
| Heavy processing in validate | frappe.enqueue() |
Skip super().validate() | ALWAYS call parent |
except: pass | Log errors properly |
frappe.get_doc() in loops | frappe.get_cached_doc() |
| Hardcode thresholds/rates | Use Settings DocType |
| Synchronous bulk emails | now=False or frappe.enqueue |
| Duplicate across hooks | Shared methods |
datetime.now() | frappe.utils.now() |
Controller Hook Decision Tree
Master Decision: Which Hook?
WHAT DO YOU WANT TO ACHIEVE?
VALIDATION & CALCULATIONS
├── Validate field values? → validate
├── Auto-calculate totals/percentages? → validate (changes saved)
├── Set default values before validation? → before_validate
└── Pre-validation setup (rarely needed)? → before_validate
POST-SAVE ACTIONS
├── Send email notifications? → on_update
├── Update linked/related documents? → on_update
├── Create audit log? → on_update
└── Trigger webhook / external API? → on_update (or enqueue)
NEW DOCUMENT ONLY
├── Action only on creation (not updates)? → after_insert
├── Create related document on first save? → after_insert
├── Generate custom document name? → autoname
├── Modify naming parameters? → before_naming
└── Setup before any validation (new only)? → before_insert
SUBMITTABLE DOCUMENTS
├── Additional validation before submit? → before_submit
├── Create ledger entries / update stock? → on_submit
├── Prevent cancel under conditions? → before_cancel
├── Reverse ledger entries / restore stock? → on_cancel
└── Action when submitted doc updated? → on_update_after_submit
DELETE / RENAME
├── Prevent delete under conditions? → on_trash (throw)
├── Cleanup before delete? → on_trash
├── Post-delete actions? → after_delete
├── Before rename validation? → before_rename
└── Update references after rename? → after_rename
SPECIAL CASES
├── Detect ANY change (including db_set)? → on_change
├── Modify print output? → before_print
└── Draft discard (v15+)? → before_discard / on_discardComplete Hook Reference
Standard Hooks (All DocTypes)
| Hook | Timing | Changes Saved? | Primary Use |
|---|---|---|---|
before_insert | Before new doc processing | Yes | Setup for new docs |
before_naming | Before name generated | Yes | Modify naming_series |
autoname | Generate document name | Yes (name) | Custom naming logic |
before_validate | Before validation | Yes | Pre-validation defaults |
validate | Main validation | Yes | Validation + calculations |
before_save | After validate, before DB | Yes | Final adjustments |
after_insert | After first DB insert | No | Creation-only actions |
on_update | After every DB save | No | Post-save actions |
on_change | After any change | No | Universal change detection |
before_rename | Before name change | N/A | Rename validation |
after_rename | After name changed | No | Update references |
on_trash | Before delete | N/A | Cleanup, prevent delete |
after_delete | After deleted | N/A | Post-delete cleanup |
before_print | Before print render | N/A | Modify print data |
Submittable Document Hooks
| Hook | Timing | Primary Use |
|---|---|---|
before_submit | Before docstatus=1 | Submit validation |
on_submit | After docstatus=1 | Ledger entries, stock |
before_cancel | Before docstatus=2 | Cancel validation |
on_cancel | After docstatus=2 | Reverse entries |
before_update_after_submit | Before submitted doc update | Validate changes |
on_update_after_submit | After submitted doc update | Post-update actions |
v15+ Hooks
| Hook | Timing | Primary Use |
|---|---|---|
before_discard | Before draft discard | Prevent discard |
on_discard | After draft discarded | Cleanup |
Execution Order Diagrams
INSERT (New Document)
doc.insert()
▼
before_insert ← Setup for new doc
▼
before_naming ← Modify naming params
▼
autoname ← Generate doc name
▼
before_validate ← Pre-validation setup
▼
validate ← Main validation + calc [changes SAVED]
▼
before_save ← Final adjustments
▼
[DB INSERT]
▼
after_insert ← Creation-only actions [changes NOT saved]
▼
on_update ← Post-save actions [changes NOT saved]
▼
on_change ← Universal change hookSAVE (Existing Document)
doc.save()
▼
before_validate [changes SAVED]
▼
validate [changes SAVED]
▼
before_save [changes SAVED]
▼
[DB UPDATE]
▼
on_update [changes NOT saved — use db_set]
▼
on_changeSUBMIT
doc.submit()
▼
validate ← Standard validation
▼
before_submit ← Submit-specific checks (throw to abort)
▼
[DB: docstatus=1]
▼
on_update
▼
on_submit ← Ledger entries, stock updates
▼
on_changeCANCEL
doc.cancel()
▼
before_cancel ← Prevent cancel (throw)
▼
[DB: docstatus=2]
▼
on_cancel ← Reverse entries
▼
[check_no_back_links]
▼
on_changeDELETE
doc.delete()
▼
on_trash ← Cleanup / prevent (throw)
▼
[DB DELETE]
▼
after_delete ← Post-delete cleanupQuick Selection Guide
| I want to... | Use hook |
|---|---|
| Prevent save if invalid | validate + frappe.throw() |
| Auto-fill a field | validate |
| Send email after save | on_update |
| Create linked doc on first save | after_insert |
| Custom document naming | autoname |
| Prevent delete | on_trash + frappe.throw() |
| Detect changes via db_set | on_change |
| Prevent submit | before_submit + frappe.throw() |
| Create GL entries on submit | on_submit |
| Reverse GL on cancel | on_cancel |
| Modify print data | before_print |
Controller Complete Examples
Example 1: Basic Document with Validation
# apps/myapp/myapp/hr/doctype/leave_request/leave_request.py
import frappe
from frappe import _
from frappe.model.document import Document
class LeaveRequest(Document):
def validate(self):
self.validate_dates()
self.calculate_days()
self.check_balance()
def validate_dates(self):
if self.from_date > self.to_date:
frappe.throw(_("From Date cannot be after To Date"))
if frappe.utils.getdate(self.from_date) < frappe.utils.getdate(frappe.utils.today()):
frappe.throw(_("Cannot apply for past dates"))
def calculate_days(self):
self.total_days = frappe.utils.date_diff(self.to_date, self.from_date) + 1
def check_balance(self):
balance = frappe.db.get_value("Leave Allocation",
{"employee": self.employee, "leave_type": self.leave_type},
"total_leaves_allocated") or 0
if self.total_days > balance:
frappe.throw(_("Insufficient balance. Available: {0}").format(balance))
def on_update(self):
manager = frappe.db.get_value("Employee", self.employee, "reports_to")
if manager:
user = frappe.db.get_value("Employee", manager, "user_id")
if user:
frappe.sendmail(recipients=[user],
subject=_("Leave Request from {0}").format(self.employee_name),
message=_("{0} days from {1} to {2}").format(
self.total_days, self.from_date, self.to_date))Example 2: Submittable Document with Journal Entry
# apps/myapp/myapp/expense/doctype/expense_claim/expense_claim.py
import frappe
from frappe import _
from frappe.model.document import Document
class ExpenseClaim(Document):
def validate(self):
self.validate_amounts()
self.calculate_totals()
def validate_amounts(self):
for item in self.expenses:
if item.amount <= 0:
frappe.throw(_("Row {0}: Amount must be positive").format(item.idx))
if item.amount > 500 and not item.receipt:
frappe.throw(_("Row {0}: Receipt required for amounts over 500").format(item.idx))
def calculate_totals(self):
self.total_amount = sum(item.amount for item in self.expenses)
self.total_approved = sum(item.approved_amount or 0 for item in self.expenses)
def before_submit(self):
if self.total_amount > 5000 and not self.manager_approval:
frappe.throw(_("Manager approval required for claims over 5,000"))
def on_submit(self):
self.create_journal_entry()
def create_journal_entry(self):
if self.total_approved <= 0:
return
je = frappe.get_doc({
"doctype": "Journal Entry",
"voucher_type": "Expense Claim",
"posting_date": self.posting_date,
"accounts": [
{"account": self.expense_account,
"debit_in_account_currency": self.total_approved},
{"account": self.payable_account,
"credit_in_account_currency": self.total_approved,
"party_type": "Employee", "party": self.employee}
]
})
je.flags.ignore_permissions = True
je.submit()
frappe.db.set_value(self.doctype, self.name, "journal_entry", je.name)
def before_cancel(self):
if self.journal_entry:
status = frappe.db.get_value("Journal Entry", self.journal_entry, "docstatus")
if status == 1:
frappe.throw(_("Cancel Journal Entry {0} first").format(self.journal_entry))
def on_cancel(self):
if self.advance_reference:
frappe.db.set_value("Employee Advance", self.advance_reference,
"claimed_amount", 0)Example 3: Custom Naming with Customer Code
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.naming import getseries
class Project(Document):
def autoname(self):
code = self.get_customer_code()
year = frappe.utils.getdate(self.start_date or frappe.utils.today()).year
self.name = getseries(f"PRJ-{code}-{year}-", 3)
def get_customer_code(self):
if not self.customer:
return "GEN"
code = frappe.db.get_value("Customer", self.customer, "customer_code")
return (code or self.customer)[:3].upper()
def validate(self):
if self.start_date and self.end_date and self.start_date > self.end_date:
frappe.throw(_("Start Date cannot be after End Date"))
if self.tasks:
completed = sum(1 for t in self.tasks if t.status == "Completed")
self.percent_complete = (completed / len(self.tasks)) * 100Example 4: Change Detection with Audit Log
import frappe
from frappe import _
from frappe.model.document import Document
class Contract(Document):
TRACKED = ['status', 'contract_value', 'end_date', 'party']
def validate(self):
old = self.get_doc_before_save()
if not old:
self.flags.is_new = True
return
changes = []
for field in self.TRACKED:
old_val = getattr(old, field)
new_val = getattr(self, field)
if old_val != new_val:
changes.append({'field': field, 'old': old_val, 'new': new_val})
if changes:
self.flags.changes = changes
if any(c['field'] == 'contract_value' for c in changes):
self.flags.value_changed = True
def on_update(self):
if self.flags.get('changes'):
lines = [f"{frappe.bold(c['field'])}: {c['old']} -> {c['new']}"
for c in self.flags.changes]
self.add_comment("Edit", "<br>".join(lines))
if self.flags.get('value_changed'):
for c in self.flags.changes:
if c['field'] == 'contract_value':
frappe.sendmail(recipients=["legal@company.com"],
subject=f"Contract Value Changed: {self.name}",
message=f"Changed from {c['old']} to {c['new']}")Example 5: Controller Override with Loyalty Discount
# hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.sales_invoice.CustomSalesInvoice"
}
# myapp/overrides/sales_invoice.py
import frappe
from frappe import _
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate()
self.apply_loyalty_discount()
self.validate_credit_limit()
def apply_loyalty_discount(self):
if not self.customer:
return
tier = frappe.db.get_value("Customer", self.customer, "loyalty_tier")
discount = {"Gold": 10, "Silver": 5, "Bronze": 2}.get(tier, 0)
if discount and not self.loyalty_discount_applied:
self.additional_discount_percentage = discount
self.loyalty_discount_applied = 1
def validate_credit_limit(self):
if not self.customer or self.is_return:
return
limit = frappe.db.get_value("Customer", self.customer, "credit_limit") or 0
if not limit:
return
outstanding = frappe.db.sql("""
SELECT COALESCE(SUM(outstanding_amount), 0)
FROM `tabSales Invoice`
WHERE customer = %s AND docstatus = 1 AND name != %s
""", (self.customer, self.name))[0][0] or 0
if outstanding + self.grand_total > limit:
frappe.throw(_("Credit limit ({0}) exceeded").format(limit))
def on_submit(self):
super().on_submit()
self.update_loyalty_points()
def update_loyalty_points(self):
if not self.customer:
return
points = int(self.grand_total / 100)
if points > 0:
current = frappe.db.get_value("Customer", self.customer, "loyalty_points") or 0
frappe.db.set_value("Customer", self.customer, "loyalty_points", current + points)Example 6: Tree DocType (NestedSet)
import frappe
from frappe import _
from frappe.utils.nestedset import NestedSet
class Department(NestedSet):
nsm_parent_field = "parent_department"
def validate(self):
if self.parent_department == self.name:
frappe.throw(_("Cannot be its own parent"))
if self.tasks:
self.set_full_path()
def set_full_path(self):
parts = [self.department_name]
parent = self.parent_department
while parent:
parts.insert(0, frappe.db.get_value("Department", parent, "department_name"))
parent = frappe.db.get_value("Department", parent, "parent_department")
self.full_path = " > ".join(parts)
def on_trash(self):
count = frappe.db.count("Employee", {"department": self.name})
if count:
frappe.throw(_("Cannot delete: {0} employees").format(count))Example 7: Whitelisted Methods (Client-Callable)
class Quotation(Document):
def validate(self):
self.total = sum(item.amount for item in self.items)
@frappe.whitelist()
def apply_discount(self, discount_percent):
if discount_percent < 0 or discount_percent > 100:
frappe.throw(_("Discount must be 0-100"))
self.discount_amount = self.total * (discount_percent / 100)
self.grand_total = self.total - self.discount_amount
self.save()
return {"discount_amount": self.discount_amount,
"grand_total": self.grand_total}
@frappe.whitelist()
def create_sales_order(self):
if self.docstatus != 1:
frappe.throw(_("Must be submitted"))
so = frappe.get_doc({
"doctype": "Sales Order",
"customer": self.party_name,
"items": [{"item_code": i.item_code, "qty": i.qty, "rate": i.rate}
for i in self.items]
})
so.insert()
return so.nameClient-side:
frm.call('apply_discount', { discount_percent: 10 }).then(r => frm.reload_doc());
frm.call('create_sales_order').then(r => frappe.set_route('Form', 'Sales Order', r.message));Example 8: Virtual DocType (API-Backed)
import frappe
from frappe.model.document import Document
import requests
class ExternalProduct(Document):
@staticmethod
def get_list(args):
resp = requests.get("https://api.example.com/products",
headers={"Authorization": f"Bearer {get_key()}"})
if resp.status_code != 200:
frappe.throw("Failed to fetch products")
return [frappe._dict(p) for p in resp.json()]
def load_from_db(self):
resp = requests.get(f"https://api.example.com/products/{self.name}",
headers={"Authorization": f"Bearer {get_key()}"})
if resp.status_code != 200:
frappe.throw("Not found")
super(Document, self).__init__(resp.json())
def db_insert(self, *args, **kwargs):
data = self.get_valid_dict(convert_dates_to_str=True)
resp = requests.post("https://api.example.com/products",
json=data, headers={"Authorization": f"Bearer {get_key()}"})
if resp.status_code != 201:
frappe.throw("Failed to create")
self.name = resp.json().get('id')
def get_key():
return frappe.db.get_single_value("External API Settings", "api_key")Controller Implementation Workflows
Workflow 1: Field Validation Patterns
Simple Required
def validate(self):
if not self.customer:
frappe.throw(_("Customer is required"))Conditional Required
def validate(self):
if self.is_recurring and not self.end_date:
frappe.throw(_("End Date required for recurring documents"))Cross-Field
def validate(self):
if self.from_date and self.to_date and self.from_date > self.to_date:
frappe.throw(_("From Date cannot be after To Date"))Link Field
def validate(self):
if self.customer:
customer = frappe.get_cached_doc("Customer", self.customer)
if customer.disabled:
frappe.throw(_("Customer {0} is disabled").format(self.customer))Child Table
def validate(self):
if not self.items:
frappe.throw(_("At least one item is required"))
for item in self.items:
if item.qty <= 0:
frappe.throw(_("Row {0}: Qty must be positive").format(item.idx))Workflow 2: Auto-Calculations
Child Table Totals
def validate(self):
for item in self.items:
item.amount = item.qty * item.rate
item.net_amount = item.amount - (item.discount_amount or 0)
self.net_total = sum(item.net_amount for item in self.items)
self.tax_amount = self.net_total * (self.tax_rate / 100)
self.grand_total = self.net_total + self.tax_amountRunning Totals
def validate(self):
running = 0
for item in self.items:
running += item.amount
item.running_total = runningWorkflow 3: Change Detection
Detect and Flag
TRACKED = ['status', 'priority', 'assigned_to']
def validate(self):
old = self.get_doc_before_save()
if not old:
return
changed = [f for f in TRACKED if getattr(old, f) != getattr(self, f)]
if changed:
self.flags.changed_fields = changed
def on_update(self):
if not self.flags.get('changed_fields'):
return
changes = []
old = self.get_doc_before_save()
for field in self.flags.changed_fields:
changes.append(f"{field}: {getattr(old, field)} -> {getattr(self, field)}")
self.add_comment("Edit", "<br>".join(changes))Workflow 4: Post-Save Notifications
Email on Status Change
def on_update(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
frappe.sendmail(
recipients=[self.owner],
subject=f"{self.doctype} {self.name}: {self.status}",
message=f"Status changed to {self.status}.")Background Email (Non-Blocking)
def on_update(self):
if self.flags.get('status_changed'):
frappe.enqueue(
'myapp.notifications.send_status_email',
queue='short', doc_name=self.name, doctype=self.doctype)Workflow 5: Linked Documents
Create Related Document on Insert
def after_insert(self):
frappe.get_doc({
"doctype": "Task",
"subject": f"Follow up: {self.name}",
"project": self.name,
"status": "Open"
}).insert(ignore_permissions=True)Sync Status to Linked Docs
def on_update(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
linked = frappe.get_all("Child DocType",
filters={"parent_ref": self.name}, pluck="name")
for name in linked:
frappe.db.set_value("Child DocType", name,
"parent_status", self.status)Workflow 6: Custom Naming
Prefix Based on Field
from frappe.model.naming import getseries
def autoname(self):
prefix = "CUST" if self.party_type == "Customer" else "SUPP"
self.name = getseries(f"P-{prefix}-", 3) # P-CUST-001Date-Based
def autoname(self):
year = frappe.utils.getdate(self.posting_date).year
self.name = getseries(f"INV-{year}-", 5) # INV-2025-00001Conditional Series
def before_naming(self):
self.naming_series = "PRIORITY-.#####" if self.is_priority else "STD-.#####"Workflow 7: Submittable Documents
class PurchaseOrder(Document):
def validate(self):
self.validate_items()
self.calculate_totals()
def before_submit(self):
if self.total > 100000 and not self.manager_approval:
frappe.throw(_("Approval required for POs over 100,000"))
def on_submit(self):
self.update_ordered_qty()
def before_cancel(self):
if self.has_linked_invoices():
frappe.throw(_("Cancel linked invoices first"))
def on_cancel(self):
self.reverse_ordered_qty()
def has_linked_invoices(self):
return frappe.db.exists("Purchase Invoice",
{"purchase_order": self.name, "docstatus": 1})Workflow 8: Controller Override
Full Override
# hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSalesInvoice"
}
# myapp/overrides.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call parent
self.apply_loyalty_discount()Event Handler (No Override)
# hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.si_validate",
"on_submit": "myapp.events.si_on_submit"
}
}
# myapp/events.py
def si_validate(doc, method=None):
validate_territory_discount(doc)Workflow 9: Background Jobs
def on_update(self):
if self.requires_heavy_processing():
frappe.enqueue(
'myapp.tasks.process_document',
queue='long', timeout=600,
doc_name=self.name)
# myapp/tasks.py
def process_document(doc_name):
doc = frappe.get_doc("MyDocType", doc_name)
# Heavy processing
doc.db_set("processed", 1)
frappe.db.commit()With Deduplication (v15+)
frappe.enqueue(
'myapp.tasks.sync_external',
queue='default',
job_id=f"sync_{self.name}",
deduplicate=True,
doc_name=self.name)Workflow 10: Permissions in Controller
Check Before Action
def on_submit(self):
if self.grand_total > 50000:
if not frappe.has_permission(self.doctype, "submit"):
frappe.throw(_("Not permitted for high-value docs"))Bypass for System Operations
def after_insert(self):
task = frappe.get_doc({"doctype": "Task", "subject": f"Follow up: {self.name}"})
task.flags.ignore_permissions = True
task.insert()Workflow 11: Type Annotations (v15+)
Enable in hooks.py:
export_python_type_annotations = TrueController gains auto-generated types:
class Person(Document):
# begin: auto-generated types
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
first_name: DF.Data
last_name: DF.Data
# end: auto-generated types