Loading...
Loading...
Implementation workflows and decision trees for ERPNext Server Scripts. Use when determining HOW to implement server-side features: document validation, automated calculations, API endpoints, scheduled tasks, permission filtering. Triggers: how do I implement server-side, when to use server script vs controller, which script type, build custom API, automate validation, schedule task, filter documents per user.
npx skill4agent add openaec-foundation/erpnext_anthropic_claude_development_skill_package erpnext-impl-serverscriptserpnext-syntax-serverscripts┌─────────────────────────────────────────────────────────────────────┐
│ ⚠️ ALL IMPORTS BLOCKED IN SERVER SCRIPTS │
├─────────────────────────────────────────────────────────────────────┤
│ import json → ImportError: __import__ not found │
│ from frappe.utils import → ImportError │
│ │
│ SOLUTION: Use pre-loaded namespace directly: │
│ frappe.utils.nowdate() frappe.parse_json(data) │
└─────────────────────────────────────────────────────────────────────┘┌───────────────────────────────────────────────────────────────────┐
│ WHAT DO YOU NEED? │
├───────────────────────────────────────────────────────────────────┤
│ │
│ ► No custom app / Quick prototyping │
│ └── Server Script ✓ │
│ │
│ ► Import external libraries (requests, pandas, etc.) │
│ └── Controller (in custom app) │
│ │
│ ► Complex multi-document transactions │
│ └── Controller (full Python, try/except/rollback) │
│ │
│ ► Simple validation / auto-fill / notifications │
│ └── Server Script ✓ │
│ │
│ ► Create REST API without custom app │
│ └── Server Script API type ✓ │
│ │
│ ► Scheduled background job │
│ └── Server Script Scheduler type ✓ (simple) │
│ └── hooks.py scheduler_events (complex) │
│ │
│ ► Dynamic list filtering per user │
│ └── Server Script Permission Query type ✓ │
│ │
└───────────────────────────────────────────────────────────────────┘WHAT DO YOU WANT TO ACHIEVE?
│
├─► React to document lifecycle (save/submit/cancel)?
│ └── Document Event
│ └── Which event? See event mapping below
│
├─► Create REST API endpoint?
│ └── API
│ ├── Public endpoint? → Allow Guest: Yes
│ └── Authenticated? → Allow Guest: No
│
├─► Run task on schedule (daily/hourly)?
│ └── Scheduler Event
│ └── Define cron pattern
│
└─► Filter list view per user/role/territory?
└── Permission Query
└── Return conditions string for WHERE clause| UI Name | Internal Hook | Best For |
|---|---|---|
| Before Validate | | Pre-validation setup |
| Before Save | | All validation + auto-calc |
| After Save | | Notifications, audit logs |
| Before Submit | | Submit-time validation |
| After Submit | | Post-submit automation |
| Before Cancel | | Cancel prevention |
| After Cancel | | Cleanup after cancel |
| After Insert | | Create related docs |
| Before Delete | | Delete prevention |
# Configuration:
# Type: Document Event
# DocType Event: Before Save
# Reference DocType: Sales Order
# Get customer's credit limit
credit_limit = frappe.db.get_value("Customer", doc.customer, "credit_limit") or 0
# Check outstanding
outstanding = frappe.db.get_value(
"Sales Invoice",
filters={"customer": doc.customer, "docstatus": 1, "status": "Unpaid"},
fieldname="sum(outstanding_amount)"
) or 0
# Validate
total_exposure = outstanding + doc.grand_total
if credit_limit > 0 and total_exposure > credit_limit:
frappe.throw(
f"Credit limit exceeded. Limit: {credit_limit}, Exposure: {total_exposure}",
title="Credit Limit Error"
)# Configuration:
# Type: Document Event
# DocType Event: Before Save
# Reference DocType: Purchase Order
# Calculate from child table
doc.total_qty = sum(item.qty or 0 for item in doc.items)
doc.total_amount = sum(item.amount or 0 for item in doc.items)
# Set derived fields
if doc.total_amount > 50000:
doc.requires_approval = 1
doc.approval_status = "Pending"
# Auto-fill from linked document
if doc.supplier and not doc.supplier_name:
doc.supplier_name = frappe.db.get_value("Supplier", doc.supplier, "supplier_name")# Configuration:
# Type: Document Event
# DocType Event: After Insert
# Reference DocType: Lead
# Create follow-up task
frappe.get_doc({
"doctype": "ToDo",
"allocated_to": doc.lead_owner or doc.owner,
"reference_type": "Lead",
"reference_name": doc.name,
"description": f"Follow up with new lead: {doc.lead_name}",
"date": frappe.utils.add_days(frappe.utils.today(), 1),
"priority": "High" if doc.status == "Hot" else "Medium"
}).insert(ignore_permissions=True)# Configuration:
# Type: API
# API Method: get_customer_dashboard
# Allow Guest: No
# Endpoint: /api/method/get_customer_dashboard
customer = frappe.form_dict.get("customer")
if not customer:
frappe.throw("Parameter 'customer' is required")
# Permission check
if not frappe.has_permission("Customer", "read", customer):
frappe.throw("Access denied", frappe.PermissionError)
# Aggregate data
orders = frappe.db.count("Sales Order", {"customer": customer, "docstatus": 1})
revenue = frappe.db.get_value(
"Sales Invoice",
filters={"customer": customer, "docstatus": 1},
fieldname="sum(grand_total)"
) or 0
frappe.response["message"] = {
"customer": customer,
"total_orders": orders,
"total_revenue": revenue
}# Configuration:
# Type: Scheduler Event
# Event Frequency: Cron
# Cron Format: 0 9 * * * (daily at 9:00)
today = frappe.utils.today()
overdue = frappe.get_all("Sales Invoice",
filters={
"status": "Unpaid",
"due_date": ["<", today],
"docstatus": 1
},
fields=["name", "customer", "owner", "due_date", "grand_total"],
limit=100
)
for inv in overdue:
days_overdue = frappe.utils.date_diff(today, inv.due_date)
# Create ToDo if not exists
if not frappe.db.exists("ToDo", {
"reference_type": "Sales Invoice",
"reference_name": inv.name,
"status": "Open"
}):
frappe.get_doc({
"doctype": "ToDo",
"allocated_to": inv.owner,
"reference_type": "Sales Invoice",
"reference_name": inv.name,
"description": f"Invoice {inv.name} is {days_overdue} days overdue (${inv.grand_total})"
}).insert(ignore_permissions=True)
frappe.db.commit() # REQUIRED in scheduler scripts# Configuration:
# Type: Permission Query
# Reference DocType: Customer
user_territory = frappe.db.get_value("User", user, "territory")
user_roles = frappe.get_roles(user)
if "System Manager" in user_roles:
conditions = "" # Full access
elif user_territory:
conditions = f"`tabCustomer`.territory = {frappe.db.escape(user_territory)}"
else:
conditions = f"`tabCustomer`.owner = {frappe.db.escape(user)}"| Client Script Calls | Server Script Provides |
|---|---|
| API type script |
| Direct DB (no script needed) |
| Controller method (not Server Script) |
// CLIENT: Call server API
frappe.call({
method: 'check_credit_limit',
args: {
customer: frm.doc.customer,
amount: frm.doc.grand_total
},
callback: function(r) {
if (!r.message.allowed) {
frappe.throw(__('Credit limit exceeded'));
}
}
});# SERVER: API script 'check_credit_limit'
customer = frappe.form_dict.get("customer")
amount = frappe.utils.flt(frappe.form_dict.get("amount"))
credit_limit = frappe.db.get_value("Customer", customer, "credit_limit") or 0
outstanding = frappe.db.get_value(
"Sales Invoice",
{"customer": customer, "docstatus": 1, "status": "Unpaid"},
"sum(outstanding_amount)"
) or 0
frappe.response["message"] = {
"allowed": (outstanding + amount) <= credit_limit or credit_limit == 0,
"available": max(0, credit_limit - outstanding)
}frappe.utils.*frappe.db.*frappe.throw()frappe.db.commit()limit| Rule | Why |
|---|---|
NO | Sandbox blocks all imports |
| Changes not auto-committed |
NO | Framework handles save |
| Stops document operation |
| Always escape user input in SQL | Prevent SQL injection |
Add | Prevent memory issues |
erpnext-syntax-serverscriptserpnext-errors-serverscriptserpnext-databaseerpnext-permissionserpnext-api-patterns