[Tutorial] Script Reports in ERPNext: A Step-by-Step Guide

Summary:

Unlock the power of custom reporting in ERPNext with this comprehensive step-by-step guide. Dive into the world of script reports, where you’ll learn to harness scripting capabilities to create tailored reports that provide valuable insights for your business. From setting up the environment to writing scripts and designing dynamic reports, this guide covers it all. Elevate your reporting game and gain full control over your ERPNext data.

Reference Documentation

Create a report

I’m going to create a simple report with simple filters using the Employee Checkin Doctype data.

image

Python

employee_checkin_sheet.py

import frappe
from frappe import _


def execute(filters=None):
	columns, data = get_columns(filters=filters), get_datas(filters=filters)
	return columns, data



def get_columns(filters=None):
    return [
  		{
			"label": _("Employee"),
        		"fieldname": "employee",
          		"fieldtype": "Link",
	           	"options": "Employee",
	           	"width": 100
	        },
  		{
			"label": _("Employee Name"),
	        	"fieldname": "employee_name",
	          	"fieldtype": "Data",
	           	"width": 150
	        },
  		{
			"label": _("Log Type"),
	        	"fieldname": "log_type",
	          	"fieldtype": "Data",
	           	"width": 100,
			"align": 'center',
			"dropdown": False
	        },
  		{
			"label": _("Time"),
	        	"fieldname": "time",
	          	"fieldtype": "Datetime",
	           	"width": 200
	        },
  		{
			"label": _("Auto Attenadnce"),
	        	"fieldname": "auto_attendance",
	          	"fieldtype": "Check",
	           	"width": 150
	        },
	]


def get_datas(filters=None):
    
	data = frappe.get_all(
		'Employee Checkin',
		filters={
			'employee': filters.employee
		},
		fields=['employee', 'employee_name', 'log_type', 'time', 'skip_auto_attendance']
	)

	return data

JS File

employee_checkin_sheet.js

frappe.query_reports["Employee Checkin Sheet"] = {
	"filters": [
		{
			"label": "Employee",
        		"fieldname": "employee",
          		"fieldtype": "Link",
			"options":'Employee',
           		"width": 200,
			"reqd": 1
        	}
	]
}

What are all the events or functions we can use in JS

  • onload
    Basically onload you can used to make a server call and fetch data and you can set in filters
    • onload:function(){
      alert('onload')
       }
      
  • formatter
    Formatter used to change the format and look of the report
    formatter:function (value, row, column, data, default_formatter) {
      value = default_formatter(value, row, column, data);
    	return value
    }
    
  • get_chart_data
    This function gets triggered when the data is rendered in the report
    get_chart_data: function (_columns, result) {
      console.log(_columns, result)
    }
    

Column Settings

You can set some settings in the columns like some features.
Here a small example.

check in the python file I mentioned above.

{
	"label": _("Employee"),
	"fieldname": "employee",
	"fieldtype": "Link",
	"options": "Employee",
	"width": 100
}

Here you can see label, fieldname, fieldtype, options & width but there is other more options is there here some of the options

Options Description
align It's used to align the content in the cell left, right, center
colIndex It's used to change the column position Integer value
column To give a name for a column to handle in diffrent way same like fieldname
focusable When you click a cell its show which cell you clicked but its in False it won't show
resizable Won't allow you to resize the report
sortOrder String type 'asc', 'desc', 'none'

Change the color of the cell

we can achieve this through formatter

In the report you can see the IN & OUT log type i want to make the IN bold green and OUT bold red. the column field name is log_type

formatter:function (value, row, column, data, default_formatter) {
	value = default_formatter(value, row, column, data);

	if(column.fieldname == 'log_type'){
		if(value == 'IN'){
			value = `<b style="color:green">${value}</b>`
		}else if(value == 'OUT'){
			value = `<b style="color:red">${value}</b>`
		}
	}
}
  • value → Argument Gives the current cell value
  • row → This argument gives all the details of the current cell row
  • column → This argument gives the column details of the current cell
  • data → This argument gives the current row data
  • default_formatter it’s a formatter funtion
    image

