Print, Reports & Dashboards
Every franchise operation generates mountains of paperwork — invoices, stock reports, performance summaries. This chapter teaches you how to create professional print outputs, build powerful reports, and assemble dashboards that give ScoopJoy’s franchise owners real-time visibility into their business.
Print Formats
Section titled “Print Formats”Print Formats control how documents look when printed or exported to PDF. Frappe provides three approaches: the built-in Standard format, the drag-and-drop Print Format Builder, and fully custom HTML/Jinja templates.
Standard vs custom print formats
Section titled “Standard vs custom print formats”Every DocType gets a Standard print format automatically. It renders all visible fields in a clean, default layout. When you need branding, custom layouts, or specialized logic, you create a Custom print format.
To create one via Desk:
- Navigate to the Print Format list (search “Print Format” in the Awesomebar).
- Click + Add Print Format.
- Set a name (e.g., “Franchise POS Receipt”).
- Select the DocType (e.g., “POS Invoice”).
- Set Standard to “No”.
- Choose your format type: Print Format Builder (drag-and-drop) or Custom HTML (Jinja).
Print Format Builder (drag-and-drop)
Section titled “Print Format Builder (drag-and-drop)”The builder lets non-developers create layouts by dragging fields, tables, and HTML blocks into a visual editor. You can:
- Reorder fields and sections
- Add Custom HTML blocks with Jinja expressions
- Include/exclude specific fields
- Set column widths for child tables
This is ideal for quick customizations like hiding internal fields or rearranging the invoice layout.
HTML/Jinja print format: the full-control approach
Section titled “HTML/Jinja print format: the full-control approach”For complete control, write raw HTML with Jinja templating. In a custom print format with “Custom Format” checked, every variable is available.
Key variables available in Jinja templates:
| Variable | Description |
|---|---|
doc | The document being printed |
doc.items | Child table rows (e.g., line items) |
doc.name | Document ID |
doc.meta | DocType metadata |
frappe | The frappe module (utilities, formatting) |
frappe.utils | Date, number, currency utilities |
nowdate() | Current date |
frappe.format() | Format values based on field type |
frappe.db.get_value() | Fetch data from other DocTypes |
A basic Jinja structure loops over doc.items and formats currency fields with
frappe.format():
<!-- Custom Print Format: Franchise Invoice --><div class="print-format"> <div style="text-align: center; margin-bottom: 20px;"> <h2>{{ doc.company }}</h2> <p>{{ frappe.db.get_value("Company", doc.company, "company_description") or "" }}</p> <p>Tax ID: {{ doc.tax_id or "N/A" }}</p> </div>
<hr>
<table class="table table-condensed"> <thead> <tr> <th style="width: 40%;">Item</th> <th style="width: 15%; text-align: right;">Qty</th> <th style="width: 20%; text-align: right;">Rate</th> <th style="width: 25%; text-align: right;">Amount</th> </tr> </thead> <tbody> {% for item in doc.items %} <tr> <td>{{ item.item_name }}</td> <td style="text-align: right;">{{ item.qty }}</td> <td style="text-align: right;">{{ frappe.format(item.rate, {"fieldtype": "Currency"}) }}</td> <td style="text-align: right;">{{ frappe.format(item.amount, {"fieldtype": "Currency"}) }}</td> </tr> {% endfor %} </tbody> </table>
<hr>
<div style="text-align: right;"> <p><strong>Net Total:</strong> {{ frappe.format(doc.net_total, {"fieldtype": "Currency"}) }}</p> {% for tax in doc.taxes %} <p>{{ tax.description }}: {{ frappe.format(tax.tax_amount, {"fieldtype": "Currency"}) }}</p> {% endfor %} <h3>Grand Total: {{ frappe.format(doc.grand_total, {"fieldtype": "Currency"}) }}</h3> </div></div>Calling custom Python methods in print formats
Section titled “Calling custom Python methods in print formats”For complex logic, define a whitelisted Python function and call it from Jinja:
import frappe
# No allow_guest needed — print formats render server-side with session user context@frappe.whitelist()def get_loyalty_points(customer): """Fetch loyalty points for receipt footer.""" points = frappe.db.get_value( "Loyalty Point Entry", {"customer": customer, "docstatus": 1}, "sum(loyalty_points)" ) return points or 0Use it in the print format with {% set %} to call the method and bind the
result to a variable, then render {{ points }}:
{% set points = frappe.call( "scoopjoy.scoopjoy.utils.print_helpers.get_loyalty_points", customer=doc.customer) %}<p>Your Loyalty Points: {{ points }}</p>Letter Head setup
Section titled “Letter Head setup”Letter Heads define the header and footer for all print formats. Configure via the Letter Head DocType:
- Letter Head Name: e.g., “ScoopJoy Franchise Standard”
- Image: Upload company logo
- Header HTML: Custom HTML for the header area
- Footer HTML: Custom HTML for footer (address, phone, legal text)
- Is Default: Check to apply to all documents
<!-- Example Letter Head - Header HTML --><div style="display: flex; align-items: center; border-bottom: 3px solid #e74c3c;"> <img src="/files/scoopjoy-logo.png" style="height: 60px; margin-right: 15px;"> <div> <h2 style="margin: 0; color: #e74c3c;">ScoopJoy Franchise Pvt Ltd</h2> <p style="margin: 0; font-size: 11px;"> 123 Ice Cream Blvd, Delhicious City | GSTIN: 07AABCS1234A1Z5 | Ph: +91-11-12345678 </p> </div></div>PDF generation: frappe.utils.pdf.get_pdf()
Section titled “PDF generation: frappe.utils.pdf.get_pdf()”Frappe uses wkhtmltopdf (or the newer Chromium engine in recent versions) to convert HTML to PDF. You can render a print format to PDF bytes, attach it to a document, or email it:
import frappefrom frappe.utils.pdf import get_pdf
# Method 1: Get PDF content as byteshtml = frappe.get_print("Sales Invoice", "ACC-SINV-2025-00001", print_format="Franchise POS Receipt")pdf_content = get_pdf(html)
# Method 2: Attach PDF to a documentfrom frappe.utils.file_manager import save_filesave_file( "invoice.pdf", pdf_content, "Sales Invoice", "ACC-SINV-2025-00001", is_private=1)
# Method 3: Send PDF via emailfrappe.sendmail( recipients=["franchise_owner@example.com"], subject="Invoice ACC-SINV-2025-00001", message="Please find your invoice attached.", attachments=[{ "fname": "invoice.pdf", "fcontent": pdf_content }])The same PDF can be downloaded directly over REST:
GET /api/method/frappe.utils.print_format.download_pdf ?doctype=Sales Invoice &name=ACC-SINV-2025-00001 &format=Franchise POS Receipt &no_letterhead=0Print Settings
Section titled “Print Settings”Configure global print behaviour at Print Settings:
| Setting | Description |
|---|---|
| PDF Page Size | A4, Letter, Legal, or custom dimensions |
| Print Style | Visual theme (Modern, Classic, Monochrome, etc.) |
| Send Print as PDF | Attach PDF when emailing documents |
| Repeat Header and Footer | Repeat letter head on each page |
| Allow Print for Draft | Enable printing before submission |
| Allow Print for Cancelled | Enable printing cancelled docs |
| PDF Generator | wkhtmltopdf (legacy) or chrome (newer, better CSS support) |
Reports
Section titled “Reports”Frappe provides four types of reports, each suited to different complexity levels.
Report Builder (no code)
Section titled “Report Builder (no code)”The simplest option — create reports directly from Desk without writing code:
- Navigate to any DocType list view.
- Click Menu > Report Builder (or the Report icon).
- Add/remove columns, apply filters, sort, and group.
- Save the report with Menu > Save.
Report Builder supports:
- Column selection from the DocType and linked DocTypes
- Filters with standard operators (
=,!=,like,between,in) - Group By with aggregate functions (Count, Sum, Average)
- Sorting on any column
- Export to CSV/Excel
This is ideal for ad-hoc reporting — franchise managers who need quick answers without developer help.
Query Report: SQL-based reports
Section titled “Query Report: SQL-based reports”Query Reports let you write raw SQL. They are powerful for complex joins and aggregations.
Creating via Desk:
- Go to the Report list, click + Add Report.
- Set Report Type to “Query Report”.
- Select the Reference DocType (determines permissions).
- Write your SQL in the Query field.
Each column alias encodes a label, field type, and width — so the report knows how to render and link each column:
SELECT si.outlet AS "Outlet:Link/Branch:200", sii.item_name AS "Item:Data:200", sii.item_group AS "Category:Data:150", SUM(sii.qty) AS "Qty Sold:Float:100", SUM(sii.amount) AS "Revenue:Currency:120"FROM `tabSales Invoice Item` siiJOIN `tabSales Invoice` si ON si.name = sii.parentWHERE si.docstatus = 1 AND si.posting_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)GROUP BY si.outlet, sii.item_name, sii.item_groupORDER BY si.outlet, SUM(sii.amount) DESCThe column format is "Label:FieldType:Width" or "Label:Link/DocType:Width".
Common field types in column definitions: Data, Currency, Float, Int,
Date, Link/DocType, Percent.
Script Report: Python-based with full control
Section titled “Script Report: Python-based with full control”Script Reports use Python for data retrieval and JavaScript for filters. They live in your custom app’s file structure and offer complete control over data processing.
The report lives in four files under the report/ directory:
Directoryapps/scoopjoy/scoopjoy/
Directoryreport/
Directoryoutlet_sales_summary/
__init__.py- outlet_sales_summary.py Python: columns + data
- outlet_sales_summary.js JavaScript: filters
- outlet_sales_summary.json Report definition
Scaffold it with bench make-report:
# Create the report scaffolding (or create the files manually)bench make-report "Outlet Sales Summary" --module "ScoopJoy"The Python file defines the columns, runs the queries, and returns charts and
summary cards. The execute() function is the entry point:
import frappefrom frappe import _from frappe.utils import getdate, add_days
def execute(filters=None): columns = get_columns() data = get_data(filters) chart = get_chart(data) summary = get_summary(data) return columns, data, None, chart, summary
def get_columns(): return [ { "label": _("Outlet"), "fieldname": "outlet", "fieldtype": "Link", "options": "Branch", "width": 180, }, { "label": _("Total Orders"), "fieldname": "total_orders", "fieldtype": "Int", "width": 120, }, { "label": _("Total Revenue"), "fieldname": "total_revenue", "fieldtype": "Currency", "width": 150, }, { "label": _("Avg Order Value"), "fieldname": "avg_order_value", "fieldtype": "Currency", "width": 150, }, { "label": _("Top Selling Item"), "fieldname": "top_item", "fieldtype": "Data", "width": 200, }, ]
def get_data(filters): conditions = get_conditions(filters)
# Main aggregation query data = frappe.db.sql( """ SELECT si.outlet AS outlet, COUNT(DISTINCT si.name) AS total_orders, SUM(si.grand_total) AS total_revenue, AVG(si.grand_total) AS avg_order_value FROM `tabSales Invoice` si WHERE si.docstatus = 1 {conditions} GROUP BY si.outlet ORDER BY total_revenue DESC """.format(conditions=conditions), filters, as_dict=True, )
# Enrich with top-selling item per outlet for row in data: top_item = frappe.db.sql( """ SELECT sii.item_name, SUM(sii.qty) as total_qty FROM `tabSales Invoice Item` sii JOIN `tabSales Invoice` si ON si.name = sii.parent WHERE si.docstatus = 1 AND si.outlet = %s GROUP BY sii.item_name ORDER BY total_qty DESC LIMIT 1 """, row.outlet, as_dict=True, ) row["top_item"] = top_item[0].item_name if top_item else "N/A"
return data
def get_conditions(filters): conditions = "" if filters.get("from_date"): conditions += " AND si.posting_date >= %(from_date)s" if filters.get("to_date"): conditions += " AND si.posting_date <= %(to_date)s" if filters.get("outlet"): conditions += " AND si.outlet = %(outlet)s" return conditions
def get_chart(data): if not data: return None
return { "data": { "labels": [row.outlet for row in data], "datasets": [ { "name": "Revenue", "values": [row.total_revenue for row in data], } ], }, "type": "bar", "colors": ["#e74c3c"], }
def get_summary(data): total_revenue = sum(row.total_revenue for row in data) total_orders = sum(row.total_orders for row in data) return [ { "value": total_revenue, "indicator": "Green", "label": _("Total Revenue"), "datatype": "Currency", }, { "value": total_orders, "indicator": "Blue", "label": _("Total Orders"), "datatype": "Int", }, ]The JavaScript file declares the filters the user sees above the report, and can
post-process cell values with a formatter:
frappe.query_reports["Outlet Sales Summary"] = { filters: [ { fieldname: "from_date", label: __("From Date"), fieldtype: "Date", default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), reqd: 1, }, { fieldname: "to_date", label: __("To Date"), fieldtype: "Date", default: frappe.datetime.get_today(), reqd: 1, }, { fieldname: "outlet", label: __("Outlet"), fieldtype: "Link", options: "Branch", }, ],
formatter: function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data);
// Highlight high-revenue outlets in green if (column.fieldname === "total_revenue" && data.total_revenue > 500000) { value = `<span style="color: green; font-weight: bold;">${value}</span>`; }
return value; },};The execute() function can return up to five values — the trailing ones are
optional:
return columns, data, message, chart, report_summary
# columns: list of dicts defining column schema# data: list of dicts (rows)# message: HTML string displayed above the report (or None)# chart: dict with chart config (or None)# report_summary: list of dicts for summary cards below the report (or None)Report permissions
Section titled “Report permissions”Reports inherit permissions from their Reference DocType. For additional
control, throw early inside execute():
def execute(filters=None): # Custom permission check if not frappe.has_permission("Sales Invoice", "report"): frappe.throw(_("Insufficient permissions"))
# ... rest of the reportYou can also set explicit roles on the Report document itself via the Roles table. See Chapter 9 for the full permission model.
Chart rendering in reports
Section titled “Chart rendering in reports”Script Reports support rich chart configurations. The type field selects the
chart style, and axis-mixed combines bar + line:
def get_chart(data): return { "data": { "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], "datasets": [ {"name": "Revenue", "values": [120, 150, 180, 200, 170, 220]}, {"name": "Orders", "values": [30, 45, 50, 60, 42, 55]}, ], }, "type": "bar", # bar, line, pie, donut, percentage, heatmap "height": 300, "colors": ["#e74c3c", "#3498db"], "barOptions": { "stacked": False, }, }Supported chart types: bar, line, pie, donut, percentage, heatmap, and
axis-mixed (for combining bar + line).
Dashboards
Section titled “Dashboards”Frappe v16 introduced a redesigned workspace with improved dashboard capabilities. Dashboards are assembled from Number Cards and Dashboard Charts.
Number Cards: KPI cards
Section titled “Number Cards: KPI cards”Number Cards display a single metric prominently. Three types are available.
1. Document Type Number Card — created via Desk at Number Card > + Add Number Card:
| Field | Example Value |
|---|---|
| Name | Today’s Sales Count |
| Document Type | Sales Invoice |
| Function | Count |
| Filters | posting_date = Today, docstatus = 1 |
| Show Percentage Change | Check (compares with previous period) |
2. Report-based Number Card — pulls a value from an existing report’s output:
| Field | Example Value |
|---|---|
| Name | Monthly Revenue |
| Type | Report |
| Report Name | Outlet Sales Summary |
| Report Field | total_revenue |
| Function | Sum |
3. Custom Number Card (Python API) — for complex KPIs, write a whitelisted function that returns the value (plus optional routing):
import frappefrom frappe.utils import nowdate, add_days
@frappe.whitelist()def get_franchise_health_score(): """Calculate a composite franchise health score (0-100).""" today = nowdate() week_ago = add_days(today, -7)
# Factor 1: Sales trend (40% weight) this_week = frappe.db.sql( """SELECT COALESCE(SUM(grand_total), 0) FROM `tabSales Invoice` WHERE docstatus=1 AND posting_date BETWEEN %s AND %s""", (week_ago, today), )[0][0]
last_week = frappe.db.sql( """SELECT COALESCE(SUM(grand_total), 0) FROM `tabSales Invoice` WHERE docstatus=1 AND posting_date BETWEEN %s AND %s""", (add_days(week_ago, -7), week_ago), )[0][0]
sales_score = min(40, (this_week / max(last_week, 1)) * 40)
# Factor 2: Stock health (30% weight) — % items above reorder total_items = frappe.db.count("Bin", {"warehouse": ["like", "%Outlet%"]}) healthy_items = frappe.db.count( "Bin", {"warehouse": ["like", "%Outlet%"], "actual_qty": [">", 0]}, ) stock_score = (healthy_items / max(total_items, 1)) * 30
# Factor 3: Order fulfillment (30% weight) total_orders = frappe.db.count( "Sales Order", {"transaction_date": ["between", [week_ago, today]], "docstatus": 1}, ) fulfilled = frappe.db.count( "Sales Order", { "transaction_date": ["between", [week_ago, today]], "docstatus": 1, "per_delivered": 100, }, ) fulfillment_score = (fulfilled / max(total_orders, 1)) * 30
return { "value": round(sales_score + stock_score + fulfillment_score), "fieldtype": "Int", "route_options": {}, "route": ["query-report", "Outlet Sales Summary"], }Then point a Custom Number Card at the method:
| Field | Value |
|---|---|
| Type | Custom |
| Method | scoopjoy.scoopjoy.api.dashboard.get_franchise_health_score |
| Label | Franchise Health Score |
Dashboard Charts
Section titled “Dashboard Charts”Dashboard Charts visualize trends and distributions. Create via **Dashboard Chart
- Add Dashboard Chart**.
Count/Sum/Average Chart:
| Field | Example |
|---|---|
| Chart Name | Weekly Sales Trend |
| Chart Type | Count |
| Document Type | Sales Invoice |
| Based On | posting_date |
| Timespan | Last Quarter |
| Time Interval | Weekly |
| Filters | docstatus = 1 |
| Type | Line |
Group By Chart:
| Field | Example |
|---|---|
| Chart Name | Sales by Item Group |
| Chart Type | Group By |
| Document Type | Sales Invoice Item |
| Group By Type | Count |
| Group By | item_group |
| Number of Groups | 10 |
| Type | Pie |
Report-Based Chart — you can source chart data from any Script or Query Report:
| Field | Example |
|---|---|
| Chart Name | Outlet Revenue Comparison |
| Chart Type | Report |
| Report Name | Outlet Sales Summary |
| X Field | outlet |
| Y Axis | total_revenue |
| Type | Bar |
For full control, create a Dashboard Chart Source (Developer Mode required) with
a whitelisted get() method that returns labels and datasets:
import frappefrom frappe.utils import add_months, nowdate
@frappe.whitelist()def get(chart_name=None, chart=None, no_cache=None, filters=None, from_date=None, to_date=None, timespan=None, time_interval=None, heatmap_year=None): labels = [] datasets = []
data = frappe.db.sql(""" SELECT sii.item_group AS flavor, SUM(sii.qty) AS total_qty FROM `tabSales Invoice Item` sii JOIN `tabSales Invoice` si ON si.name = sii.parent WHERE si.docstatus = 1 AND si.posting_date BETWEEN %s AND %s GROUP BY sii.item_group ORDER BY total_qty DESC LIMIT 10 """, (from_date or add_months(nowdate(), -1), to_date or nowdate()), as_dict=True)
labels = [row.flavor for row in data] datasets = [{"name": "Quantity Sold", "values": [row.total_qty for row in data]}]
return { "labels": labels, "datasets": datasets, "type": "bar", }Combining into a dashboard
Section titled “Combining into a dashboard”The Dashboard DocType (separate from the workspace) aggregates charts and cards into a unified view:
- Go to Dashboard > + Add Dashboard.
- Name it (e.g., “Franchise Performance”).
- Add Dashboard Chart entries (the charts you created above).
- Set the layout order.
But in v16, the preferred approach is to use Workspace blocks directly:
- Navigate to your module’s Workspace.
- Click Customize Workspace (top right).
- Add blocks: Number Card, Chart, Quick List, Shortcut.
- Arrange them by dragging.
- Save.
Module Workspace customization (v16)
Section titled “Module Workspace customization (v16)”Frappe v16 redesigned workspaces with app-based navigation and dedicated sidebars.
To create a workspace for your custom app programmatically, define a Workspace
fixture. Note the content field is a JSON string describing the block layout:
{ "charts": [ { "chart_name": "Weekly Sales Trend", "label": "Weekly Sales Trend" }, { "chart_name": "Sales by Item Group", "label": "Sales by Item Group" } ], "cards": [ { "card_name": "Today's Sales Count", "label": "Today's Orders" }, { "card_name": "Monthly Revenue", "label": "Monthly Revenue" } ], "content": "[{\"id\":\"heading\",\"type\":\"header\",\"data\":{\"text\":\"Franchise Hub\",\"level\":4}},{\"id\":\"chart_block_1\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Weekly Sales Trend\"}}]", "doctype": "Workspace", "label": "Franchise Hub", "module": "ScoopJoy", "name": "Franchise Hub", "public": 1}Practical examples
Section titled “Practical examples”Example 1: Franchise-branded POS receipt with GST
Section titled “Example 1: Franchise-branded POS receipt with GST”A thermal-printer-style receipt for POS Invoices with a GST breakup. It pulls the
company address with frappe.db.get_value(), loops over doc.items,
doc.taxes, and doc.payments, and uses Jinja filters like {{ item.qty | int }}
and {{ "%.2f" | format(doc.grand_total) }} for formatting:
<!-- Print Format: Franchise POS Receipt (DocType: POS Invoice) --><style> .pos-receipt { font-family: 'Courier New', monospace; font-size: 11px; max-width: 280px; margin: 0 auto; padding: 10px; } .pos-receipt .center { text-align: center; } .pos-receipt .right { text-align: right; } .pos-receipt hr { border-top: 1px dashed #000; } .pos-receipt table { width: 100%; border-collapse: collapse; } .pos-receipt td { padding: 2px 0; vertical-align: top; } .pos-receipt .total-row { font-weight: bold; font-size: 13px; }</style>
<div class="pos-receipt"> <!-- Header --> <div class="center"> <strong style="font-size: 14px;">{{ doc.company }}</strong><br> {% set company_address = frappe.db.get_value("Address", {"is_primary_address": 1, "link_doctype": "Company", "link_name": doc.company}, ["address_line1", "city", "state", "pincode", "gstin"], as_dict=True) %} {% if company_address %} {{ company_address.address_line1 }}<br> {{ company_address.city }}, {{ company_address.state }} - {{ company_address.pincode }}<br> GSTIN: {{ company_address.gstin or doc.company_gstin or "" }} {% endif %} </div>
<hr>
<!-- Invoice Details --> <table> <tr> <td>Invoice #:</td> <td class="right">{{ doc.name }}</td> </tr> <tr> <td>Date:</td> <td class="right">{{ frappe.format(doc.posting_date, {"fieldtype": "Date"}) }}</td> </tr> <tr> <td>Time:</td> <td class="right">{{ doc.posting_time }}</td> </tr> {% if doc.customer_name %} <tr> <td>Customer:</td> <td class="right">{{ doc.customer_name }}</td> </tr> {% endif %} <tr> <td>POS Profile:</td> <td class="right">{{ doc.pos_profile }}</td> </tr> </table>
<hr>
<!-- Items --> <table> <tr style="font-weight: bold; border-bottom: 1px solid #000;"> <td style="width: 45%;">Item</td> <td style="width: 15%; text-align: center;">Qty</td> <td style="width: 20%; text-align: right;">Rate</td> <td style="width: 20%; text-align: right;">Amt</td> </tr> {% for item in doc.items %} <tr> <td>{{ item.item_name[:20] }}</td> <td style="text-align: center;">{{ item.qty | int }}</td> <td class="right">{{ "%.2f" | format(item.rate) }}</td> <td class="right">{{ "%.2f" | format(item.amount) }}</td> </tr> {% if item.item_tax_rate %} <tr> <td colspan="4" style="font-size: 9px; color: #666;"> HSN: {{ item.gst_hsn_code or "" }} </td> </tr> {% endif %} {% endfor %} </table>
<hr>
<!-- Totals --> <table> <tr> <td>Subtotal:</td> <td class="right">{{ "%.2f" | format(doc.net_total) }}</td> </tr> {% for tax in doc.taxes %} <tr> <td>{{ tax.description }}:</td> <td class="right">{{ "%.2f" | format(tax.tax_amount) }}</td> </tr> {% endfor %} {% if doc.discount_amount %} <tr> <td>Discount:</td> <td class="right">-{{ "%.2f" | format(doc.discount_amount) }}</td> </tr> {% endif %} <tr class="total-row" style="border-top: 1px solid #000;"> <td>GRAND TOTAL:</td> <td class="right">{{ doc.currency }} {{ "%.2f" | format(doc.grand_total) }}</td> </tr> {% if doc.rounded_total %} <tr class="total-row"> <td>Rounded Total:</td> <td class="right">{{ doc.currency }} {{ "%.2f" | format(doc.rounded_total) }}</td> </tr> {% endif %} </table>
<hr>
<!-- Payment Details --> <table> {% for payment in doc.payments %} {% if payment.amount > 0 %} <tr> <td>{{ payment.mode_of_payment }}:</td> <td class="right">{{ "%.2f" | format(payment.amount) }}</td> </tr> {% endif %} {% endfor %} {% if doc.change_amount %} <tr> <td>Change:</td> <td class="right">{{ "%.2f" | format(doc.change_amount) }}</td> </tr> {% endif %} </table>
<hr>
<!-- GST Summary --> <div class="center" style="font-size: 10px;"> <strong>GST Summary</strong> </div> <table style="font-size: 10px;"> <tr style="font-weight: bold;"> <td>Tax</td> <td class="right">Taxable</td> <td class="right">Tax Amt</td> </tr> {% for tax in doc.taxes %} <tr> <td>{{ tax.description }}</td> <td class="right">{{ "%.2f" | format(tax.total - tax.tax_amount) }}</td> <td class="right">{{ "%.2f" | format(tax.tax_amount) }}</td> </tr> {% endfor %} </table>
<hr>
<!-- Footer --> <div class="center"> <p style="font-size: 10px;"> Items sold are non-refundable.<br> Thank you for choosing {{ doc.company }}!<br> Visit us again soon. </p> {% if doc.loyalty_program %} <p style="font-size: 10px; margin-top: 5px;"> Loyalty Program: {{ doc.loyalty_program }}<br> Points Earned: {{ doc.loyalty_points or 0 }} </p> {% endif %} </div></div>Example 2: Outlet-wise monthly sales summary (Script Report)
Section titled “Example 2: Outlet-wise monthly sales summary (Script Report)”This extends the Script Report above with month-over-month comparison. It computes
the current and previous month windows with get_first_day()/get_last_day(),
runs two aggregations, and derives a growth percentage per outlet:
import frappefrom frappe import _from frappe.utils import getdate, get_first_day, get_last_day, add_months
def execute(filters=None): columns = get_columns() data = get_data(filters) chart = get_chart(data) summary = get_summary(data) return columns, data, None, chart, summary
def get_columns(): return [ {"label": _("Outlet"), "fieldname": "outlet", "fieldtype": "Link", "options": "Branch", "width": 180}, {"label": _("Current Month Revenue"), "fieldname": "current_revenue", "fieldtype": "Currency", "width": 180}, {"label": _("Previous Month Revenue"), "fieldname": "previous_revenue", "fieldtype": "Currency", "width": 180}, {"label": _("Growth %"), "fieldname": "growth", "fieldtype": "Percent", "width": 120}, {"label": _("Current Month Orders"), "fieldname": "current_orders", "fieldtype": "Int", "width": 150}, {"label": _("Avg Order Value"), "fieldname": "avg_order", "fieldtype": "Currency", "width": 150}, ]
def get_data(filters): month_date = getdate(filters.get("month") or frappe.utils.nowdate()) current_start = get_first_day(month_date) current_end = get_last_day(month_date) prev_start = get_first_day(add_months(month_date, -1)) prev_end = get_last_day(add_months(month_date, -1))
outlet_filter = "" if filters.get("outlet"): outlet_filter = "AND si.outlet = %(outlet)s"
# Current month data current = frappe.db.sql(""" SELECT si.outlet, SUM(si.grand_total) AS revenue, COUNT(si.name) AS orders FROM `tabSales Invoice` si WHERE si.docstatus = 1 AND si.posting_date BETWEEN %(start)s AND %(end)s {outlet_filter} GROUP BY si.outlet """.format(outlet_filter=outlet_filter), { "start": current_start, "end": current_end, "outlet": filters.get("outlet") }, as_dict=True)
# Previous month data previous = frappe.db.sql(""" SELECT si.outlet, SUM(si.grand_total) AS revenue FROM `tabSales Invoice` si WHERE si.docstatus = 1 AND si.posting_date BETWEEN %(start)s AND %(end)s {outlet_filter} GROUP BY si.outlet """.format(outlet_filter=outlet_filter), { "start": prev_start, "end": prev_end, "outlet": filters.get("outlet") }, as_dict=True)
prev_map = {r.outlet: r.revenue for r in previous}
data = [] for row in current: prev_rev = prev_map.get(row.outlet, 0) growth = ((row.revenue - prev_rev) / prev_rev * 100) if prev_rev else 0 data.append({ "outlet": row.outlet, "current_revenue": row.revenue, "previous_revenue": prev_rev, "growth": growth, "current_orders": row.orders, "avg_order": row.revenue / row.orders if row.orders else 0, })
return sorted(data, key=lambda x: x["current_revenue"], reverse=True)
def get_chart(data): if not data: return None return { "data": { "labels": [r["outlet"] for r in data], "datasets": [ {"name": _("Current Month"), "values": [r["current_revenue"] for r in data]}, {"name": _("Previous Month"), "values": [r["previous_revenue"] for r in data]}, ], }, "type": "bar", "colors": ["#e74c3c", "#95a5a6"], }
def get_summary(data): total_current = sum(r["current_revenue"] for r in data) total_previous = sum(r["previous_revenue"] for r in data) overall_growth = ((total_current - total_previous) / total_previous * 100) if total_previous else 0 return [ {"value": total_current, "indicator": "Green", "label": _("Current Month Total"), "datatype": "Currency"}, {"value": total_previous, "indicator": "Gray", "label": _("Previous Month Total"), "datatype": "Currency"}, {"value": overall_growth, "indicator": "Green" if overall_growth >= 0 else "Red", "label": _("Growth %"), "datatype": "Percent"}, ]frappe.query_reports["Outlet Monthly Comparison"] = { filters: [ { fieldname: "month", label: __("Month"), fieldtype: "Date", default: frappe.datetime.get_today(), reqd: 1, }, { fieldname: "outlet", label: __("Outlet"), fieldtype: "Link", options: "Branch", }, ],};Example 3: Expiring Batch Report (Query Report)
Section titled “Example 3: Expiring Batch Report (Query Report)”Shows items with batch expiry dates coming up, filterable by a days-ahead window.
The %(days_ahead)s placeholder is bound from the filter file below:
SELECT b.name AS "Batch:Link/Batch:150", b.item AS "Item Code:Link/Item:150", i.item_name AS "Item Name:Data:200", b.expiry_date AS "Expiry Date:Date:120", DATEDIFF(b.expiry_date, CURDATE()) AS "Days Remaining:Int:120", COALESCE(SUM(sle.actual_qty), 0) AS "Stock Qty:Float:100", sle.warehouse AS "Warehouse:Link/Warehouse:180"FROM `tabBatch` bJOIN `tabItem` i ON i.name = b.itemLEFT JOIN `tabStock Ledger Entry` sle ON sle.batch_no = b.name AND sle.is_cancelled = 0WHERE b.expiry_date IS NOT NULL AND b.expiry_date >= CURDATE() AND b.expiry_date <= DATE_ADD(CURDATE(), INTERVAL %(days_ahead)s DAY) AND b.disabled = 0GROUP BY b.name, sle.warehouseHAVING COALESCE(SUM(sle.actual_qty), 0) > 0ORDER BY b.expiry_date ASC, b.itemfrappe.query_reports["Expiring Batch Report"] = { filters: [ { fieldname: "days_ahead", label: __("Days Ahead"), fieldtype: "Select", options: "7\n14\n30\n60\n90", default: "30", reqd: 1, }, ],};Example 4: Franchise Performance Dashboard
Section titled “Example 4: Franchise Performance Dashboard”Here is the complete setup for a franchise performance workspace.
Step 1: Create Number Cards via Desk (Number Card > + New):
| Card Name | Type | DocType | Function | Filters |
|---|---|---|---|---|
| Today’s POS Revenue | Document Type | POS Invoice | Sum (grand_total) | posting_date = Today, docstatus = 1 |
| Active Outlets | Document Type | Branch | Count | disabled = 0 |
| Low Stock Alerts | Document Type | Bin | Count | actual_qty < projected_qty |
| Pending Purchase Orders | Document Type | Purchase Order | Count | docstatus = 0 |
Step 2: Create Dashboard Charts:
| Chart Name | Type | DocType / Report | Config |
|---|---|---|---|
| Daily Sales Trend | Count | Sales Invoice | Based On: posting_date, Timespan: Last Month, Interval: Daily |
| Revenue by Outlet | Group By | Sales Invoice | Group By: outlet, Aggregate: Sum(grand_total), Chart: Pie |
| Top 10 Items This Week | Report | Outlet Sales Summary | X: item_name, Y: total_revenue, Chart: Bar |
| Stock Value Trend | Sum | Stock Ledger Entry | Field: stock_value, Based On: posting_date, Interval: Weekly |
Step 3: Assemble in Workspace. Navigate to your “Franchise Hub” workspace, click Customize Workspace, and add blocks:
- Header: “Franchise Performance Dashboard”
- Number Cards row: Today’s POS Revenue, Active Outlets, Low Stock Alerts, Pending POs
- Chart: Daily Sales Trend (full width)
- Two Charts side by side: Revenue by Outlet (pie) + Top 10 Items (bar)
- Quick List: Recent Sales Invoices (last 10, filtered by
docstatus = 1) - Quick List: Recent Stock Entries (last 5, Material Receipt type)
The workspace content field uses a block structure — each block has a type and
a data object with its placement (col is the column span out of 12):
[ { "type": "header", "data": {"text": "Franchise Performance Dashboard", "level": 3} }, { "type": "number_card", "data": {"card_name": "Today's POS Revenue", "col": 3} }, { "type": "number_card", "data": {"card_name": "Active Outlets", "col": 3} }, { "type": "number_card", "data": {"card_name": "Low Stock Alerts", "col": 3} }, { "type": "number_card", "data": {"card_name": "Pending Purchase Orders", "col": 3} }, { "type": "chart", "data": {"chart_name": "Daily Sales Trend", "col": 12} }, { "type": "chart", "data": {"chart_name": "Revenue by Outlet", "col": 6} }, { "type": "chart", "data": {"chart_name": "Top 10 Items This Week", "col": 6} }]