Back to skills
extension
Category: Development & EngineeringNo API key required

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.

personAuthor: jakexiaohubgithub

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

  1. [ ] Determine script type

    • Document lifecycle? → Document Event
    • Custom API? → API
    • Scheduled job? → Scheduler Event
    • List filtering? → Permission Query
  2. [ ] Check sandbox limitations

    • No imports needed? → Proceed
    • Need imports? → Use Controller instead
  3. [ ] Implement core logic

    • Use frappe.utils.* directly
    • Use frappe.db.* for database
  4. [ ] Add validation & error handling

    • frappe.throw() for user errors
    • Input validation for API scripts
  5. [ ] Test edge cases

    • Empty values (null checks)
    • Permission scenarios
    • Large data volumes (add limits)
  6. [ ] 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 |

Related Skills

  • erpnext-syntax-serverscripts — Exact syntax and method signatures
  • erpnext-errors-serverscripts — Error handling patterns
  • erpnext-database — frappe.db.* operations
  • erpnext-permissions — Permission system details
  • erpnext-api-patterns — API design patterns

→ See references/examples.md for 10+ complete implementation examples.