Skip to content

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 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.

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:

  1. Navigate to the Print Format list (search “Print Format” in the Awesomebar).
  2. Click + Add Print Format.
  3. Set a name (e.g., “Franchise POS Receipt”).
  4. Select the DocType (e.g., “POS Invoice”).
  5. Set Standard to “No”.
  6. Choose your format type: Print Format Builder (drag-and-drop) or Custom HTML (Jinja).

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:

VariableDescription
docThe document being printed
doc.itemsChild table rows (e.g., line items)
doc.nameDocument ID
doc.metaDocType metadata
frappeThe frappe module (utilities, formatting)
frappe.utilsDate, 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:

scoopjoy/scoopjoy/utils/print_helpers.py
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 0

Use 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 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 frappe
from frappe.utils.pdf import get_pdf
# Method 1: Get PDF content as bytes
html = 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 document
from frappe.utils.file_manager import save_file
save_file(
"invoice.pdf",
pdf_content,
"Sales Invoice",
"ACC-SINV-2025-00001",
is_private=1
)
# Method 3: Send PDF via email
frappe.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:

Terminal window
GET /api/method/frappe.utils.print_format.download_pdf
?doctype=Sales Invoice
&name=ACC-SINV-2025-00001
&format=Franchise POS Receipt
&no_letterhead=0

Configure global print behaviour at Print Settings:

SettingDescription
PDF Page SizeA4, Letter, Legal, or custom dimensions
Print StyleVisual theme (Modern, Classic, Monochrome, etc.)
Send Print as PDFAttach PDF when emailing documents
Repeat Header and FooterRepeat letter head on each page
Allow Print for DraftEnable printing before submission
Allow Print for CancelledEnable printing cancelled docs
PDF Generatorwkhtmltopdf (legacy) or chrome (newer, better CSS support)

Frappe provides four types of reports, each suited to different complexity levels.

The simplest option — create reports directly from Desk without writing code:

  1. Navigate to any DocType list view.
  2. Click Menu > Report Builder (or the Report icon).
  3. Add/remove columns, apply filters, sort, and group.
  4. 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 Reports let you write raw SQL. They are powerful for complex joins and aggregations.

Creating via Desk:

  1. Go to the Report list, click + Add Report.
  2. Set Report Type to “Query Report”.
  3. Select the Reference DocType (determines permissions).
  4. 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:

