Remade customer balance report

This commit is contained in:
Ali 2025-07-31 21:21:28 +04:00
parent 6a8f424671
commit b498e7100c
3 changed files with 201 additions and 303 deletions

View File

@ -3,6 +3,14 @@
frappe.query_reports["Customer Outstanding Balance"] = { frappe.query_reports["Customer Outstanding Balance"] = {
"filters": [ "filters": [
{
"fieldname": "company",
"label": __("Company"),
"fieldtype": "Link",
"options": "Company",
"reqd": 1,
"default": frappe.defaults.get_user_default("Company")
},
{ {
"fieldname": "from_date", "fieldname": "from_date",
"label": __("From Date"), "label": __("From Date"),
@ -18,31 +26,42 @@ frappe.query_reports["Customer Outstanding Balance"] = {
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "company", "fieldname": "customer",
"label": __("Company"), "label": __("Customer"),
"fieldtype": "MultiSelectList",
"get_data": function (txt) {
return frappe.db.get_link_options("Customer", txt);
}
},
{
"fieldname": "customer_group",
"label": __("Customer Group"),
"fieldtype": "Link", "fieldtype": "Link",
"options": "Company", "options": "Customer Group"
"reqd": 1,
"default": frappe.defaults.get_user_default("Company")
} }
], ],
"formatter": function (value, row, column, data, default_formatter) { "formatter": function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data); value = default_formatter(value, row, column, data);
// Highlight outstanding amounts in red if overdue // Highlight opening balance in blue
if (column.fieldname == "outstanding" && data && data.outstanding > 0 && data.age > 30) { if (column.fieldname == "opening_balance" && data && data.opening_balance > 0) {
value = "<span style='color: #d32f2f; font-weight: bold;'>" + value + "</span>"; value = "<span style='color: #1f77b4; font-weight: bold;'>" + value + "</span>";
} }
// Highlight age in orange if > 60 days // Highlight payments in green
if (column.fieldname == "age" && data && data.age > 60) { if (column.fieldname == "payments" && data && data.payments > 0) {
value = "<span style='color: #f57c00; font-weight: bold;'>" + value + "</span>"; value = "<span style='color: #2ca02c; font-weight: bold;'>" + value + "</span>";
} }
// Bold totals // Highlight new invoices in orange
if (data && data.bold) { if (column.fieldname == "new_invoices" && data && data.new_invoices > 0) {
value = value.bold(); value = "<span style='color: #ff7f0e; font-weight: bold;'>" + value + "</span>";
}
// Highlight closing balance in dark red
if (column.fieldname == "closing_balance" && data && data.closing_balance > 0) {
value = "<span style='color: #d62728; font-weight: bold;'>" + value + "</span>";
} }
return value; return value;

View File

@ -9,7 +9,7 @@
"idx": 0, "idx": 0,
"is_standard": "Yes", "is_standard": "Yes",
"letterhead": null, "letterhead": null,
"modified": "2025-07-31 19:49:06.707566", "modified": "2025-07-31 19:36:23.704645",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Taxes Az", "module": "Taxes Az",
"name": "Customer Outstanding Balance", "name": "Customer Outstanding Balance",
@ -25,5 +25,6 @@
{ {
"role": "Accounts Manager" "role": "Accounts Manager"
} }
] ],
"timeout": 0
} }

View File