Button In Report

Model 1

Add one button in filters in the js file like this

{
	"label": "Take Action",
	"fieldname": "custom_take_action",
	"fieldtype": "Button",
	"width": 200,
}

and add this code at the bottom of the js file

$(document).on("click", "button[data-fieldname='custom_take_action']", function() {
    // Your code to be executed when the element is clicked
    alert("Taking Action");
});

The final output
image

Basically, you can use this feature for any other operations for example load the css file
or send mail. some custom operations for these things you can use.

Model 2

Button in report cell. you can perform any operation from that. First create a column like this
in python file employee_checkin_sheet.py

{
	"label": _("Send Mail"),
	"fieldname": "send_mail",
	"fieldtype": "Data",
	"width": 150,
	"align": "center"
}

employee_checkin_sheet.js

In the formatter change add this line

try{
	if (column.id == 'send_mail'){
	value = `<button class="btn-primary" id="sent-email-${row[0].rowIndex}" data-employee="${data.employee}" data-employee_name="${data.employee}" data-log_type="${data.log_type}" onclick="send_mail(${row[0].rowIndex})">Send Mail</button>`
	return value
}
}catch(err){
	console.log('Error throwing because of last column its -> Total Row')
	console.log(err)
}

After adding the above line in the formatter add this send_mail function in the botom of the JS filr

const send_mail = (row_index) => {


	button = document.getElementById(`sent-email-${row_index}`)

	var employee = button.getAttribute('data-employee');
        var employee_name = button.getAttribute('data-employee_name');
    	var log_type = button.getAttribute('data-log_type');

	frappe.msgprint({
		title: __('Notification'),
		indicator: 'green',
		message: __(`Mail Sent successfully to <b>${employee_name}</b> Log Type <b>${log_type}</b>`)
	});
}

The final output like this
image

Change the background color

I want to make the all row background color as a antiquewhite for log type is OUT

Actually changing the background color of the row is not that much easy. Inspect the row you can see the structure of the row in html like this
image

You can see the class="dt-row dt-row-0 vrow" in that class dt-row-0 is unique and we can apply the CSS for each row we have to find the what are all the row contains the log_type OUT which means the rowIndex.

Row inside a row every cell contains class like this class="dt-cell dt-cell--col-0 dt-cell--0-4 dt-cell--row-4" here the sample image.
image

Here you can see lots of class but im chosing the class is this dt-cell. note be carefull while using this class it will fetch all the cell.

I’m Creating a button in js file in filters

{
	"label": '<b style="color:blue">Apply CSS</b>',
	"fieldname": "custom_apply_css",
	"fieldtype": "Button",
	"width": 150,
}

Finding rowIndex OUT for the rows. At the top of the JS file I’m putting a global variable like this

var log_type_out_rowIndex = []

in the formatter, pushing the rowIndex in the log_type_out_rowIndex variable

try{
	if(column.fieldname == 'log_type'){
		if(value == 'OUT'){
			log_type_out_rowIndex.push(row[0].rowIndex)
		}
	}
}catch(err){
	console.log('Total row table error')
	console.log(err)
}

At the bottom of the JS file im creating a event listner

$(document).on("click", "button[data-fieldname='custom_apply_css']", function() {

	console.log(log_type_out_rowIndex)

	if(log_type_out_rowIndex.length){
		for(index of log_type_out_rowIndex){
			const element = document.getElementsByClassName(`dt-row-${index}`);
			element[0].style.background = 'antiquewhite';
		}
	}
	const rowCell = document.getElementsByClassName(`dt-cell`);
	for(cell of rowCell){
		cell.style.background = 'inherit'
	}
});

Final output
image

Editable Column or Cell

Adding one more column in the report in the Python file

