Claude-skill-registry erpnext-impl-serverscripts
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.
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-impl-serverscripts" ~/.claude/skills/majiayu000-claude-skill-registry-erpnext-impl-serverscripts && rm -rf "$T"
skills/data/erpnext-impl-serverscripts/SKILL.mdERPNext 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 | | 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 |
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 |
|---|---|
| API type script |
| Direct DB (no script needed) |
| 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
directlyfrappe.utils.* - Use
for databasefrappe.db.*
- Use
-
[ ] Add validation & error handling
for user errorsfrappe.throw()- Input validation for API scripts
-
[ ] Test edge cases
- Empty values (null checks)
- Permission scenarios
- Large data volumes (add limits)
-
[ ] Scheduler-specific
- Add
at endfrappe.db.commit() - Add
to querieslimit - Batch process large datasets
- Add
Critical Rules
| Rule | Why |
|---|---|
NO statements | Sandbox blocks all imports |
in Scheduler | Changes not auto-committed |
NO in Before Save | Framework handles save |
for validation | Stops document operation |
| Always escape user input in SQL | Prevent SQL injection |
Add to queries | Prevent memory issues |
Related Skills
— Exact syntax and method signatureserpnext-syntax-serverscripts
— Error handling patternserpnext-errors-serverscripts
— frappe.db.* operationserpnext-database
— Permission system detailserpnext-permissions
— API design patternserpnext-api-patterns
→ See references/examples.md for 10+ complete implementation examples.