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