{
	"label": _('<b style="color:blue">Edit Me</b>'),
	"fieldname": "edit_me",
	"fieldtype": "Data",
	"width": 150,
	"align": "center"
}

image

Add this code in the formatter

// Edit the column
try{
	if(column.fieldname == 'edit_me'){
		console.log(row, column)
		column.editable = true
	}
}catch(err){
	console.log('Total row table error')
	console.log(err)
}

Here is the final output
image

I can able to edit but while exporting in pdf, excel or print it’s not showing. if anyone has an idea please suggest.

Kindly share your thoughts and please support with your likes and comments

Github: Antony-M1 · GitHub
Linkedin: Antony
Source Code: https://github.com/Antony-M1/erpnext-doc/tree/main/employee_checkin_sheet

33 Likes

Hello, @Antony_Praveenkumar! Thank you for providing this information. However, I’m interested in learning more about Dynamic Link, specifically child table items.

For Dynamic Link you need one Link field with options of DocType which means in the options you have to mention this DocType, then create another field with Dynamic Link in that field Options mention the Link field name

for example:

Field 1
Label Name: Voucher Type
fieldname: voucher_type
Data Type: Link
Options: Doctype

Field 2
Label Name: Voucher No
fieldname: voucher_no
Data Type: Dynamic Link
Options: voucher_type

1 Like

but there is a child table like item

Child Table you can’t able to integrate in Dynamic Link

Ok Thank you

Thanks for the explanation! Is there any way to allow text wrap in the cells?

Hello,

Show options for each cell of a specific column in script report

How can we achieve this functionality similar to default report view for select field.
Could you help me @Antony_Praveenkumar with this?

Regards,
Akash Tomar

How to Create Custom Reports in Frappe

This comprehensive guide explains how to create custom reports in Frappe Framework, based on analysis of real-world examples from my experience. Frappe supports three main types of reports: Script Reports , Query Reports and Report Builder.

Frappe lets you build custom reports to display data from your system. Two common types are Script Reports and Query Reports. Script Reports use Python code to prepare data, while Query Reports use a raw SQL query. This guide explains how to create each type, what files are involved, and includes simple code examples. It assumes you have Developer Mode enabled and basic knowledge of Frappe/App structure.

Table of Contents

  1. Report Types Overview
  2. File Structure
  3. Script Report: Step-by-Step
  4. Query Report: Step-by-Step
  5. Key Files and Their Roles
  6. Advanced Features
  7. Best Practices
  8. Column Format Specifications

1. Report Types Overview

Script Report

  • Most flexible and powerful
  • Uses Python for data processing and complex logic
  • Supports dynamic columns, custom calculations, and data aggregation
  • Requires Python (.py) file for backend logic
  • Optional JavaScript (.js) file for frontend filters and interactions

Query Report

  • Simple and direct
  • Uses raw SQL queries defined in JSON configuration
  • Best for straightforward data extraction
  • No Python file needed
  • Optional JavaScript (.js) file for filters

Report Builder

  • Visual/GUI-based
  • Created through Frappe’s web interface
  • Uses JSON configuration with visual settings
  • Limited customization but user-friendly
  • No coding required

2. File Structure

Every custom report follows this directory structure:

apps/[app_name]/[app_name]/[module]/report/[report_name]/
├── __init__.py                    # Required Python package file
├── [report_name].json            # Report configuration (required)
├── [report_name].py              # Python logic (Script Reports only)
└── [report_name].js              # Frontend filters/logic (optional)

3. Script Report: Step-by-Step

Script Reports are reports written in Python. They give you full flexibility (you can run complex logic, multiple queries, charts, etc.), but require Administrator rights and Developer Mode.

It basically depends on: [python Dict datatype + SQL Query] that’s it, literally that’s it

