- ERPNext Server Scripts - Implementation
- This skill helps you determine HOW to implement server-side features. For exact syntax, see
- erpnext-syntax-serverscripts
- .
- Version
-
- v14/v15/v16 compatible
- CRITICAL: Sandbox Limitation
- ┌─────────────────────────────────────────────────────────────────────┐
- │ ⚠️ 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) │
- └─────────────────────────────────────────────────────────────────────┘
- Main Decision: Server Script vs Controller?
- ┌───────────────────────────────────────────────────────────────────┐
- │ 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 ✓ │
- │ │
- └───────────────────────────────────────────────────────────────────┘
- Rule of thumb
-
- Server Scripts for no-code/low-code solutions within Frappe's sandbox. Controllers for full Python power.
- Decision Tree: Which Script 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
- → See
- references/decision-tree.md
- for complete decision tree.
- Event Name Mapping (Document Event)
- UI Name
- Internal Hook
- Best For
- Before Validate
- before_validate
- Pre-validation setup
- Before Save
- validate
- All validation + auto-calc
- After Save
- on_update
- Notifications, audit logs
- Before Submit
- before_submit
- Submit-time validation
- After Submit
- on_submit
- Post-submit automation
- Before Cancel
- before_cancel
- Cancel prevention
- After Cancel
- on_cancel
- Cleanup after cancel
- After Insert
- after_insert
- Create related docs
- Before Delete
- on_trash
- Delete prevention
- Implementation Workflows
- Workflow 1: Validation with Conditional Logic
- Scenario
- Validate sales order based on customer credit limit.
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"
- )
- Workflow 2: Auto-Calculate and Auto-Fill
- Scenario
- Auto-calculate totals and set derived fields.
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"
- )
- Workflow 3: Create Related Document
- Scenario
- Create ToDo when document is inserted.
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
- )
- Workflow 4: Custom API Endpoint
- Scenario
- Create API to fetch customer dashboard data.
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
- }
- Workflow 5: Scheduled Task
- Scenario
- Daily reminder for overdue invoices.
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
- Workflow 6: Permission Query
- Scenario
- Filter documents by user's territory.
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
)
}
"
→ See
references/workflows.md
for more workflow patterns.
Integration: Client Script + Server Script
Client Script Calls
Server Script Provides
frappe.call({method: 'api_name'})
API type script
frappe.db.get_value()
Direct DB (no script needed)
frm.call('method')
Controller method (not Server Script)
Combined Pattern
// 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 ) } Checklist: Implementation Steps New Server Script Feature [ ] Determine script type Document lifecycle? → Document Event Custom API? → API Scheduled job? → Scheduler Event List filtering? → Permission Query [ ] Check sandbox limitations No imports needed? → Proceed Need imports? → Use Controller instead [ ] Implement core logic Use frappe.utils. directly Use frappe.db. for database [ ] Add validation & error handling frappe.throw() for user errors Input validation for API scripts [ ] Test edge cases Empty values (null checks) Permission scenarios Large data volumes (add limits) [ ] Scheduler-specific Add frappe.db.commit() at end Add limit to queries Batch process large datasets Critical Rules Rule Why NO import statements Sandbox blocks all imports frappe.db.commit() in Scheduler Changes not auto-committed NO doc.save() in Before Save Framework handles save frappe.throw() for validation Stops document operation Always escape user input in SQL Prevent SQL injection Add limit to queries Prevent memory issues