feat(statements): add overdue-customers query API
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>
This commit is contained in:
107
ns_app/api/statements.py
Normal file
107
ns_app/api/statements.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user