Quick Answer
Odoo's default reports don't cover packing slips, inventory analysis, revenue breakdowns, or sales rep performance. You could buy apps ($800-$3,000/year each). Or build them yourself in 2-4 days: PDF reports with QWeb, Excel reports with openpyxl, Pivot/Graph analytics. Save $40,000-$100,000 in lost insights and bad executive decisions.
The Custom Report Problem
Your D2C brand needs:
❏ Packing slips (different from invoices, shows only what ships)
❏ Inventory reports (stock levels by warehouse, highlighted when low)
❏ Revenue analysis (by product category, sales channel, customer segment)
❏ Sales rep performance (orders assigned, revenue closed, commissions)
❏ Shipping manifest (list of all orders shipping today with barcodes)
Odoo's default reports don't cover these. You could buy apps ($800-$3,000/year each). Or you could build them yourself in 2-4 days.
We've implemented 150+ Odoo systems. The ones where developers mastered custom reports? They have 12+ custom reports delivering real business insights. The ones who don't? They're stuck with generic pivot tables, squinting at data that doesn't match their actual business needs. That's $40,000-$100,000 in lost insights and executive decisions made on bad data.
Type 1: PDF Reports (QWeb Templates)
What it is: Formatted document (packing slip, invoice, report) rendered as PDF.
Real D2C example: Custom packing slip
Step 1: Create the Report Action (XML)
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- REPORT ACTION -->
<record id="action_packing_slip" model="ir.actions.report">
<!-- Display name -->
<field name="name">Packing Slip</field>
<!-- Which model -->
<field name="model">sale.order</field>
<!-- PDF format -->
<field name="report_type">qweb-pdf</field>
<!-- QWeb template to use -->
<field name="report_name">custom_sales.report_packing_slip</field>
<!-- Template file name -->
<field name="report_file">custom_sales.report_packing_slip</field>
<!-- Paper format (optional) -->
<field name="paperformat_id" ref="base.paperformat_us_letter"/>
<!-- Only visible to warehouse group -->
<field name="groups_id" eval="[(6, 0, [ref('stock.group_stock_user')])]"/>
<!-- Save as attachment for printing later -->
<field name="attachment_use">True</field>
<!-- Attachment file name (Python expression) -->
<field name="attachment">'Packing_Slip_' + object.name.replace('/', '_') + '.pdf'</field>
</record>
</odoo>
Step 2: Create the QWeb Template (XML)
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- QWEB TEMPLATE -->
<template id="report_packing_slip">
<!-- Wraps with company header/footer -->
<t t-call="web.html_container">
<!-- Loop through each order (can print multiple) -->
<t t-foreach="docs" t-as="order">
<!-- Apply standard Odoo page layout -->
<t t-call="web.external_layout">
<div class="page">
<!-- HEADER -->
<div class="row mb-2">
<div class="col-6">
<h2>PACKING SLIP</h2>
<p><strong>Order #:</strong> <t t-esc="order.name"/></p>
<p><strong>Date:</strong> <t t-esc="order.order_date" t-options="{'widget': 'date'}"/></p>
</div>
<div class="col-6 text-right">
<!-- Barcode of order number -->
<img alt="Barcode" t-att-src="'/report/barcode/Code128/' + order.name"/>
</div>
</div>
<!-- SHIPPING ADDRESS -->
<div class="row mb-3">
<div class="col-6">
<h4>Ship To:</h4>
<address>
<strong><t t-esc="order.partner_id.name"/></strong><br/>
<t t-esc="order.partner_shipping_id.street"/><br/>
<t t-esc="order.partner_shipping_id.city"/>,
<t t-esc="order.partner_shipping_id.state_id.code"/>
<t t-esc="order.partner_shipping_id.zip"/><br/>
<t t-esc="order.partner_shipping_id.country_id.name"/>
</address>
</div>
</div>
<!-- LINE ITEMS TABLE -->
<table class="table table-sm">
<thead>
<tr>
<th>SKU</th>
<th>Product</th>
<th class="text-right">Qty</th>
<th>Barcode</th>
</tr>
</thead>
<tbody>
<tr t-foreach="order.order_line" t-as="line">
<td><t t-esc="line.product_id.default_code"/></td>
<td><t t-esc="line.product_id.name"/></td>
<td class="text-right"><t t-esc="int(line.product_qty)"/></td>
<td>
<img t-att-src="'/report/barcode/Code128/' + line.product_id.barcode"
style="height: 30px;"/>
</td>
</tr>
</tbody>
</table>
<!-- SPECIAL INSTRUCTIONS -->
<div class="mt-3">
<strong>Special Instructions:</strong>
<p><t t-esc="order.note or 'None'"/></p>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>
Result: Professional Packing Slip
When you click "Print → Packing Slip" on an order, Odoo generates a professional PDF showing:
✓ Order number with barcode
✓ Shipping address
✓ Line items with barcodes (for scanning during packing)
✓ Special instructions
✓ Automatic file naming and storage
Type 2: Excel Reports (Dynamic Data Export)
What it is: Formatted Excel file with data, formulas, styling.
Real D2C example: Daily inventory report
Step 1: Create Python Report Class
from odoo import models, fields, api
from io import BytesIO
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
class InventoryExcelReport(models.AbstractModel):
_name = 'report.custom_inventory.report_inventory_excel'
_description = 'Inventory Excel Report'
def generate_xlsx_report(self, workbook, data, records):
"""
Generate Excel report.
workbook = openpyxl workbook object
data = context data passed from report wizard
records = products being reported
"""
# Create sheet
sheet = workbook.active
sheet.title = 'Inventory'
# Set column widths
sheet.column_dimensions['A'].width = 15
sheet.column_dimensions['B'].width = 30
sheet.column_dimensions['C'].width = 12
sheet.column_dimensions['D'].width = 12
sheet.column_dimensions['E'].width = 12
sheet.column_dimensions['F'].width = 12
# HEADERS
headers = ['SKU', 'Product Name', 'Qty On Hand', 'Reorder Level', 'Status', 'Value']
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
header_font = Font(bold=True, color='FFFFFF')
for col_num, header in enumerate(headers, 1):
cell = sheet.cell(row=1, column=col_num)
cell.value = header
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# DATA ROWS
row_num = 2
total_value = 0
for product in records:
# Calculate stock status
if product.qty_available < product.x_reorder_level:
status = 'LOW STOCK'
elif product.qty_available < (product.x_reorder_level * 1.5):
status = 'Reorder Soon'
else:
status = 'OK'
# Calculate inventory value
inventory_value = product.qty_available * product.cost
total_value += inventory_value
# Write row
sheet.cell(row=row_num, column=1).value = product.default_code
sheet.cell(row=row_num, column=2).value = product.name
sheet.cell(row=row_num, column=3).value = product.qty_available
sheet.cell(row=row_num, column=4).value = product.x_reorder_level
sheet.cell(row=row_num, column=5).value = status
sheet.cell(row=row_num, column=6).value = inventory_value
# Color-code status
status_cell = sheet.cell(row=row_num, column=5)
if status == 'LOW STOCK':
status_cell.fill = PatternFill(start_color='FF0000', end_color='FF0000', fill_type='solid')
status_cell.font = Font(color='FFFFFF', bold=True)
elif status == 'Reorder Soon':
status_cell.fill = PatternFill(start_color='FFA500', end_color='FFA500', fill_type='solid')
row_num += 1
# TOTAL ROW
sheet.cell(row=row_num, column=5).value = 'TOTAL VALUE:'
sheet.cell(row=row_num, column=5).font = Font(bold=True)
sheet.cell(row=row_num, column=6).value = total_value
sheet.cell(row=row_num, column=6).font = Font(bold=True)
Step 2: Create Report Action (XML)
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_inventory_excel" model="ir.actions.report">
<field name="name">Inventory Report (Excel)</field>
<field name="model">product.product</field>
<field name="report_type">xlsx</field>
<field name="report_name">custom_inventory.report_inventory_excel</field>
<field name="report_file">custom_inventory.report_inventory_excel</field>
<field name="groups_id" eval="[(6, 0, [ref('stock.group_stock_manager')])]"/>
</record>
</odoo>
Step 3: Add to Manifest
{
'name': 'Custom Inventory',
'depends': ['stock', 'report_xlsx'], # report_xlsx = OCA module for Excel
'data': [
'reports/inventory_reports.xml',
],
'installable': True,
}
Result: Professional Excel Report
Click "Print → Inventory Report (Excel)" and get:
✓ Formatted Excel file
✓ Color-coded status (red = LOW STOCK, orange = Reorder Soon)
✓ Automatic calculations (total inventory value)
✓ Professional styling
✓ Downloadable for analysis
Type 3: Pivot & Graph Reports (Analytics)
Create using Odoo's built-in pivot view:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- PIVOT VIEW -->
<record id="view_sale_pivot_analysis" model="ir.ui.view">
<field name="name">Sales Analysis Pivot</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<pivot string="Sales Analysis">
<!-- ROWS: Product categories -->
<field name="product_id.categ_id" type="row"/>
<!-- COLUMNS: Status -->
<field name="state" type="col"/>
<!-- VALUES: Revenue and count -->
<field name="amount_total" type="measure" aggregate="sum"/>
<field name="id" type="measure" aggregate="count" string="Orders"/>
</pivot>
</field>
</record>
<!-- GRAPH VIEW -->
<record id="view_sale_graph_analysis" model="ir.ui.view">
<field name="name">Sales Analysis Graph</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<graph type="bar" stacked="False">
<!-- X-axis: Month -->
<field name="order_date" interval="month" type="row"/>
<!-- Data: Revenue by status -->
<field name="state" type="col"/>
<!-- Y-axis: Total amount -->
<field name="amount_total" type="measure" aggregate="sum"/>
</graph>
</field>
</record>
</odoo>
Report Types Comparison
| Report Type | Best For | Technology | Complexity |
|---|---|---|---|
| PDF (QWeb) | Packing slips, invoices, formatted documents | XML templates + QWeb | Medium |
| Excel (XLSX) | Data export, analysis, calculations | Python + openpyxl | Advanced |
| Pivot/Graph | Interactive analytics, dashboards | XML view definitions | Easy |
Action Items: Build Your Custom Reports
For PDF Reports
❏ Create report action (XML) with report_type="qweb-pdf"
❏ Create QWeb template with HTML/QWeb markup
❏ Add barcode/images using <img> tags
❏ Test by clicking "Print" on records
❏ Customize styling with CSS
For Excel Reports
❏ Create Python report class inheriting from AbstractModel
❏ Write generate_xlsx_report() method
❏ Format cells, add formulas, style colors
❏ Add report action with report_type="xlsx"
❏ Install report_xlsx dependency (OCA module)
For Pivot/Graph
❏ Create view with <pivot> or <graph> tags
❏ Specify row, column, measure fields
❏ Create menu item for easy access
❏ Test grouping and filtering
Frequently Asked Questions
What's the difference between QWeb PDF reports and Excel reports in Odoo?
PDF (QWeb) creates formatted documents (packing slips, invoices) with fixed layouts. Excel (XLSX) creates spreadsheets with formulas, styling, and editable data for analysis. Use PDF for documents users print, Excel for data users analyze.
Do I need the report_xlsx module for Excel reports?
Yes. Odoo doesn't include Excel report support by default. Install report_xlsx from OCA (Odoo Community Association). It provides the base infrastructure for generating XLSX files with openpyxl.
Can I add barcodes to PDF reports?
Yes. Use <img t-att-src="'/report/barcode/Code128/' + field_value"/> in your QWeb template. Odoo generates barcode images on-the-fly. Supports Code128, QR codes, EAN13, and more.
How much can I save by building custom reports vs. buying apps?
Custom report apps cost $800-$3,000/year each. With 5-7 custom reports needed, that's $4,000-$21,000/year. Learning to build reports yourself (2-4 days) saves $40,000-$100,000 over 5 years, plus you get exactly what you need.
Free Custom Reporting Design Workshop
Stop settling for generic reports. We'll identify which custom reports you actually need, design report layouts and structure, build your first 2-3 custom reports, and teach you to extend reports for future needs. Most D2C brands have 5-7 critical reports they need. Building them costs $8,000-$20,000 through consultants. Learning to build reports yourself saves $40,000-$100,000 annually.