Items Sold by Outlet (Last 30 Days)
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` sii
JOIN
`tabSales Invoice` si ON si.name = sii.parent
WHERE
si.docstatus = 1
AND si.posting_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY
si.outlet, sii.item_name, sii.item_group
ORDER BY
si.outlet, SUM(sii.amount) DESC

The 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:

Terminal window
# 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:

scoopjoy/scoopjoy/report/outlet_sales_summary/outlet_sales_summary.py
import frappe
from 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:

scoopjoy/scoopjoy/report/outlet_sales_summary/outlet_sales_summary.js
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)

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 report

You can also set explicit roles on the Report document itself via the Roles table. See Chapter 9 for the full permission model.

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).

Frappe v16 introduced a redesigned workspace with improved dashboard capabilities. Dashboards are assembled from Number Cards and Dashboard Charts.

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:

FieldExample Value
NameToday’s Sales Count
Document TypeSales Invoice
FunctionCount
Filtersposting_date = Today, docstatus = 1
Show Percentage ChangeCheck (compares with previous period)

2. Report-based Number Card — pulls a value from an existing report’s output:

FieldExample Value
NameMonthly Revenue
TypeReport
Report NameOutlet Sales Summary
Report Fieldtotal_revenue
FunctionSum

3. Custom Number Card (Python API) — for complex KPIs, write a whitelisted function that returns the value (plus optional routing):

scoopjoy/scoopjoy/api/dashboard.py
import frappe
from 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:

FieldValue
TypeCustom
Methodscoopjoy.scoopjoy.api.dashboard.get_franchise_health_score
LabelFranchise Health Score

Dashboard Charts visualize trends and distributions. Create via **Dashboard Chart

  • Add Dashboard Chart**.

Count/Sum/Average Chart:

FieldExample
Chart NameWeekly Sales Trend
Chart TypeCount
Document TypeSales Invoice
Based Onposting_date
TimespanLast Quarter
Time IntervalWeekly
Filtersdocstatus = 1
TypeLine

Group By Chart:

FieldExample
Chart NameSales by Item Group
Chart TypeGroup By
Document TypeSales Invoice Item
Group By TypeCount
Group Byitem_group
Number of Groups10
TypePie

Report-Based Chart — you can source chart data from any Script or Query Report:

FieldExample
Chart NameOutlet Revenue Comparison
Chart TypeReport
Report NameOutlet Sales Summary
X Fieldoutlet
Y Axistotal_revenue
TypeBar

For full control, create a Dashboard Chart Source (Developer Mode required) with a whitelisted get() method that returns labels and datasets:

scoopjoy/scoopjoy/dashboard_chart_source/flavor_popularity/flavor_popularity.py
import frappe
from 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",
}

The Dashboard DocType (separate from the workspace) aggregates charts and cards into a unified view:

  1. Go to Dashboard > + Add Dashboard.
  2. Name it (e.g., “Franchise Performance”).
  3. Add Dashboard Chart entries (the charts you created above).
  4. Set the layout order.

But in v16, the preferred approach is to use Workspace blocks directly:

  1. Navigate to your module’s Workspace.
  2. Click Customize Workspace (top right).
  3. Add blocks: Number Card, Chart, Quick List, Shortcut.
  4. Arrange them by dragging.
  5. Save.

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:

scoopjoy/scoopjoy/workspace/franchise_hub/franchise_hub.json
{
"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
}

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;">
&nbsp; 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:

scoopjoy/scoopjoy/report/outlet_monthly_comparison/outlet_monthly_comparison.py
import frappe
from 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"},
]
scoopjoy/scoopjoy/report/outlet_monthly_comparison/outlet_monthly_comparison.js
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:

Expiring Batch Report — Query field
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` b
JOIN
`tabItem` i ON i.name = b.item
LEFT JOIN
`tabStock Ledger Entry` sle ON sle.batch_no = b.name AND sle.is_cancelled = 0
WHERE
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 = 0
GROUP BY
b.name, sle.warehouse
HAVING
COALESCE(SUM(sle.actual_qty), 0) > 0
ORDER BY
b.expiry_date ASC, b.item
expiring_batch_report.js
frappe.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 NameTypeDocTypeFunctionFilters
Today’s POS RevenueDocument TypePOS InvoiceSum (grand_total)posting_date = Today, docstatus = 1
Active OutletsDocument TypeBranchCountdisabled = 0
Low Stock AlertsDocument TypeBinCountactual_qty < projected_qty
Pending Purchase OrdersDocument TypePurchase OrderCountdocstatus = 0

Step 2: Create Dashboard Charts:

Chart NameTypeDocType / ReportConfig
Daily Sales TrendCountSales InvoiceBased On: posting_date, Timespan: Last Month, Interval: Daily
Revenue by OutletGroup BySales InvoiceGroup By: outlet, Aggregate: Sum(grand_total), Chart: Pie
Top 10 Items This WeekReportOutlet Sales SummaryX: item_name, Y: total_revenue, Chart: Bar
Stock Value TrendSumStock Ledger EntryField: 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:

  1. Header: “Franchise Performance Dashboard”
  2. Number Cards row: Today’s POS Revenue, Active Outlets, Low Stock Alerts, Pending POs
  3. Chart: Daily Sales Trend (full width)
  4. Two Charts side by side: Revenue by Outlet (pie) + Top 10 Items (bar)
  5. Quick List: Recent Sales Invoices (last 10, filtered by docstatus = 1)
  6. 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}
}
]