Step 1: Create Report via UI

  1. Enable Developer Mode and log in as an Administrator.
  2. Go to Report List in your app
  3. Click New Report
  4. Fill basic details:
    • Report Name: “My Custom Report”
    • Module: Select your target module
    • Report Type: “Script Report”
    • Reference DocType: Base doctype for the report (that we will Query data from)
    • Set Is Standard = Yes
  5. Save the report. Frappe will create a folder in your app with boilerplate files.
  6. report_name.py: implement execute(filters=None) function.
  7. report_name.js: (Optional) define any filters.

Key files for a Script Report

  • report_name.py – Contains the Python logic.
  • report_name.js – (Optional) Defines filters.
  • report_name.json – Metadata (auto-generated).

Quick Example – Simple Script Report

def execute(filters=None):
    columns = [
        {"fieldname": "account", "label": "Account", "fieldtype": "Link", "options": "Account"},
        {"fieldname": "balance", "label": "Balance", "fieldtype": "Currency"}
    ]
    data = [
        {"account": "Cash", "balance": 100},
        {"account": "Bank", "balance": 250}
    ]
    return columns, data

Quick Example Filter (JS)

frappe.query_reports["My Report"] = {
    filters: [
        {
            fieldname: "start_date",
            label: __("Start Date"),
            fieldtype: "Date"
        },
        {
            fieldname: "end_date",
            label: __("End Date"),
            fieldtype: "Date"
        }
    ]
};

Step 2: Create Python Logic

File: [report_name].py

# Copyright (c) 2024, Your Company and contributors
# For license information, please see license.txt

import frappe
from frappe import _

def execute(filters=None):
    """Main entry point for the report"""
    filters = frappe._dict(filters or {})
    columns = get_columns(filters)
    data = get_data(filters)
    return columns, data

def get_columns(filters):
    """Define report columns"""
    return [
        {
            "label": _("ID"),
            "fieldtype": "Link",
            "fieldname": "name",
            "options": "Your DocType",
            "width": 120,
        },
        {
            "label": _("Status"),
            "fieldtype": "Data",
            "fieldname": "status",
            "width": 100,
        },
        {
            "label": _("Amount"),
            "fieldtype": "Currency",
            "fieldname": "amount",
            "width": 120,
        }
    ]

def get_data(filters):
    """Fetch and process data"""
    conditions = []
    values = {}

    if filters.get("organization"):
        conditions.append("organization = %(organization)s")
        values["organization"] = filters.organization

    where_clause = " AND ".join(conditions)
    if where_clause:
        where_clause = "WHERE " + where_clause

    query = f"""
        SELECT
            name,
            status,
            amount
        FROM `tabYour DocType`
        {where_clause}
        ORDER BY creation DESC
    """

    return frappe.db.sql(query, values=values, as_dict=True)

Step 4: Create JavaScript Filters (Optional)

File: [report_name].js

// Copyright (c) 2024, Your Company and contributors
// For license information, please see license.txt

frappe.query_reports["My Custom Report"] = {
    "filters": [
        {
            fieldname: "organization",
            label: __("organization"),
            fieldtype: "Link",
            options: "organization",
            default: frappe.defaults.get_user_default("organization"),
            reqd: 1,
        },
        {
            fieldname: "from_date",
            label: __("From Date"),
            fieldtype: "Date",
            default: frappe.datetime.year_start(),
            reqd: 1,
        },
        {
            fieldname: "to_date",
            label: __("To Date"),
            fieldtype: "Date",
            default: frappe.datetime.year_end(),
            reqd: 1,
        }
    ]
};

4. Query Report: Step-by-Step

Query Reports use SQL queries to generate reports.

Steps to create a Query Report

Same as Script Report, but select “Query Report” as Report Type.

  1. Go to “New Report” and choose:
    • Report Type = “Query Report”
    • Reference DocType
    • Module
  2. Enter SQL in the “Query” field.
  3. (Optional) Add filters via report_name.js
  4. Use %(filter)s syntax to inject filter values in SQL.

