From 12775e6ca73702b1e093185ddd354b9fcc16b046 Mon Sep 17 00:00:00 2001 From: Corentin Forler Date: Fri, 24 Oct 2025 15:34:21 +0200 Subject: [PATCH] fix: Fix Customer Ledger Summary report --- .../customer_ledger_summary.py | 407 ++++++++++-------- 1 file changed, 218 insertions(+), 189 deletions(-) diff --git a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py index 99c2c85018..db5f8df526 100644 --- a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py +++ b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py @@ -1,72 +1,136 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt + import frappe from frappe import _, qb, scrub +from frappe.query_builder import Criterion, Tuple +from frappe.query_builder.functions import IfNull from frappe.utils import getdate, nowdate +from frappe.utils.nestedset import get_descendants_of +from pypika.terms import LiteralValue + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, + get_dimension_with_children, +) + +TREE_DOCTYPES = frozenset( + ["Customer Group", "Territory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"] +) -class PartyLedgerSummaryReport(object): +class PartyLedgerSummaryReport: def __init__(self, filters=None): self.filters = frappe._dict(filters or {}) self.filters.from_date = getdate(self.filters.from_date or nowdate()) self.filters.to_date = getdate(self.filters.to_date or nowdate()) - if not self.filters.get("company"): - self.filters["company"] = frappe.db.get_single_value("Global Defaults", "default_company") - def run(self, args): - if self.filters.from_date > self.filters.to_date: - frappe.throw(_("From Date must be before To Date")) - self.filters.party_type = args.get("party_type") - self.party_naming_by = frappe.db.get_value( - args.get("naming_by")[0], None, args.get("naming_by")[1] - ) + + self.validate_filters() + self.get_party_details() + + if not self.parties: + return [], [] self.get_gl_entries() - self.get_additional_columns() self.get_return_invoices() self.get_party_adjustment_amounts() + self.party_naming_by = frappe.db.get_single_value( + args.get("naming_by")[0], args.get("naming_by")[1] + ) columns = self.get_columns() data = self.get_data() + return columns, data - def get_additional_columns(self): + def validate_filters(self): + if not self.filters.get("company"): + frappe.throw(_("{0} is mandatory").format(_("Company"))) + + if self.filters.from_date > self.filters.to_date: + frappe.throw(_("From Date must be before To Date")) + + self.update_hierarchical_filters() + + def update_hierarchical_filters(self): + for doctype in TREE_DOCTYPES: + key = scrub(doctype) + if self.filters.get(key): + self.filters[key] = get_children(doctype, self.filters[key]) + + def get_party_details(self): """ Additional Columns for 'User Permission' based access control """ + self.parties = [] + self.party_details = frappe._dict() + party_type = self.filters.party_type + + doctype = qb.DocType(party_type) + conditions = self.get_party_conditions(doctype) + query = ( + qb.from_(doctype) + .select(doctype.name.as_("party"), f"{scrub(party_type)}_name") + .where(Criterion.all(conditions)) + ) - if self.filters.party_type == "Customer": - self.territories = frappe._dict({}) - self.customer_group = frappe._dict({}) - - customer = qb.DocType("Customer") - result = ( - frappe.qb.from_(customer) - .select( - customer.name, customer.territory, customer.customer_group, customer.default_sales_partner - ) - .where((customer.disabled == 0)) - .run(as_dict=True) - ) + from frappe.desk.reportview import build_match_conditions - for x in result: - self.territories[x.name] = x.territory - self.customer_group[x.name] = x.customer_group - else: - self.supplier_group = frappe._dict({}) - supplier = qb.DocType("Supplier") - result = ( - frappe.qb.from_(supplier) - .select(supplier.name, supplier.supplier_group) - .where((supplier.disabled == 0)) - .run(as_dict=True) + match_conditions = build_match_conditions(party_type) + + if match_conditions: + query = query.where(LiteralValue(match_conditions)) + + party_details = query.run(as_dict=True) + + for row in party_details: + self.parties.append(row.party) + self.party_details[row.party] = row + + def get_party_conditions(self, doctype): + conditions = [] + group_field = "customer_group" if self.filters.party_type == "Customer" else "supplier_group" + + if self.filters.party: + conditions.append(doctype.name == self.filters.party) + + if self.filters.territory: + conditions.append(doctype.territory.isin(self.filters.territory)) + + if self.filters.get(group_field): + conditions.append(doctype[group_field].isin(self.filters.get(group_field))) + + if self.filters.payment_terms_template: + conditions.append(doctype.payment_terms == self.filters.payment_terms_template) + + if self.filters.sales_partner: + conditions.append(doctype.default_sales_partner.isin(self.filters.sales_partner)) + + if self.filters.sales_person: + sales_team = qb.DocType("Sales Team") + sales_invoice = qb.DocType("Sales Invoice") + + customers = ( + qb.from_(sales_team) + .select(sales_team.parent) + .where(sales_team.sales_person.isin(self.filters.sales_person)) + .where(sales_team.parenttype == "Customer") + ) + ( + qb.from_(sales_team) + .join(sales_invoice) + .on(sales_team.parent == sales_invoice.name) + .select(sales_invoice.customer) + .where(sales_team.sales_person.isin(self.filters.sales_person)) + .where(sales_team.parenttype == "Sales Invoice") ) - for x in result: - self.supplier_group[x.name] = x.supplier_group + conditions.append(doctype.name.isin(customers)) + + return conditions def get_columns(self): columns = [ @@ -209,24 +273,31 @@ class PartyLedgerSummaryReport(object): ), ) - if self.filters.party_type == "Customer": - self.party_data[gle.party].update({"territory": self.territories.get(gle.party)}) - self.party_data[gle.party].update({"customer_group": self.customer_group.get(gle.party)}) - else: - self.party_data[gle.party].update({"supplier_group": self.supplier_group.get(gle.party)}) - amount = gle.get(invoice_dr_or_cr) - gle.get(reverse_dr_or_cr) self.party_data[gle.party].closing_balance += amount if gle.posting_date < self.filters.from_date or gle.is_opening == "Yes": self.party_data[gle.party].opening_balance += amount else: - if amount > 0: - self.party_data[gle.party].invoiced_amount += amount - elif gle.voucher_no in self.return_invoices: - self.party_data[gle.party].return_amount -= amount + # Cache the party data reference to avoid repeated dictionary lookups + party_data = self.party_data[gle.party] + + # Check if this is a direct return invoice (most specific condition first) + if gle.voucher_no in self.return_invoices: + party_data.return_amount -= amount + # Check if this entry is against a return invoice + elif gle.against_voucher in self.return_invoices: + # For entries against return invoices, positive amounts are payments + if amount > 0: + party_data.paid_amount -= amount + else: + party_data.invoiced_amount += amount + # Normal transaction logic else: - self.party_data[gle.party].paid_amount -= amount + if amount > 0: + party_data.invoiced_amount += amount + else: + party_data.paid_amount -= amount out = [] for party, row in self.party_data.items(): @@ -235,7 +306,7 @@ class PartyLedgerSummaryReport(object): or row.invoiced_amount or row.paid_amount or row.return_amount - or row.closing_amount + or row.closing_balance # Fixed typo from closing_amount to closing_balance ): total_party_adjustment = sum( amount for amount in self.party_adjustment_details.get(party, {}).values() @@ -251,175 +322,119 @@ class PartyLedgerSummaryReport(object): return out def get_gl_entries(self): - conditions = self.prepare_conditions() - join = join_field = "" - if self.filters.party_type == "Customer": - join_field = ", p.customer_name as party_name" - join = "left join `tabCustomer` p on gle.party = p.name" - elif self.filters.party_type == "Supplier": - join_field = ", p.supplier_name as party_name" - join = "left join `tabSupplier` p on gle.party = p.name" - - self.gl_entries = frappe.db.sql( - """ - select - gle.posting_date, gle.party, gle.voucher_type, gle.voucher_no, gle.against_voucher_type, - gle.against_voucher, gle.debit, gle.credit, gle.is_opening {join_field} - from `tabGL Entry` gle - {join} - where - gle.docstatus < 2 and gle.is_cancelled = 0 and gle.party_type=%(party_type)s and ifnull(gle.party, '') != '' - and gle.posting_date <= %(to_date)s {conditions} - order by gle.posting_date - """.format( - join=join, join_field=join_field, conditions=conditions - ), - self.filters, - as_dict=True, + gle = qb.DocType("GL Entry") + query = ( + qb.from_(gle) + .select( + gle.posting_date, + gle.party, + gle.voucher_type, + gle.voucher_no, + gle.against_voucher, # For handling returned invoices (Credit/Debit Notes) + gle.debit, + gle.credit, + gle.is_opening, + ) + .where( + (gle.docstatus < 2) + & (gle.is_cancelled == 0) + & (gle.party_type == self.filters.party_type) + & (IfNull(gle.party, "") != "") + & (gle.posting_date <= self.filters.to_date) + & (gle.party.isin(self.parties)) + ) ) - def prepare_conditions(self): - conditions = [""] + query = self.prepare_conditions(query) + + self.gl_entries = query.run(as_dict=True) + def prepare_conditions(self, query): + gle = qb.DocType("GL Entry") if self.filters.company: - conditions.append("gle.company=%(company)s") + query = query.where(gle.company == self.filters.company) if self.filters.finance_book: - conditions.append("ifnull(finance_book,'') in (%(finance_book)s, '')") - - if self.filters.get("party"): - conditions.append("party=%(party)s") - - if self.filters.party_type == "Customer": - if self.filters.get("customer_group"): - lft, rgt = frappe.get_cached_value( - "Customer Group", self.filters["customer_group"], ["lft", "rgt"] - ) - - conditions.append( - """party in (select name from tabCustomer - where exists(select name from `tabCustomer Group` where lft >= {0} and rgt <= {1} - and name=tabCustomer.customer_group))""".format( - lft, rgt - ) - ) - - if self.filters.get("territory"): - lft, rgt = frappe.db.get_value("Territory", self.filters.get("territory"), ["lft", "rgt"]) - - conditions.append( - """party in (select name from tabCustomer - where exists(select name from `tabTerritory` where lft >= {0} and rgt <= {1} - and name=tabCustomer.territory))""".format( - lft, rgt - ) - ) - - if self.filters.get("payment_terms_template"): - conditions.append( - "party in (select name from tabCustomer where payment_terms=%(payment_terms_template)s)" - ) + query = query.where(IfNull(gle.finance_book, "") == self.filters.finance_book) - if self.filters.get("sales_partner"): - conditions.append( - "party in (select name from tabCustomer where default_sales_partner=%(sales_partner)s)" - ) + if self.filters.cost_center: + query = query.where((gle.cost_center).isin(self.filters.cost_center)) - if self.filters.get("sales_person"): - lft, rgt = frappe.db.get_value( - "Sales Person", self.filters.get("sales_person"), ["lft", "rgt"] - ) + if self.filters.project: + query = query.where((gle.project).isin(self.filters.project)) - conditions.append( - """exists(select name from `tabSales Team` steam where - steam.sales_person in (select name from `tabSales Person` where lft >= {0} and rgt <= {1}) - and ((steam.parent = voucher_no and steam.parenttype = voucher_type) - or (steam.parent = against_voucher and steam.parenttype = against_voucher_type) - or (steam.parent = party and steam.parenttype = 'Customer')))""".format( - lft, rgt - ) - ) + accounting_dimensions = get_accounting_dimensions(as_list=False) - if self.filters.party_type == "Supplier": - if self.filters.get("supplier_group"): - conditions.append( - """party in (select name from tabSupplier - where supplier_group=%(supplier_group)s)""" - ) + if accounting_dimensions: + for dimension in accounting_dimensions: + if self.filters.get(dimension.fieldname): + if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"): + self.filters[dimension.fieldname] = get_dimension_with_children( + dimension.document_type, self.filters.get(dimension.fieldname) + ) + query = query.where((gle[dimension.fieldname]).isin(self.filters.get(dimension.fieldname))) + else: + query = query.where((gle[dimension.fieldname]).isin(self.filters.get(dimension.fieldname))) - return " and ".join(conditions) + return query def get_return_invoices(self): doctype = "Sales Invoice" if self.filters.party_type == "Customer" else "Purchase Invoice" - self.return_invoices = [ - d.name - for d in frappe.get_all( - doctype, - filters={ - "is_return": 1, - "docstatus": 1, - "posting_date": ["between", [self.filters.from_date, self.filters.to_date]], - }, - ) - ] + filters = ( + { + "is_return": 1, + "docstatus": 1, + "posting_date": ["between", [self.filters.from_date, self.filters.to_date]], + f"{scrub(self.filters.party_type)}": ["in", self.parties], + }, + ) + + self.return_invoices = frappe.get_all(doctype, filters=filters, pluck="name") def get_party_adjustment_amounts(self): - conditions = self.prepare_conditions() account_type = "Expense Account" if self.filters.party_type == "Customer" else "Income Account" - income_or_expense_accounts = frappe.db.get_all( - "Account", filters={"account_type": account_type, "company": self.filters.company}, pluck="name" - ) + invoice_dr_or_cr = "debit" if self.filters.party_type == "Customer" else "credit" reverse_dr_or_cr = "credit" if self.filters.party_type == "Customer" else "debit" round_off_account = frappe.get_cached_value("Company", self.filters.company, "round_off_account") - gl = qb.DocType("GL Entry") - if not income_or_expense_accounts: - # prevent empty 'in' condition - income_or_expense_accounts.append("") - else: - # escape '%' in account name - # ignoring frappe.db.escape as it replaces single quotes with double quotes - income_or_expense_accounts = [x.replace("%", "%%") for x in income_or_expense_accounts] + current_period_vouchers = set() + adjustment_voucher_entries = {} + + self.party_adjustment_details = {} + self.party_adjustment_accounts = set() + + for gle in self.gl_entries: + if ( + gle.is_opening != "Yes" + and gle.posting_date >= self.filters.from_date + and gle.posting_date <= self.filters.to_date + ): + current_period_vouchers.add((gle.voucher_type, gle.voucher_no)) + adjustment_voucher_entries.setdefault((gle.voucher_type, gle.voucher_no), []).append(gle) + + if not current_period_vouchers: + return - accounts_query = ( + gl = qb.DocType("GL Entry") + query = ( qb.from_(gl) - .select(gl.voucher_type, gl.voucher_no) + .select( + gl.posting_date, gl.account, gl.party, gl.voucher_type, gl.voucher_no, gl.debit, gl.credit + ) .where( - (gl.account.isin(income_or_expense_accounts)) + (gl.docstatus < 2) + & (gl.is_cancelled == 0) & (gl.posting_date.gte(self.filters.from_date)) & (gl.posting_date.lte(self.filters.to_date)) + & (Tuple((gl.voucher_type, gl.voucher_no)).isin(current_period_vouchers)) + & (IfNull(gl.party, "") == "") ) ) + query = self.prepare_conditions(query) + gl_entries = query.run(as_dict=True) - gl_entries = frappe.db.sql( - """ - select - posting_date, account, party, voucher_type, voucher_no, debit, credit - from - `tabGL Entry` - where - docstatus < 2 and is_cancelled = 0 - and (voucher_type, voucher_no) in ( - {accounts_query} - ) and (voucher_type, voucher_no) in ( - select voucher_type, voucher_no from `tabGL Entry` gle - where gle.party_type=%(party_type)s and ifnull(party, '') != '' - and gle.posting_date between %(from_date)s and %(to_date)s and gle.docstatus < 2 {conditions} - ) - """.format( - accounts_query=accounts_query, - conditions=conditions, - ), - self.filters, - as_dict=True, - ) - - self.party_adjustment_details = {} - self.party_adjustment_accounts = set() - adjustment_voucher_entries = {} for gle in gl_entries: - adjustment_voucher_entries.setdefault((gle.voucher_type, gle.voucher_no), []) adjustment_voucher_entries[(gle.voucher_type, gle.voucher_no)].append(gle) for voucher_gl_entries in adjustment_voucher_entries.values(): @@ -456,9 +471,23 @@ class PartyLedgerSummaryReport(object): self.party_adjustment_details[party][account] += amount +def get_children(doctype, value): + if not isinstance(value, list): + value = [d.strip() for d in value.strip().split(",") if d] + + all_children = [] + + for d in value: + all_children += get_descendants_of(doctype, value) + all_children.append(d) + + return list(set(all_children)) + + def execute(filters=None): args = { "party_type": "Customer", "naming_by": ["Selling Settings", "cust_master_name"], } + return PartyLedgerSummaryReport(filters).run(args) -- GitLab