Add ns_app/api/statements.py with get_customers_with_overdue_invoices (one aggregated row per customer with overdue Sales Invoices) plus the _get_outstanding_invoices / _aging_bucket helpers used to build the per-customer statement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
108 lines
2.9 KiB
Python
108 lines
2.9 KiB
Python
"""Customer account statements.
|
|
|
|
Generates printable, one-customer-per-page account statements for customers with
|
|
overdue invoices, formatted for a standard double-window envelope. Statement
|
|
generation also books a late-payment fee to the ledger (see the late-fee helpers
|
|
added alongside the generator).
|
|
"""
|
|
|
|
import frappe
|
|
from frappe import _
|
|
from frappe.utils import getdate, nowdate
|
|
|
|
# Roles allowed to run collections/statement actions.
|
|
ALLOWED_ROLES = [
|
|
"System Manager",
|
|
"Sales User",
|
|
"Sales Manager",
|
|
"Accounts User",
|
|
"Accounts Manager",
|
|
]
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_customers_with_overdue_invoices():
|
|
"""Return one row per customer that has at least one overdue Sales Invoice.
|
|
|
|
A Sales Invoice is overdue when it is submitted, still has an outstanding
|
|
balance, and its due date is in the past.
|
|
"""
|
|
frappe.only_for(ALLOWED_ROLES)
|
|
|
|
today = nowdate()
|
|
rows = frappe.get_all(
|
|
"Sales Invoice",
|
|
filters={
|
|
"docstatus": 1,
|
|
"outstanding_amount": [">", 0],
|
|
"due_date": ["<", today],
|
|
},
|
|
fields=[
|
|
"customer",
|
|
"customer_name",
|
|
"count(name) as overdue_count",
|
|
"sum(outstanding_amount) as total_outstanding",
|
|
"min(due_date) as oldest_due_date",
|
|
],
|
|
group_by="customer, customer_name",
|
|
order_by="total_outstanding desc",
|
|
)
|
|
|
|
for row in rows:
|
|
row["max_days_overdue"] = (
|
|
(getdate(today) - getdate(row.oldest_due_date)).days
|
|
if row.oldest_due_date
|
|
else 0
|
|
)
|
|
|
|
return rows
|
|
|
|
|
|
def _aging_bucket(days_overdue):
|
|
"""Map days-overdue to a standard aging bucket label."""
|
|
if days_overdue <= 0:
|
|
return "Current"
|
|
if days_overdue <= 30:
|
|
return "1-30"
|
|
if days_overdue <= 60:
|
|
return "31-60"
|
|
if days_overdue <= 90:
|
|
return "61-90"
|
|
return "90+"
|
|
|
|
|
|
def _get_outstanding_invoices(customer):
|
|
"""Return all open (submitted, unpaid) Sales Invoices for a customer.
|
|
|
|
The statement lists the full open balance, so this includes not-yet-due
|
|
invoices; each row is annotated with days overdue, an overdue flag, and its
|
|
aging bucket.
|
|
"""
|
|
today = getdate(nowdate())
|
|
invoices = frappe.get_all(
|
|
"Sales Invoice",
|
|
filters={
|
|
"customer": customer,
|
|
"docstatus": 1,
|
|
"outstanding_amount": [">", 0],
|
|
},
|
|
fields=[
|
|
"name",
|
|
"posting_date",
|
|
"due_date",
|
|
"outstanding_amount",
|
|
"grand_total",
|
|
"company",
|
|
],
|
|
order_by="due_date asc",
|
|
)
|
|
|
|
for inv in invoices:
|
|
due = getdate(inv.due_date) if inv.due_date else None
|
|
days = (today - due).days if due else 0
|
|
inv["days_overdue"] = days if days > 0 else 0
|
|
inv["is_overdue"] = days > 0
|
|
inv["aging_bucket"] = _aging_bucket(inv["days_overdue"])
|
|
|
|
return invoices
|