Example – Simple Query Report

SELECT
  name AS "Name:Link/Item:200",
  stock_uom AS "UOM:Data:100",
  ifnull(sum(bin.actual_qty),0) AS "Stock:Float:100"
FROM `tabItem`
LEFT JOIN `tabBin` bin ON bin.item_code = `tabItem`.name
GROUP BY `tabItem`.name, `tabItem`.stock_uom;

Example Filter (JS)

frappe.query_reports["My Query Report"] = {
    filters: [
        {
            fieldname: "item_code",
            label: __("Item Code"),
            fieldtype: "Link",
            options: "Item"
        },
        {
            fieldname: "status",
            label: __("Status"),
            fieldtype: "Select",
            options: ["Open", "Closed"],
            default: "Open"
        }
    ]
};

5. Key Files and Their Roles

JSON Configuration File (.json)

  • Purpose: Defines report metadata and configuration
  • Required for: All report types
  • Key fields:
    • report_type: “Script Report”, “Query Report”, or “Report Builder”
    • ref_doctype: Base DocType for the report
    • query: SQL query (Query Reports only)
    • roles: User roles that can access the report
    • add_total_row: Whether to add totals row

Python Logic File (.py)

  • Purpose: Contains backend logic for data processing
  • Required for: Script Reports only
  • Key functions:
    • execute(filters): Main entry point, returns (columns, data)
    • get_columns(filters): Defines report columns
    • get_data(filters): Fetches and processes data

JavaScript File (.js)

  • Purpose: Defines frontend filters and interactions
  • Required for: Optional for all report types
  • Key features:
    • Filter definitions
    • Dynamic filter behavior
    • Custom formatting
    • Event handlers

Package File (init.py)

  • Purpose: Makes directory a Python package
  • Required for: All reports
  • Content: Usually empty

6. Advanced Features

Dynamic Columns

def get_columns(filters, project_types):
    columns = [
        {"label": _("Product"), "fieldtype": "Data", "fieldname": "product"}
    ]

    # Add dynamic columns based on data
    for project_type in project_types:
        columns.append({
            "label": _(project_type["title"]),
            "fieldtype": "Int",
            "fieldname": project_type["id"],
        })

    return columns

Complex Filters with Dependencies

{
    fieldname: "field_name",
    label: __("Field Name"),
    fieldtype: "Link",
    options: "DocType",
    get_query: function() {
        return {
            filters: {
                "field_name": frappe.query_report.get_filter_value("field_name")
            }
        };
    }
}

Conditional Logic in Filters

{
    fieldname: "time_window",
    label: __("Time Window"),
    fieldtype: "Select",
    options: ['Annual', 'Quarterly', 'Monthly'],
    on_change: function(report) {
        const value = this.get_value();
        if (value == "Annual") {
            report.set_filter_value('from_date', frappe.datetime.year_start());
            report.set_filter_value('to_date', frappe.datetime.year_end());
        }
        report.refresh();
    }
}

Multi-Select Filters

{
    fieldname: "account",
    label: __("Account"),
    fieldtype: "MultiSelectList",
    options: "Account",
    get_data: function (txt) {
        return frappe.db.get_link_options("Account", txt, {
            company: frappe.query_report.get_filter_value("organization"),
        });
    },
}

Data Aggregation and Processing

