From b498e7100cbd99c35d65d930408e9f05b22f81ea Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Thu, 31 Jul 2025 21:21:28 +0400 Subject: [PATCH] Remade customer balance report --- .../customer_outstanding_balance.js | 57 ++- .../customer_outstanding_balance.json | 5 +- .../customer_outstanding_balance.py | 442 +++++++----------- 3 files changed, 201 insertions(+), 303 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 50e79d1..26beec2 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 @@ -3,6 +3,14 @@ frappe.query_reports["Customer Outstanding Balance"] = { "filters": [ + { + "fieldname": "company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "default": frappe.defaults.get_user_default("Company") + }, { "fieldname": "from_date", "label": __("From Date"), @@ -11,40 +19,51 @@ frappe.query_reports["Customer Outstanding Balance"] = { "reqd": 1 }, { - "fieldname": "to_date", + "fieldname": "to_date", "label": __("To Date"), "fieldtype": "Date", "default": frappe.datetime.get_today(), "reqd": 1 }, { - "fieldname": "company", - "label": __("Company"), + "fieldname": "customer", + "label": __("Customer"), + "fieldtype": "MultiSelectList", + "get_data": function (txt) { + return frappe.db.get_link_options("Customer", txt); + } + }, + { + "fieldname": "customer_group", + "label": __("Customer Group"), "fieldtype": "Link", - "options": "Company", - "reqd": 1, - "default": frappe.defaults.get_user_default("Company") + "options": "Customer Group" } ], "formatter": function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - - // Highlight outstanding amounts in red if overdue - if (column.fieldname == "outstanding" && data && data.outstanding > 0 && data.age > 30) { - value = "" + value + ""; - } - - // Highlight age in orange if > 60 days - if (column.fieldname == "age" && data && data.age > 60) { - value = "" + value + ""; + + // Highlight opening balance in blue + if (column.fieldname == "opening_balance" && data && data.opening_balance > 0) { + value = "" + value + ""; } - // Bold totals - if (data && data.bold) { - value = value.bold(); + // Highlight payments in green + if (column.fieldname == "payments" && data && data.payments > 0) { + value = "" + value + ""; } - + + // Highlight new invoices in orange + if (column.fieldname == "new_invoices" && data && data.new_invoices > 0) { + value = "" + value + ""; + } + + // Highlight closing balance in dark red + if (column.fieldname == "closing_balance" && data && data.closing_balance > 0) { + value = "" + value + ""; + } + return value; } }; \ No newline at end of file diff --git a/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.json b/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.json index 67cbd39..b537870 100644 --- a/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.json +++ b/taxes_az/taxes_az/report/customer_outstanding_balance/customer_outstanding_balance.json @@ -9,7 +9,7 @@ "idx": 0, "is_standard": "Yes", "letterhead": null, - "modified": "2025-07-31 19:49:06.707566", + "modified": "2025-07-31 19:36:23.704645", "modified_by": "Administrator", "module": "Taxes Az", "name": "Customer Outstanding Balance", @@ -25,5 +25,6 @@ { "role": "Accounts Manager" } - ] + ], + "timeout": 0 } \ No newline at end of file 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 55ad3b5..d872e51 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,19 +2,10 @@ # For license information, please see license.txt import frappe -from frappe import _, qb -from frappe.utils import cint, flt, getdate, nowdate -from collections import OrderedDict +from frappe import _ 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) @@ -24,14 +15,8 @@ def get_columns(): """Get report columns definition""" return [ { - "label": _("Posting Date"), - "fieldname": "posting_date", - "fieldtype": "Date", - "width": 100 - }, - { - "label": _("Party"), - "fieldname": "party", + "label": _("Customer"), + "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 150 @@ -40,303 +25,196 @@ def get_columns(): "label": _("Customer Name"), "fieldname": "customer_name", "fieldtype": "Data", - "width": 150 + "width": 200 }, { - "label": _("Voucher Type"), - "fieldname": "voucher_type", - "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", + "label": _("Opening Balance"), + "fieldname": "opening_balance", "fieldtype": "Currency", - "width": 120 + "width": 140 }, { - "label": _("Paid Amount"), - "fieldname": "paid", + "label": _("Payments (Decrease)"), + "fieldname": "payments", "fieldtype": "Currency", - "width": 120 + "width": 140 }, { - "label": _("Credit Note"), - "fieldname": "credit_note", + "label": _("New Invoices (Increase)"), + "fieldname": "new_invoices", "fieldtype": "Currency", - "width": 120 + "width": 140 }, { - "label": _("Outstanding Amount"), - "fieldname": "outstanding", + "label": _("Closing Balance"), + "fieldname": "closing_balance", "fieldtype": "Currency", - "width": 130 - }, - { - "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 + "width": 140 } ] def get_data(filters): """Get report data""" - # Get Payment Ledger Entries - ple_entries = get_ple_entries(filters) + conditions = get_conditions(filters) - # Build voucher balance - voucher_balance = build_voucher_balance(ple_entries) + # Get opening balance (before from_date) + 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_invoice_details(voucher_balance, filters) - get_customer_details(voucher_balance) + # Get movements during the period + period_data = frappe.db.sql(f""" + 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 - data = [] - company_currency = frappe.get_cached_value("Company", filters.get("company"), "default_currency") + # Get closing balance (up to to_date) + closing_balance_data = frappe.db.sql(f""" + 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(): - # Calculate outstanding - row.outstanding = flt(row.invoiced - row.paid - row.credit_note, 2) - - # 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) + # Combine all data + result = combine_data(opening_balance_data, period_data, closing_balance_data) - # Sort by posting date and party - data = sorted(data, key=lambda x: (x.get('posting_date'), x.get('party'))) + # Get customer names and other details + add_customer_details(result) - return data + return result -def get_ple_entries(filters): - """Get Payment Ledger Entries""" - ple = qb.DocType("Payment Ledger Entry") +def get_conditions(filters): + """Build WHERE conditions based on filters""" + conditions = "" - # Build query conditions - conditions = [ - ple.delinked == 0, - ple.company == filters.get("company"), - ple.party_type == "Customer" # Only customers for receivables - ] + if filters.get("customer"): + customer_list = "', '".join(filters.get("customer")) + conditions += f" AND ple.party IN ('{customer_list}')" - # Date filter - if filters.get("from_date"): - conditions.append(ple.posting_date >= filters.get("from_date")) - if filters.get("to_date"): - conditions.append(ple.posting_date <= filters.get("to_date")) + if filters.get("customer_group"): + conditions += " AND ple.party IN (SELECT name FROM `tabCustomer` WHERE customer_group = %(customer_group)s)" - # 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) + return conditions -def build_voucher_balance(ple_entries): - """Build voucher balance from PLE entries""" - voucher_balance = OrderedDict() +def combine_data(opening_data, period_data, closing_data): + """Combine all datasets into final result""" + result_dict = {} - 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 + # Add opening balances + for row in opening_data: + customer = row['customer'] + result_dict[customer] = { + 'customer': customer, + 'opening_balance': row['opening_balance'] or 0.0, + 'payments': 0.0, + 'new_invoices': 0.0, + 'closing_balance': 0.0 + } - return voucher_balance + # Add period movements + for row in period_data: + customer = row['customer'] + if customer not in result_dict: + result_dict[customer] = { + 'customer': customer, + 'opening_balance': 0.0, + 'payments': 0.0, + 'new_invoices': 0.0, + 'closing_balance': 0.0 + } + + result_dict[customer]['payments'] = row['payments'] or 0.0 + result_dict[customer]['new_invoices'] = row['new_invoices'] or 0.0 + + # Add closing balances + for row in closing_data: + customer = row['customer'] + if customer not in result_dict: + result_dict[customer] = { + 'customer': customer, + 'opening_balance': 0.0, + 'payments': 0.0, + 'new_invoices': 0.0, + 'closing_balance': 0.0 + } + + result_dict[customer]['closing_balance'] = row['closing_balance'] or 0.0 + + # 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) + + return sorted(result, key=lambda x: x['customer']) -def get_invoice_details(voucher_balance, filters): - """Get additional invoice details""" - invoice_names = [] +def add_customer_details(data): + """Add customer names and other details""" + if not data: + return - 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) + customer_names = {} + customers = [row['customer'] for row in data] if customers: - customer_details = frappe.db.get_all( - "Customer", - filters={"name": ["in", list(customers)]}, - fields=["name", "customer_name"] - ) + customer_details = frappe.db.sql(""" + SELECT name, customer_name + FROM `tabCustomer` + WHERE name IN ({}) + """.format(', '.join(['%s'] * len(customers))), customers, as_dict=1) - 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+ + for customer in customer_details: + customer_names[customer['name']] = customer['customer_name'] - # 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 + # Update data with customer names + for row in data: + row['customer_name'] = customer_names.get(row['customer'], row['customer']) \ No newline at end of file