Sales Unbilled Report Documentation¶
Overview¶
The Sales Unbilled Report module (report.sale.unbill) tracks sales orders that have not yet been fully invoiced. It identifies unbilled revenue, helping businesses manage their billing pipeline and recognize deferred revenue obligations.
Model Information¶
Model Name: report.sale.unbill
Display Name: Sales Unbilled Report
Type: Transient Report Model (_transient = True)
Features¶
- Tracks orders not yet invoiced
- Calculates unbilled amounts per order
- Shows partial billing progress
- Customer and order filtering
- Foreign currency support
- Person-in-charge and category filtering
Key Fields Reference¶
Report Parameters¶
| Field | Type | Required | Description |
|---|---|---|---|
order_date_from |
Date | No | Filter by order date from (default: Jan 1 of current year) |
order_date_to |
Date | No | Filter by order date to (default: Dec 31 of current year) |
contact_id |
Many2One | No | Filter by specific customer |
sale_order |
Char | No | Filter by order number (partial match) |
invoice_number |
Char | No | Filter by invoice number (partial match) |
person_in_charge |
Many2One | No | Filter by salesperson/user |
sale_category |
Many2One | No | Filter by sales category |
show_foreign_currency |
Boolean | No | Show foreign currency amounts |
Output Fields¶
| Field | Description |
|---|---|
id |
Sales order ID |
number |
Order number |
date |
Order date |
ref |
Customer reference |
contact_code |
Customer code |
contact_name |
Customer name |
amount_total_cur |
Order total in company currency |
inv_amount_total_cur |
Total invoiced in company currency |
amount_unbill |
Unbilled amount remaining |
amount_unbill_USD |
Unbilled amount in USD (if foreign currency shown) |
person_in_charge |
Salesperson name |
sale_category |
Sales category name |
invoices |
Array of invoices against this order |
Unbilled Calculation Logic¶
# For each order
order_amount = sale.amount_total_cur
# Sum all invoices
invoice_amount = sum(inv.amount_total_cur for inv in sale.invoices)
# Calculate unbilled
amount_unbill = order_amount - invoice_amount
# Exclude fully invoiced orders (amount_unbill = 0)
Key States: - Only includes non-draft, non-voided orders - Partial invoicing: amount_unbill > 0 - Fully invoiced: amount_unbill = 0 (excluded from report) - Not invoiced: amount_unbill = order_amount (all invoices shown in detail)
API Methods¶
1. Generate Unbilled Report¶
Method: get_report_data(ids, context)
Generates unbilled sales tracking report.
Parameters:
ids = [report_id] # Report configuration ID
context = {
"no_limit": True # Optional: disable query limits
}
Returns: dict - Report data:
{
"company_name": "ABC Company",
"order_date_from": "2024-01-01",
"order_date_to": "2024-12-31",
"show_foreign_currency": False,
"base_currency_code": "THB",
"foreign_currency_code": "USD",
"sale_orders": [
{
"id": 123,
"number": "SO-2024-001",
"date": "2024-01-15",
"ref": "CUST-REF-001",
"contact_code": "C001",
"contact_name": "ABC Corp",
"amount_total_cur": 100000.00,
"inv_amount_total_cur": 60000.00,
"amount_unbill": 40000.00,
"amount_unbill_USD": 1200.00,
"person_in_charge": "John Doe",
"sale_category": "Retail",
"invoices": [
{
"id": 456,
"number": "INV-2024-001",
"date": "2024-01-20",
"amount_total_cur": 60000.00
}
]
}
],
"sale_amount_total": 100000.00,
"inv_amount_total": 60000.00,
"unbill_amount_total": 40000.00
}
Example:
# Generate unbilled report for Q1 2024
report_id = get_model("report.sale.unbill").create({
"order_date_from": "2024-01-01",
"order_date_to": "2024-03-31"
})
data = get_model("report.sale.unbill").get_report_data([report_id])
print(f"Unbilled Revenue Report:")
print(f" Total Orders: ${data['sale_amount_total']:,.2f}")
print(f" Invoiced: ${data['inv_amount_total']:,.2f}")
print(f" Unbilled: ${data['unbill_amount_total']:,.2f}")
print(f" Billing %: {(data['inv_amount_total']/data['sale_amount_total'])*100:.1f}%")
Related Models¶
| Model | Relationship | Description |
|---|---|---|
sale.order |
Data Source | Sales orders with invoicing status |
account.invoice |
One2Many | Invoices generated from orders |
contact |
Many2One | Customer information |
base.user |
Many2One | Person in charge (salesperson) |
sale.categ |
Many2One | Sales category |
currency.rate |
Reference | For foreign currency conversion |
Common Use Cases¶
Use Case 1: Billing Pipeline Management¶
# Track what needs to be invoiced this month
from datetime import date, timedelta
# Look back 90 days for pending billing
date_from = (date.today() - timedelta(days=90)).strftime("%Y-%m-%d")
date_to = date.today().strftime("%Y-%m-%d")
report_id = get_model("report.sale.unbill").create({
"order_date_from": date_from,
"order_date_to": date_to
})
data = get_model("report.sale.unbill").get_report_data([report_id])
print("Billing Pipeline:")
print(f"Total Unbilled: ${data['unbill_amount_total']:,.2f}\n")
# Sort by unbilled amount
sorted_orders = sorted(data["sale_orders"], key=lambda x: x["amount_unbill"], reverse=True)
print("Top 10 Orders to Invoice:")
for i, order in enumerate(sorted_orders[:10], 1):
days_old = (date.today() - datetime.strptime(order["date"], "%Y-%m-%d").date()).days
print(f"{i}. {order['number']} - {order['contact_name']}")
print(f" Unbilled: ${order['amount_unbill']:,.2f}")
print(f" Age: {days_old} days")
print(f" Person: {order['person_in_charge']}")
Use Case 2: Salesperson Billing Accountability¶
# Track unbilled orders by salesperson
report_id = get_model("report.sale.unbill").create({
"order_date_from": "2024-01-01",
"order_date_to": "2024-12-31"
})
data = get_model("report.sale.unbill").get_report_data([report_id])
from collections import defaultdict
by_person = defaultdict(lambda: {"count": 0, "unbilled": 0, "orders": []})
for order in data["sale_orders"]:
person = order.get("person_in_charge") or "Unassigned"
by_person[person]["count"] += 1
by_person[person]["unbilled"] += order["amount_unbill"]
by_person[person]["orders"].append(order["number"])
print("Unbilled Orders by Salesperson:")
for person, stats in sorted(by_person.items(), key=lambda x: x[1]["unbilled"], reverse=True):
print(f"\n{person}:")
print(f" Orders: {stats['count']}")
print(f" Unbilled: ${stats['unbilled']:,.2f}")
print(f" Orders: {', '.join(stats['orders'][:5])}")
if stats['count'] > 5:
print(f" ... and {stats['count'] - 5} more")
Use Case 3: Customer Unbilled Balance¶
# Check unbilled amounts for specific customer
customer_id = 45
report_id = get_model("report.sale.unbill").create({
"order_date_from": "2024-01-01",
"order_date_to": "2024-12-31",
"contact_id": customer_id
})
data = get_model("report.sale.unbill").get_report_data([report_id])
if data["sale_orders"]:
customer_name = data.get("contact_name", "Customer")
print(f"Unbilled Balance for {customer_name}:")
print(f" Total Unbilled: ${data['unbill_amount_total']:,.2f}\n")
print("Order Details:")
for order in data["sale_orders"]:
billing_pct = ((order["amount_total_cur"] - order["amount_unbill"]) / order["amount_total_cur"]) * 100
print(f" {order['number']} ({order['date']}):")
print(f" Order Total: ${order['amount_total_cur']:,.2f}")
print(f" Invoiced: ${order['inv_amount_total_cur']:,.2f} ({billing_pct:.0f}%)")
print(f" Unbilled: ${order['amount_unbill']:,.2f}")
if order.get("invoices"):
print(f" Invoices:")
for inv in order["invoices"]:
print(f" - {inv['number']} ({inv['date']}): ${inv['amount_total_cur']:,.2f}")
else:
print("No unbilled orders for this customer")
Use Case 4: Aging Unbilled Orders¶
# Identify old unbilled orders needing attention
from datetime import datetime, date
report_id = get_model("report.sale.unbill").create({
"order_date_from": "2023-01-01", # Look back further
"order_date_to": date.today().strftime("%Y-%m-%d")
})
data = get_model("report.sale.unbill").get_report_data([report_id])
# Categorize by age
aging_buckets = {
"0-30 days": {"orders": [], "amount": 0},
"31-60 days": {"orders": [], "amount": 0},
"61-90 days": {"orders": [], "amount": 0},
"90+ days": {"orders": [], "amount": 0}
}
today = date.today()
for order in data["sale_orders"]:
order_date = datetime.strptime(order["date"], "%Y-%m-%d").date()
age = (today - order_date).days
if age <= 30:
bucket = "0-30 days"
elif age <= 60:
bucket = "31-60 days"
elif age <= 90:
bucket = "61-90 days"
else:
bucket = "90+ days"
aging_buckets[bucket]["orders"].append(order)
aging_buckets[bucket]["amount"] += order["amount_unbill"]
print("Unbilled Orders Aging Analysis:")
for bucket, data in aging_buckets.items():
if data["orders"]:
print(f"\n{bucket}: {len(data['orders'])} orders, ${data['amount']:,.2f}")
if bucket == "90+ days": # Show details for old orders
for order in data["orders"]:
print(f" - {order['number']} ({order['date']}): ${order['amount_unbill']:,.2f}")
Use Case 5: Revenue Recognition Tracking¶
# Track deferred revenue for accounting
report_id = get_model("report.sale.unbill").create({
"order_date_from": "2024-01-01",
"order_date_to": "2024-12-31"
})
data = get_model("report.sale.unbill").get_report_data([report_id])
print("Revenue Recognition Report:")
print(f"Total Order Value: ${data['sale_amount_total']:,.2f}")
print(f"Recognized (Invoiced): ${data['inv_amount_total']:,.2f}")
print(f"Deferred (Unbilled): ${data['unbill_amount_total']:,.2f}")
recognition_rate = (data['inv_amount_total'] / data['sale_amount_total']) * 100 if data['sale_amount_total'] else 0
print(f"Recognition Rate: {recognition_rate:.1f}%")
# Monthly breakdown
from datetime import datetime
from dateutil.relativedelta import relativedelta
monthly_deferred = {}
start_date = datetime(2024, 1, 1)
for i in range(12):
month = (start_date + relativedelta(months=i)).strftime("%Y-%m")
month_orders = [
o for o in data["sale_orders"]
if o["date"].startswith(month)
]
if month_orders:
month_unbilled = sum(o["amount_unbill"] for o in month_orders)
monthly_deferred[month] = month_unbilled
print("\nMonthly Deferred Revenue:")
for month, amount in sorted(monthly_deferred.items()):
print(f" {month}: ${amount:,.2f}")
Performance Tips¶
1. Date Range Filtering¶
- Default range is full year (may be slow for large datasets)
- Narrow date range for faster queries
- Use
no_limitcontext only when necessary
2. Customer Filtering¶
- Filter by specific customer for detailed analysis
- Faster than loading all orders then filtering
3. Foreign Currency¶
show_foreign_currencyadds USD conversion overhead- Only enable when needed for multi-currency analysis
Troubleshooting¶
"No unbilled orders found"¶
Cause: All orders fully invoiced, or outside date range Solution: - Expand date range - Check order states (draft/voided excluded) - Verify orders have invoices linked
"Foreign currency amounts missing"¶
Cause: Currency rate not available for USD
Solution:
- Ensure USD currency rate exists in system
- Check currency.rate model for USD entries
- Foreign currency only shown if show_foreign_currency enabled
Version History¶
Last Updated: 2026-01-05 Model Version: report_sale_unbill.py (168 lines) Framework: Netforce
Additional Resources¶
- Sales Order Documentation:
sale.order - Invoice Model Documentation:
account.invoice
This documentation is generated for developer onboarding and reference purposes.