def get_dynamic_aggregation(filters, group_by_field, dynamic_field, dynamic_items, aggregation_field, base_tables):
    """
    Generate and execute a dynamic SQL aggregation query.

    Args:
        filters (dict): Dictionary of filter values to pass to SQL (used in WHERE clause).
        group_by_field (str): Field to group results by (e.g., 'product_name').
        dynamic_field (str): Field used to create dynamic aggregation columns (e.g., 'project_type').
        dynamic_items (list): List of objects with 'id' and 'title' to define dynamic columns.
        aggregation_field (str): Field to sum in each dynamic column (e.g., 'amount').
        base_tables (str): Inner SQL query string to be used as the base table for aggregation.

    Returns:
        list: Query result as list of dictionaries.
    """
    # Start SELECT statement
    query = f"SELECT {group_by_field},"

    # Add dynamic columns safely
    query += "\n" + ",\n".join([
        f"SUM(CASE WHEN {dynamic_field} = %(val_{i})s THEN {aggregation_field} ELSE 0 END) AS \"{item.title}\""
        for i, item in enumerate(dynamic_items)
    ])

    # Add total aggregation and FROM
    query += f",\nSUM({aggregation_field}) AS total\nFROM (\n{base_tables}\n) AS inner_query\n"
    query += f"GROUP BY {group_by_field}"

    # Add dynamic values to filters
    for i, item in enumerate(dynamic_items):
        filters[f"val_{i}"] = item.id

    return frappe.db.sql(query, values=filters, as_dict=True)

# Inputs
filters = {"organization": "Org001"}
project_types = [{"id": "TypeA", "title": "Agriculture"}, {"id": "TypeB", "title": "Technology"}]

group_by = "product_name"
dynamic_field = "project_type"
aggregation_field = "total_amount"
base_sql = """
    SELECT
        A.group_label_field AS group_by_value,
        B.dynamic_category_field AS dynamic_value,
        SUM(C.aggregation_metric_field) AS aggregated_total
    FROM `tabMainTable` C
    JOIN `tabJoinTable1` B ON B.foreign_key_1 = C.primary_key
    JOIN `tabJoinTable2` A ON B.foreign_key_2 = A.primary_key
    WHERE C.filter_field = %(filter_param)s
    GROUP BY A.group_label_field, B.dynamic_category_field
"""

# Call the generalized function
result = get_dynamic_aggregation(filters, group_by, dynamic_field, project_types, aggregation_field, base_sql)

7. Best Practices

Performance

  • Use parameterized queries to prevent SQL injection
  • Add appropriate WHERE clauses to limit data
  • Use indexes on filtered columns
  • Consider pagination for large datasets

Security

  • Always validate and sanitize filter inputs
  • Use frappe.db.sql() with values parameter
  • Set appropriate role permissions
  • Avoid exposing sensitive data

Code Organization

  • Keep functions small and focused
  • Use meaningful variable names
  • Add proper error handling
  • Include comprehensive comments

User Experience

  • Provide sensible default filter values
  • Use appropriate field types for filters
  • Add helpful labels and descriptions
  • Consider mobile responsiveness

8. Column Format Specifications

Column Definition Formats

# Method 1: Dictionary format (recommended)
{
    "label": _("Amount"),
    "fieldtype": "Currency",
    "fieldname": "amount",
    "width": 120,
    "options": "default_currency"  # For currency fields
}

# Method 2: String format (shorthand)
"Amount:Currency:120"
"ID:Link/DocType:90"
"Status::80"  # Auto-width

Common Field Types

  • Data: Plain text
  • Link: Reference to another DocType
  • Currency: Monetary values
  • Int: Integer numbers
  • Float: Decimal numbers
  • Date: Date values
  • Datetime: Date and time values
  • Check: Boolean checkbox

Script vs Query Reports

Feature Script Report Query Report
Language Python SQL
Role Needed Administrator System Manager
File Types .py, .js, .json .json, .js
Flexibility High Moderate
Filters Support Yes (via JS) Yes (via JS + SQL)

Typical File Structure

/apps/your_app/your_app/your_module/report/your_report/
    ├── your_report.py
    ├── your_report.js
    └── your_report.json

Conclusion

Script Reports offer the most control and customization but require developer access. Query Reports are easier to write and ideal for simpler tabular views using SQL. Use the type that best fits your use case.


More Usefull Resources


This comprehensive documentation provides everything needed to create custom reports in Frappe, from basic examples to advanced features used in production applications.

2 Likes