@ -2,19 +2,10 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
from frappe import _, qb from frappe import _
from frappe.utils import cint, flt, getdate, nowdate
from collections import OrderedDict
def execute(filters=None): def execute(filters=None):
"""Main function for report execution""" """Main function for report execution"""
if not filters:
filters = {}
# Set default company if not provided
if not filters.get("company"):
filters["company"] = frappe.db.get_single_value("Global Defaults", "default_company")
columns = get_columns() columns = get_columns()
data = get_data(filters) data = get_data(filters)
@ -24,14 +15,8 @@ def get_columns():
"""Get report columns definition""" """Get report columns definition"""
return [ return [
{ {
"label": _("Posting Date"), "label": _("Customer"),
"fieldname": "posting_date", "fieldname": "customer",
"fieldtype": "Date",
"width": 100
},
{
"label": _("Party"),
"fieldname": "party",
"fieldtype": "Link", "fieldtype": "Link",
"options": "Customer", "options": "Customer",
"width": 150 "width": 150
@ -40,303 +25,196 @@ def get_columns():
"label": _("Customer Name"), "label": _("Customer Name"),
"fieldname": "customer_name", "fieldname": "customer_name",
"fieldtype": "Data", "fieldtype": "Data",
"width": 150 "width": 200
}, },
{ {
"label": _("Voucher Type"), "label": _("Opening Balance"),
"fieldname": "voucher_type", "fieldname": "opening_balance",
"fieldtype": "Data",
"width": 120
},
{
"label": _("Voucher No"),
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"options": "voucher_type",
"width": 150
},
{
"label": _("Due Date"),
"fieldname": "due_date",
"fieldtype": "Date",
"width": 100
},
{
"label": _("Invoiced Amount"),
"fieldname": "invoiced",
"fieldtype": "Currency", "fieldtype": "Currency",
"width": 120 "width": 140
}, },
{ {
"label": _("Paid Amount"), "label": _("Payments (Decrease)"),
"fieldname": "paid", "fieldname": "payments",
"fieldtype": "Currency", "fieldtype": "Currency",
"width": 120 "width": 140
}, },
{ {
"label": _("Credit Note"), "label": _("New Invoices (Increase)"),
"fieldname": "credit_note", "fieldname": "new_invoices",
"fieldtype": "Currency", "fieldtype": "Currency",
"width": 120 "width": 140
}, },
{ {
"label": _("Outstanding Amount"), "label": _("Closing Balance"),
"fieldname": "outstanding", "fieldname": "closing_balance",
"fieldtype": "Currency", "fieldtype": "Currency",
"width": 130 "width": 140
},
{
"label": _("Age (Days)"),
"fieldname": "age",
"fieldtype": "Int",
"width": 80
},
{
"label": _("0-30"),
"fieldname": "range1",
"fieldtype": "Currency",
"width": 100
},
{
"label": _("30-60"),
"fieldname": "range2",
"fieldtype": "Currency",
"width": 100
},
{
"label": _("60-90"),
"fieldname": "range3",
"fieldtype": "Currency",
"width": 100
},
{
"label": _("90+"),
"fieldname": "range4",
"fieldtype": "Currency",
"width": 100
},
{
"label": _("Currency"),
"fieldname": "currency",
"fieldtype": "Link",
"options": "Currency",
"width": 80
} }
] ]
def get_data(filters): def get_data(filters):
"""Get report data""" """Get report data"""
# Get Payment Ledger Entries conditions = get_conditions(filters)
ple_entries = get_ple_entries(filters)
# Build voucher balance # Get opening balance (before from_date)
voucher_balance = build_voucher_balance(ple_entries) opening_balance_data = frappe.db.sql(f"""
SELECT
ple.party as customer,
SUM(ple.amount) as opening_balance
FROM
`tabPayment Ledger Entry` ple
LEFT JOIN
`tabAccount` acc ON acc.name = ple.account
WHERE
ple.delinked = 0
AND ple.posting_date < %(from_date)s
AND acc.account_type = 'Receivable'
AND ple.company = %(company)s
{conditions}
GROUP BY
ple.party
HAVING
ABS(opening_balance) >= 0.01
""", filters, as_dict=1)
# Get additional details # Get movements during the period
get_invoice_details(voucher_balance, filters) period_data = frappe.db.sql(f"""
get_customer_details(voucher_balance) SELECT
ple.party as customer,
SUM(CASE WHEN ple.amount > 0 THEN ple.amount ELSE 0 END) as new_invoices,
SUM(CASE WHEN ple.amount < 0 THEN ABS(ple.amount) ELSE 0 END) as payments
FROM
`tabPayment Ledger Entry` ple
LEFT JOIN
`tabAccount` acc ON acc.name = ple.account
WHERE
ple.delinked = 0
AND ple.posting_date >= %(from_date)s
AND ple.posting_date <= %(to_date)s
AND acc.account_type = 'Receivable'
AND ple.company = %(company)s
{conditions}
GROUP BY
ple.party
""", filters, as_dict=1)
# Build final data # Get closing balance (up to to_date)
data = [] closing_balance_data = frappe.db.sql(f"""
company_currency = frappe.get_cached_value("Company", filters.get("company"), "default_currency") SELECT
ple.party as customer,
SUM(ple.amount) as closing_balance
FROM
`tabPayment Ledger Entry` ple
LEFT JOIN
`tabAccount` acc ON acc.name = ple.account
WHERE
ple.delinked = 0
AND ple.posting_date <= %(to_date)s
AND acc.account_type = 'Receivable'
AND ple.company = %(company)s
{conditions}
GROUP BY
ple.party
HAVING
ABS(closing_balance) >= 0.01
""", filters, as_dict=1)
for key, row in voucher_balance.items(): # Combine all data
# Calculate outstanding result = combine_data(opening_balance_data, period_data, closing_balance_data)
row.outstanding = flt(row.invoiced - row.paid - row.credit_note, 2)
# Only include rows with outstanding amount # Get customer names and other details
if abs(row.outstanding) >= 0.01: add_customer_details(result)
# Set aging
set_ageing(row, filters)
# Set currency return result
row.currency = company_currency
data.append(row) def get_conditions(filters):
"""Build WHERE conditions based on filters"""
conditions = ""
# Sort by posting date and party if filters.get("customer"):
data = sorted(data, key=lambda x: (x.get('posting_date'), x.get('party'))) customer_list = "', '".join(filters.get("customer"))
conditions += f" AND ple.party IN ('{customer_list}')"
return data if filters.get("customer_group"):
conditions += " AND ple.party IN (SELECT name FROM `tabCustomer` WHERE customer_group = %(customer_group)s)"
def get_ple_entries(filters): return conditions
"""Get Payment Ledger Entries"""
ple = qb.DocType("Payment Ledger Entry")
# Build query conditions def combine_data(opening_data, period_data, closing_data):
conditions = [ """Combine all datasets into final result"""
ple.delinked == 0, result_dict = {}
ple.company == filters.get("company"),
ple.party_type == "Customer" # Only customers for receivables
]
# Date filter # Add opening balances
if filters.get("from_date"): for row in opening_data:
conditions.append(ple.posting_date >= filters.get("from_date")) customer = row['customer']
if filters.get("to_date"): result_dict[customer] = {
conditions.append(ple.posting_date <= filters.get("to_date")) 'customer': customer,
'opening_balance': row['opening_balance'] or 0.0,
'payments': 0.0,
'new_invoices': 0.0,
'closing_balance': 0.0
}
# Get receivable accounts # Add period movements
receivable_accounts = frappe.db.get_all( for row in period_data:
"Account", customer = row['customer']
filters={ if customer not in result_dict:
"account_type": "Receivable", result_dict[customer] = {
"company": filters.get("company") 'customer': customer,
}, 'opening_balance': 0.0,
pluck="name" 'payments': 0.0,
) 'new_invoices': 0.0,
'closing_balance': 0.0
}
if receivable_accounts: result_dict[customer]['payments'] = row['payments'] or 0.0
conditions.append(ple.account.isin(receivable_accounts)) result_dict[customer]['new_invoices'] = row['new_invoices'] or 0.0
# Execute query # Add closing balances
query = qb.from_(ple).select( for row in closing_data:
ple.account, customer = row['customer']
ple.voucher_type, if customer not in result_dict:
ple.voucher_no, result_dict[customer] = {
ple.against_voucher_type, 'customer': customer,
ple.against_voucher_no, 'opening_balance': 0.0,
ple.party_type, 'payments': 0.0,
ple.party, 'new_invoices': 0.0,
ple.posting_date, 'closing_balance': 0.0
ple.due_date, }
ple.amount,
ple.account_currency
).orderby(ple.posting_date, ple.party)
# Apply conditions result_dict[customer]['closing_balance'] = row['closing_balance'] or 0.0
for condition in conditions:
query = query.where(condition)
return query.run(as_dict=True) # Convert to list and filter out zero balances
result = []
for customer, data in result_dict.items():
# Only include if there's some activity or outstanding balance
if (abs(data['opening_balance']) >= 0.01 or
abs(data['closing_balance']) >= 0.01 or
abs(data['new_invoices']) >= 0.01 or
abs(data['payments']) >= 0.01):
result.append(data)
def build_voucher_balance(ple_entries): return sorted(result, key=lambda x: x['customer'])
"""Build voucher balance from PLE entries"""
voucher_balance = OrderedDict()
for ple in ple_entries: def add_customer_details(data):
# Create key for grouping - use against_voucher for payments """Add customer names and other details"""
if ple.against_voucher_no and ple.voucher_no != ple.against_voucher_no: if not data:
# This is a payment against an invoice return
key = (ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party)
voucher_type = ple.against_voucher_type
voucher_no = ple.against_voucher_no
else:
# This is an invoice or standalone entry
key = (ple.account, ple.voucher_type, ple.voucher_no, ple.party)
voucher_type = ple.voucher_type
voucher_no = ple.voucher_no
if key not in voucher_balance: customer_names = {}
voucher_balance[key] = frappe._dict({ customers = [row['customer'] for row in data]
'account': ple.account,
'voucher_type': voucher_type,
'voucher_no': voucher_no,
'party': ple.party,
'party_type': ple.party_type,
'posting_date': ple.posting_date,
'due_date': ple.due_date,
'account_currency': ple.account_currency,
'invoiced': 0.0,
'paid': 0.0,
'credit_note': 0.0,
'outstanding': 0.0
})
row = voucher_balance[key]
amount = ple.amount
# Update balances based on transaction type
if amount > 0:
# Positive amount - invoice or debit entry
if ple.voucher_type in ["Journal Entry", "Payment Entry"] and ple.voucher_no != ple.against_voucher_no:
row.paid -= amount # Payment received against invoice
else:
row.invoiced += amount # Invoice amount
else:
# Negative amount - payment or credit note
if ple.voucher_type in ["Sales Invoice"] and ple.voucher_no == ple.against_voucher_no:
# Return/credit note against same invoice
row.credit_note -= amount
else:
# Payment made
row.paid -= amount
return voucher_balance
def get_invoice_details(voucher_balance, filters):
"""Get additional invoice details"""
invoice_names = []
for key, row in voucher_balance.items():
if row.voucher_type == "Sales Invoice":
invoice_names.append(row.voucher_no)
if invoice_names:
# Get invoice details
invoice_details = frappe.db.get_all(
"Sales Invoice",
filters={
"name": ["in", invoice_names],
"company": filters.get("company")
},
fields=["name", "due_date", "po_no"]
)
invoice_dict = {d.name: d for d in invoice_details}
# Update voucher balance with invoice details
for key, row in voucher_balance.items():
if row.voucher_type == "Sales Invoice" and row.voucher_no in invoice_dict:
details = invoice_dict[row.voucher_no]
if not row.due_date and details.due_date:
row.due_date = details.due_date
row.po_no = details.po_no
def get_customer_details(voucher_balance):
"""Get customer details"""
customers = set()
for key, row in voucher_balance.items():
if row.party_type == "Customer":
customers.add(row.party)
if customers: if customers:
customer_details = frappe.db.get_all( customer_details = frappe.db.sql("""
"Customer", SELECT name, customer_name
filters={"name": ["in", list(customers)]}, FROM `tabCustomer`
fields=["name", "customer_name"] WHERE name IN ({})
) """.format(', '.join(['%s'] * len(customers))), customers, as_dict=1)
customer_dict = {d.name: d for d in customer_details} for customer in customer_details:
customer_names[customer['name']] = customer['customer_name']
# Update voucher balance with customer details # Update data with customer names
for key, row in voucher_balance.items(): for row in data:
if row.party in customer_dict: row['customer_name'] = customer_names.get(row['customer'], row['customer'])
row.customer_name = customer_dict[row.party].customer_name
def set_ageing(row, filters):
"""Set aging buckets"""
# Initialize aging fields
row.age = 0
row.range1 = 0.0 # 0-30
row.range2 = 0.0 # 30-60
row.range3 = 0.0 # 60-90
row.range4 = 0.0 # 90+
# Use due date if available, otherwise posting date
entry_date = row.due_date or row.posting_date
age_as_on = getdate(filters.get("to_date") or nowdate())
if entry_date:
row.age = (age_as_on - getdate(entry_date)).days
# Assign to appropriate aging bucket
if row.age <= 30:
row.range1 = row.outstanding
elif row.age <= 60:
row.range2 = row.outstanding
elif row.age <= 90:
row.range3 = row.outstanding
else:
row.range4 = row.outstanding