Compare commits

...

11 Commits

18 changed files with 1240 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Payable"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date\nSupplier Invoice Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
options: "Supplier Group",
},
{
fieldname: "group_by_party",
label: __("Group By Supplier"),
fieldtype: "Check",
},
{
fieldname: "group_by_company",
label: __("Group By Company"),
fieldtype: "Check",
},
{
fieldname: "ignore_accounts",
label: __("Group by Voucher"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_remarks",
label: __("Show Remarks"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
{
fieldname: "in_party_currency",
label: __("In Party Currency"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Payable Summary"), function () {
frappe.set_route("query-report", "Consolidated Accounts Payable Summary", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Payable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,36 @@
{
"add_total_row": 1,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-11 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-09-11 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Payable",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Consolidated Accounts Payable",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Purchase User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
ConsolidatedReceivablePayable,
)
def execute(filters=None):
args = {
"account_type": "Payable",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return ConsolidatedReceivablePayable(filters).run(args)

View File

@@ -0,0 +1,133 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Payable Summary"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date\nSupplier Invoice Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "supplier_group",
label: __("Supplier Group"),
fieldtype: "Link",
options: "Supplier Group",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_gl_balance",
label: __("Show GL Balance"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Payable"), function () {
frappe.set_route("query-report", "Consolidated Accounts Payable", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Payable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,36 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-08-07 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Payable Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Consolidated Accounts Payable Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Purchase User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,14 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from erpnext.accounts.report.consolidated_accounts_receivable_summary.consolidated_accounts_receivable_summary import (
ConsolidatedReceivablePayableSummary,
)
def execute(filters=None):
args = {
"account_type": "Payable",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return ConsolidatedReceivablePayableSummary(filters).run(args)

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Receivable"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"customer_group",
frappe.query_report.get_filter_value("party_type") !== "Customer"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "customer_group",
label: __("Customer Group"),
fieldtype: "Link",
options: "Customer Group",
},
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "sales_partner",
label: __("Sales Partner"),
fieldtype: "Link",
options: "Sales Partner",
},
{
fieldname: "sales_person",
label: __("Sales Person"),
fieldtype: "Link",
options: "Sales Person",
},
{
fieldname: "group_by_party",
label: __("Group By Customer"),
fieldtype: "Check",
},
{
fieldname: "group_by_company",
label: __("Group By Company"),
fieldtype: "Check",
},
{
fieldname: "ignore_accounts",
label: __("Group by Voucher"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "show_remarks",
label: __("Show Remarks"),
fieldtype: "Check",
},
{
fieldname: "show_sales_person",
label: __("Show Sales Person"),
fieldtype: "Check",
},
{
fieldname: "show_delivery_notes",
label: __("Show Linked Delivery Notes"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
{
fieldname: "in_party_currency",
label: __("In Party Currency"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Receivable Summary"), function () {
frappe.set_route("query-report", "Consolidated Accounts Receivable Summary", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Receivable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,30 @@
{
"add_total_row": 1,
"add_translate_data": 0,
"columns": [],
"creation": "2026-09-11 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-09-11 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Receivable",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Invoice",
"report_name": "Consolidated Accounts Receivable",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts Manager"
},
{
"role": "Accounts User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,147 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict
import frappe
from frappe import _
from frappe.model import numeric_fieldtypes
from frappe.utils import flt
from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
get_subsidiary_companies,
)
# Outstanding invoices of a party across companies that need not be related to each other.
# Every row carries the company it belongs to, and a party's companies sit together.
def execute(filters=None):
args = {
"account_type": "Receivable",
"naming_by": ["Selling Settings", "cust_master_name"],
}
return ConsolidatedReceivablePayable(filters).run(args)
class ConsolidatedReceivablePayable(ReceivablePayableReport):
def run(self, args):
self.companies = get_consolidated_companies(self.filters)
self.args = args # the engine's get_data() takes no arguments, so hand them over here
columns, data, _message, chart, _report_summary, skip_total_row = super().run(args)
if self.filters.get("group_by_company") or len(row_currencies(data)) > 1:
# a grand total would either double count the subtotals or add unlike currencies
skip_total_row = 1
return columns, data, None, chart, None, skip_total_row
def get_columns(self):
super().get_columns()
add_company_columns(self.columns)
def get_data(self):
# party wins when both are checked, its subtotal is the point of this report
if self.filters.get("group_by_party"):
group_by, subtotal_of = "party", self.party_subtotal
elif self.filters.get("group_by_company"):
group_by, subtotal_of = "company", self.company_subtotal
else:
group_by, subtotal_of = "party", None
self.data = []
for rows in self.get_grouped_rows(group_by).values():
self.data.extend(rows)
if subtotal_of and len(row_currencies(rows)) <= 1:
self.data.append(subtotal_of(rows))
self.data.append({}) # blank separator, like the engine's own grouping
def get_grouped_rows(self, group_by):
"""Rows of every company, regrouped so those sharing `group_by` sit together."""
grouped = OrderedDict()
for row in rows_per_company(self.companies, self.filters, self.args, ReceivablePayableReport):
grouped.setdefault(row.get(group_by), []).append(row)
return grouped
def party_subtotal(self, rows):
return self.subtotal(rows, party=rows[0].party)
def company_subtotal(self, rows):
return self.subtotal(rows, company=rows[0].company)
def subtotal(self, rows, **label):
# same shape as the engine's own group-by-party subtotal
subtotal = frappe._dict(currency=rows[0].get("currency"), bold=1, **label)
for field in self.get_currency_fields():
subtotal[field] = sum(flt(row.get(field)) for row in rows)
return subtotal
def rows_per_company(companies, filters, args, engine):
"""Run `engine` once per company, tagging every row with the company it came from."""
for company in companies:
# subtotals are appended once per group by the caller, not once per company
company_filters = frappe._dict(filters, company=company, group_by_party=0)
company_filters.pop("companies", None)
parent = frappe.get_cached_value("Company", company, "parent_company")
for row in engine(company_filters).run(args)[1]:
row.company, row.parent_company = company, parent
yield row
def get_consolidated_companies(filters):
"""Selected companies, a group company standing for the companies under it."""
companies = []
for selected in filters.get("companies") or []:
for company in get_subsidiary_companies(selected):
if company not in companies:
companies.append(company)
return companies
def row_currencies(rows):
return {row.get("currency") for row in rows if row.get("currency")}
def add_company_columns(columns):
"""Company and its parent, right after the party columns, plus header alignment."""
at = company_column_index(columns)
columns.insert(
at,
dict(
label=_("Company"),
fieldname="company",
fieldtype="Link",
options="Company",
width=180,
sticky=True,
),
)
columns.insert(
at + 1,
dict(
label=_("Parent Company"),
fieldname="parent_company",
fieldtype="Link",
options="Company",
width=160,
),
)
# datatable guesses alignment from the first row, which misreads an empty column
for column in columns:
column["align"] = "right" if column["fieldtype"] in numeric_fieldtypes else "left"
def company_column_index(columns):
fieldnames = [column["fieldname"] for column in columns]
for fieldname in ("party_name", "party"):
if fieldname in fieldnames:
return fieldnames.index(fieldname) + 1
return 0

View File

@@ -0,0 +1,133 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
execute,
)
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class ConsolidatedReportMixin(AccountsTestMixin):
"""Companies, invoices and filters shared by the consolidated report tests."""
def filters(self, companies=None, **kwargs):
return {
# an explicit empty list means "nothing selected", so do not fall back on it
"companies": [self.company_a, self.company_b] if companies is None else companies,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range": "30, 60, 90, 120",
**kwargs,
}
def create_invoice(self, company, abbr, rate, customer=None, currency=None):
return create_sales_invoice(
item=self.item,
company=company,
customer=customer or self.customer,
currency=currency,
debit_to=f"Debtors - {abbr}",
income_account=f"Sales - {abbr}",
cost_center=f"Main - {abbr}",
parent_cost_center=f"Main - {abbr}",
warehouse=f"Stores - {abbr}",
posting_date=today(),
rate=rate,
price_list_rate=rate,
)
def create_test_company(self, company_name, abbr, currency="INR", is_group=0, parent=None):
if frappe.db.exists("Company", company_name):
return company_name
company = frappe.new_doc("Company")
company.company_name = company_name
company.abbr = abbr
company.country = "India"
company.default_currency = currency
company.create_chart_of_accounts_based_on = "Standard Template"
company.chart_of_accounts = "Standard"
company.is_group = is_group
company.parent_company = parent
company.insert()
return company.name
class TestConsolidatedAccountsReceivable(ERPNextTestSuite, ConsolidatedReportMixin):
def setUp(self):
self.maxDiff = None
# deliberately unrelated companies, there is no parent/child link between them
self.company_a = self.create_test_company("_Test Unrelated A", "_TUNA")
self.company_b = self.create_test_company("_Test Unrelated B", "_TUNB")
self.create_customer("_Test Consolidation Customer")
# the mixin would otherwise pass company=None, which the Item Default row rejects
self.create_item("_Test Consolidation Item", company=self.company_a)
def test_rows_carry_the_company_they_came_from(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, self.company_b])
self.assertFalse(any(r.parent_company for r in rows))
def test_group_by_party_adds_one_subtotal_for_all_companies(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
result = execute(self.filters(group_by_party=1))
subtotals = [row for row in result[1] if row.get("bold")]
self.assertEqual(len(subtotals), 1)
self.assertEqual(subtotals[0].outstanding, 500.0)
self.assertEqual(result[5], 1) # skip_total_row, else the grand total double counts
def test_group_by_company_subtotals_each_company(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
result = execute(self.filters(group_by_company=1))
subtotals = [row for row in result[1] if row.get("bold")]
self.assertEqual([s.company for s in subtotals], [self.company_a, self.company_b])
self.assertEqual([s.outstanding for s in subtotals], [200.0, 300.0])
self.assertEqual(result[5], 1)
def test_party_grouping_wins_when_both_are_selected(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
subtotals = [
row for row in execute(self.filters(group_by_party=1, group_by_company=1))[1] if row.get("bold")
]
self.assertEqual(len(subtotals), 1)
self.assertEqual(subtotals[0].party, self.customer)
def test_no_total_when_companies_use_different_currencies(self):
usd = self.create_test_company("_Test Unrelated USD", "_TUNU", currency="USD")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(usd, "_TUNU", 300, currency="USD")
result = execute(self.filters(companies=[self.company_a, usd]))
self.assertEqual([r.company for r in result[1]], [self.company_a, usd])
self.assertEqual(result[5], 1) # skip_total_row, the rows span currencies
def test_group_company_expands_to_its_subsidiaries(self):
group = self.create_test_company("_Test Consolidation Group", "_TCGRP", is_group=1)
child = self.create_test_company("_Test Consolidation Child", "_TCCLD", parent=group)
self.create_invoice(child, "_TCCLD", 400)
rows = execute(self.filters(companies=[group]))[1]
self.assertEqual([r.company for r in rows], [child])
self.assertEqual([r.parent_company for r in rows], [group])

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Consolidated Accounts Receivable Summary"] = {
filters: [
{
fieldname: "companies",
label: __("Companies"),
fieldtype: "MultiSelectList",
options: "Company",
get_data: function (txt) {
return frappe.db.get_link_options("Company", txt);
},
description: __("Totals are dropped when the companies use different currencies"),
},
{
fieldname: "report_date",
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
{
fieldname: "ageing_based_on",
label: __("Ageing Based On"),
fieldtype: "Select",
options: "Posting Date\nDue Date",
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday",
default: "Report Date",
},
{
fieldname: "range",
label: __("Ageing Range"),
fieldtype: "Data",
default: "30, 60, 90, 120",
},
{
fieldname: "party_type",
label: __("Party Type"),
fieldtype: "Autocomplete",
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"customer_group",
frappe.query_report.get_filter_value("party_type") !== "Customer"
);
},
},
{
fieldname: "party",
label: __("Party"),
fieldtype: "MultiSelectList",
options: "party_type",
get_data: function (txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value("party_type");
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{
fieldname: "customer_group",
label: __("Customer Group"),
fieldtype: "Link",
options: "Customer Group",
},
{
fieldname: "territory",
label: __("Territory"),
fieldtype: "MultiSelectList",
options: "Territory",
get_data: function (txt) {
return frappe.db.get_link_options("Territory", txt);
},
},
{
fieldname: "show_future_payments",
label: __("Show Future Payments"),
fieldtype: "Check",
},
{
fieldname: "sales_partner",
label: __("Sales Partner"),
fieldtype: "Link",
options: "Sales Partner",
},
{
fieldname: "sales_person",
label: __("Sales Person"),
fieldtype: "Link",
options: "Sales Person",
},
{
fieldname: "show_gl_balance",
label: __("Show GL Balance"),
fieldtype: "Check",
},
{
fieldname: "show_sales_person",
label: __("Show Sales Person"),
fieldtype: "Check",
},
{
fieldname: "based_on_payment_terms",
label: __("Based On Payment Terms"),
fieldtype: "Check",
},
{
fieldname: "for_revaluation_journals",
label: __("Revaluation Journals"),
fieldtype: "Check",
},
],
collapsible_filters: true,
separate_check_filters: true,
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (data && data.bold) {
value = value.bold();
}
return value;
},
onload: function (report) {
report.page.add_inner_button(__("Consolidated Accounts Receivable"), function () {
frappe.set_route("query-report", "Consolidated Accounts Receivable", report.get_values());
});
const company = frappe.defaults.get_user_default("Company");
if (company && !(report.get_filter_value("companies") || []).length) {
report.set_filter_value("companies", [company]);
}
if (frappe.boot.sysdefaults.default_ageing_range) {
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
}
},
};
function get_party_type_options() {
let options = [];
frappe.db
.get_list("Party Type", { filters: { account_type: "Receivable" }, fields: ["name"] })
.then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@@ -0,0 +1,30 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-08-07 10:00:00.000000",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2026-08-07 10:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Consolidated Accounts Receivable Summary",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Sales Invoice",
"report_name": "Consolidated Accounts Receivable Summary",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts Manager"
},
{
"role": "Accounts User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,73 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import (
AccountsReceivableSummary,
)
from erpnext.accounts.report.consolidated_accounts_receivable.consolidated_accounts_receivable import (
add_company_columns,
get_consolidated_companies,
row_currencies,
rows_per_company,
)
# What a party owes (or is owed) across companies that need not be related to each other.
# Each party gets one row per company, followed by a total row for that party.
def execute(filters=None):
args = {
"account_type": "Receivable",
"naming_by": ["Selling Settings", "cust_master_name"],
}
return ConsolidatedReceivablePayableSummary(filters).run(args)
class ConsolidatedReceivablePayableSummary(AccountsReceivableSummary):
def run(self, args):
self.companies = get_consolidated_companies(self.filters)
return super().run(args)
def get_columns(self):
super().get_columns()
add_company_columns(self.columns)
def get_data(self, args):
self.data = []
for rows in self.get_rows_by_party(args).values():
self.data.extend(rows)
# a total over companies of differing currencies would add unlike units
if len(row_currencies(rows)) <= 1:
self.data.append(self.total_row(rows))
def get_rows_by_party(self, args):
"""Rows of every company, regrouped so a party's companies sit together."""
by_party = OrderedDict()
for row in rows_per_company(self.companies, self.filters, args, AccountsReceivableSummary):
by_party.setdefault(row.party, []).append(row)
return by_party
def total_row(self, rows):
# label sits in the first column, like the total row of the plain summary reports;
# `bold` is picked up by the formatter in the report's js
total = frappe._dict(
party_type=_("Total"),
party="",
company="",
currency=rows[0].get("currency"),
bold=1,
)
for row in rows:
for field, value in row.items():
# `advance` arrives as an int when there is none, so don't filter on float alone
if isinstance(value, int | float) and not isinstance(value, bool):
total[field] = flt(total.get(field)) + value
return total

View File

@@ -0,0 +1,98 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from frappe.utils import today
from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_summary import (
execute as execute_summary,
)
from erpnext.accounts.report.consolidated_accounts_receivable.test_consolidated_accounts_receivable import (
ConsolidatedReportMixin,
)
from erpnext.accounts.report.consolidated_accounts_receivable_summary.consolidated_accounts_receivable_summary import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestConsolidatedAccountsReceivableSummary(ERPNextTestSuite, ConsolidatedReportMixin):
def setUp(self):
self.maxDiff = None
# deliberately unrelated companies, there is no parent/child link between them
self.company_a = self.create_test_company("_Test Unrelated A", "_TUNA")
self.company_b = self.create_test_company("_Test Unrelated B", "_TUNB")
self.create_customer("_Test Consolidation Customer")
# the mixin would otherwise pass company=None, which the Item Default row rejects
self.create_item("_Test Consolidation Item", company=self.company_a)
def test_party_gets_a_row_per_company_plus_a_total(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, self.company_b, ""])
self.assertEqual([r.party for r in rows], [self.customer, self.customer, ""])
self.assertEqual([r.outstanding for r in rows], [200.0, 300.0, 500.0])
self.assertEqual(rows[-1].party_type, "Total")
self.assertTrue(rows[-1].bold)
def test_total_matches_individual_company_summaries(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_b, "_TUNB", 300)
total = execute(self.filters())[1][-1].outstanding
individual = sum(self.company_outstanding(company) for company in (self.company_a, self.company_b))
self.assertEqual(total, individual)
def test_company_without_transactions_is_omitted(self):
self.create_invoice(self.company_a, "_TUNA", 200)
rows = execute(self.filters())[1]
self.assertEqual([r.company for r in rows], [self.company_a, ""])
self.assertEqual(rows[-1].outstanding, 200.0)
def test_no_companies_selected_returns_nothing(self):
self.create_invoice(self.company_a, "_TUNA", 200)
self.assertEqual(execute(self.filters(companies=[]))[1], [])
def test_all_parties_are_shown_when_no_party_is_selected(self):
self.create_customer("_Test Second Consolidation Customer")
other = self.customer
self.create_customer("_Test Consolidation Customer")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(self.company_a, "_TUNA", 300, customer=other)
filters = self.filters()
filters.pop("party")
parties = {r.party for r in execute(filters)[1] if r.party}
self.assertEqual(parties, {self.customer, other})
def test_group_by_party_carried_over_from_the_detail_report_is_ignored(self):
# the detail report's button hands its own filters over, and the engine would
# otherwise answer with separator rows the summary cannot total
self.create_invoice(self.company_a, "_TUNA", 200)
rows = execute(self.filters(group_by_party=1))[1]
self.assertEqual([r.outstanding for r in rows], [200.0, 200.0])
def test_no_total_when_companies_use_different_currencies(self):
usd = self.create_test_company("_Test Unrelated USD", "_TUNU", currency="USD")
self.create_invoice(self.company_a, "_TUNA", 200)
self.create_invoice(usd, "_TUNU", 300, currency="USD")
rows = execute(self.filters(companies=[self.company_a, usd]))[1]
self.assertEqual([r.company for r in rows], [self.company_a, usd])
self.assertFalse(any(row.get("bold") for row in rows))
# ---------- helpers ----------
def company_outstanding(self, company):
filters = {"company": company, "report_date": today(), "range": "30, 60, 90, 120"}
return sum(r.outstanding for r in execute_summary(filters)[1] if r.party == self.customer)