From 32fb26e379941794819e862ed9ee4280f765408c Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Thu, 31 Jul 2025 18:32:30 +0400 Subject: [PATCH] Fixed customer_outstanding_balance report --- .../customer_outstanding_balance.js | 44 ++- .../customer_outstanding_balance.py | 309 ++++++++++++------ 2 files changed, 244 insertions(+), 109 deletions(-) diff --git a/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.js b/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.js index 9dd9ef3..50e79d1 100644 --- a/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.js +++ b/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.js @@ -2,37 +2,45 @@ // For license information, please see license.txt frappe.query_reports["Customer Outstanding Balance"] = { - filters: [ + "filters": [ { - fieldname: "from_date", - label: __("From Date"), - fieldtype: "Date", - default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), - reqd: 1 + "fieldname": "from_date", + "label": __("From Date"), + "fieldtype": "Date", + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1), + "reqd": 1 }, { - fieldname: "to_date", - label: __("To Date"), - fieldtype: "Date", - default: frappe.datetime.get_today(), - reqd: 1 + "fieldname": "to_date", + "label": __("To Date"), + "fieldtype": "Date", + "default": frappe.datetime.get_today(), + "reqd": 1 + }, + { + "fieldname": "company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "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); - // Highlight outstanding amount in red if overdue + // Highlight outstanding amounts in red if overdue if (column.fieldname == "outstanding" && data && data.outstanding > 0 && data.age > 30) { - value = "" + value + ""; + value = "" + value + ""; } - // Highlight age in orange if over 60 days + // Highlight age in orange if > 60 days if (column.fieldname == "age" && data && data.age > 60) { - value = "" + value + ""; + value = "" + value + ""; } - - // Bold formatting for total rows + + // Bold totals if (data && data.bold) { value = value.bold(); } diff --git a/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.py b/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.py index 718a5af..55ad3b5 100644 --- a/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.py +++ b/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.py @@ -2,11 +2,19 @@ # For license information, please see license.txt import frappe -from frappe import _ -from frappe.utils import flt, getdate, nowdate, date_diff +from frappe import _, qb +from frappe.utils import cint, flt, getdate, nowdate +from collections import OrderedDict def execute(filters=None): """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() data = get_data(filters) @@ -21,25 +29,12 @@ def get_columns(): "fieldtype": "Date", "width": 100 }, - { - "label": _("Party Type"), - "fieldname": "party_type", - "fieldtype": "Data", - "width": 100 - }, { "label": _("Party"), "fieldname": "party", - "fieldtype": "Dynamic Link", - "options": "party_type", - "width": 180 - }, - { - "label": _("Party Account"), - "fieldname": "party_account", "fieldtype": "Link", - "options": "Account", - "width": 180 + "options": "Customer", + "width": 150 }, { "label": _("Customer Name"), @@ -58,7 +53,7 @@ def get_columns(): "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", - "width": 140 + "width": 150 }, { "label": _("Due Date"), @@ -88,7 +83,7 @@ def get_columns(): "label": _("Outstanding Amount"), "fieldname": "outstanding", "fieldtype": "Currency", - "width": 120 + "width": 130 }, { "label": _("Age (Days)"), @@ -115,17 +110,11 @@ def get_columns(): "width": 100 }, { - "label": _("90-120"), + "label": _("90+"), "fieldname": "range4", "fieldtype": "Currency", "width": 100 }, - { - "label": _("120-Above"), - "fieldname": "range5", - "fieldtype": "Currency", - "width": 100 - }, { "label": _("Currency"), "fieldname": "currency", @@ -137,79 +126,217 @@ def get_columns(): def get_data(filters): """Get report data""" - conditions = get_conditions(filters) - # Get Payment Ledger Entries - data = frappe.db.sql(f""" - SELECT - ple.posting_date, - ple.party_type, - ple.party, - ple.account as party_account, - COALESCE(c.customer_name, s.supplier_name, ple.party) as customer_name, - ple.voucher_type, - ple.voucher_no, - ple.due_date, - ple.account_currency as currency, - SUM(CASE WHEN ple.amount > 0 THEN ple.amount ELSE 0 END) as invoiced, - SUM(CASE WHEN ple.amount < 0 THEN ABS(ple.amount) ELSE 0 END) as paid, - 0 as credit_note, - SUM(ple.amount) as outstanding - FROM - `tabPayment Ledger Entry` ple - LEFT JOIN - `tabCustomer` c ON c.name = ple.party AND ple.party_type = 'Customer' - LEFT JOIN - `tabSupplier` s ON s.name = ple.party AND ple.party_type = 'Supplier' - WHERE - ple.delinked = 0 - {conditions} - GROUP BY - ple.account, ple.voucher_type, ple.voucher_no, ple.party - HAVING - ABS(outstanding) >= 0.01 - ORDER BY - ple.posting_date DESC, ple.party - """, filters, as_dict=1) + ple_entries = get_ple_entries(filters) - # Calculate age and age ranges - today = getdate(nowdate()) + # Build voucher balance + voucher_balance = build_voucher_balance(ple_entries) - for row in data: - # Calculate age based on due date or posting date - age_date = row.due_date or row.posting_date - if age_date: - row.age = date_diff(today, age_date) - else: - row.age = 0 + # Get additional details + get_invoice_details(voucher_balance, filters) + get_customer_details(voucher_balance) + + # Build final data + data = [] + company_currency = frappe.get_cached_value("Company", filters.get("company"), "default_currency") + + for key, row in voucher_balance.items(): + # Calculate outstanding + row.outstanding = flt(row.invoiced - row.paid - row.credit_note, 2) - # Initialize age ranges - row.range1 = row.range2 = row.range3 = row.range4 = row.range5 = 0.0 - - # Allocate outstanding amount to appropriate age range - outstanding = flt(row.outstanding) - if outstanding > 0: - if row.age <= 30: - row.range1 = outstanding - elif row.age <= 60: - row.range2 = outstanding - elif row.age <= 90: - row.range3 = outstanding - elif row.age <= 120: - row.range4 = outstanding - else: - row.range5 = outstanding + # Only include rows with outstanding amount + if abs(row.outstanding) >= 0.01: + # Set aging + set_ageing(row, filters) + + # Set currency + row.currency = company_currency + + data.append(row) + + # Sort by posting date and party + data = sorted(data, key=lambda x: (x.get('posting_date'), x.get('party'))) return data -def get_conditions(filters): - """Build WHERE conditions based on filters""" - conditions = "" +def get_ple_entries(filters): + """Get Payment Ledger Entries""" + ple = qb.DocType("Payment Ledger Entry") + # Build query conditions + conditions = [ + ple.delinked == 0, + ple.company == filters.get("company"), + ple.party_type == "Customer" # Only customers for receivables + ] + + # Date filter if filters.get("from_date"): - conditions += " AND ple.posting_date >= %(from_date)s" - + conditions.append(ple.posting_date >= filters.get("from_date")) if filters.get("to_date"): - conditions += " AND ple.posting_date <= %(to_date)s" + conditions.append(ple.posting_date <= filters.get("to_date")) - return conditions \ No newline at end of file + # Get receivable accounts + receivable_accounts = frappe.db.get_all( + "Account", + filters={ + "account_type": "Receivable", + "company": filters.get("company") + }, + pluck="name" + ) + + if receivable_accounts: + conditions.append(ple.account.isin(receivable_accounts)) + + # Execute query + query = qb.from_(ple).select( + ple.account, + ple.voucher_type, + ple.voucher_no, + ple.against_voucher_type, + ple.against_voucher_no, + ple.party_type, + ple.party, + ple.posting_date, + ple.due_date, + ple.amount, + ple.account_currency + ).orderby(ple.posting_date, ple.party) + + # Apply conditions + for condition in conditions: + query = query.where(condition) + + return query.run(as_dict=True) + +def build_voucher_balance(ple_entries): + """Build voucher balance from PLE entries""" + voucher_balance = OrderedDict() + + for ple in ple_entries: + # Create key for grouping - use against_voucher for payments + if ple.against_voucher_no and ple.voucher_no != ple.against_voucher_no: + # This is a payment against an invoice + 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: + voucher_balance[key] = frappe._dict({ + '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: + customer_details = frappe.db.get_all( + "Customer", + filters={"name": ["in", list(customers)]}, + fields=["name", "customer_name"] + ) + + customer_dict = {d.name: d for d in customer_details} + + # Update voucher balance with customer details + for key, row in voucher_balance.items(): + if row.party in customer_dict: + 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 \ No newline at end of file