From 3f29cdf8d2683e2a558bca3d9bff966fd3d99fa8 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:02:59 +0530 Subject: [PATCH] feat(analytics): filter sales and purchase analytics by entity (#58402) Co-authored-by: Mihir Kandoi --- .../purchase_analytics/purchase_analytics.js | 33 ++++++++++++++++ .../test_purchase_analytics.py | 38 +++++++++++++++++++ .../report/sales_analytics/sales_analytics.js | 31 +++++++++++++++ .../report/sales_analytics/sales_analytics.py | 19 ++++++++++ .../sales_analytics/test_sales_analytics.py | 38 +++++++++++++++++++ 5 files changed, 159 insertions(+) diff --git a/erpnext/buying/report/purchase_analytics/purchase_analytics.js b/erpnext/buying/report/purchase_analytics/purchase_analytics.js index b66c1c429d0..5ee294769cd 100644 --- a/erpnext/buying/report/purchase_analytics/purchase_analytics.js +++ b/erpnext/buying/report/purchase_analytics/purchase_analytics.js @@ -10,6 +10,26 @@ frappe.query_reports["Purchase Analytics"] = { options: ["Supplier Group", "Supplier", "Item Group", "Item"], default: "Supplier", reqd: 1, + on_change: function () { + const entity_filter = frappe.query_report.get_filter("entity"); + if (entity_filter) { + entity_filter.df.label = __(frappe.query_report.get_filter_value("tree_type")); + entity_filter.set_value([]); + entity_filter.refresh(); + } + frappe.query_report.refresh(); + }, + }, + { + fieldname: "entity", + label: __("Entity"), + fieldtype: "MultiSelectList", + get_data: function (txt) { + const tree_type = frappe.query_report.get_filter_value("tree_type"); + if (!tree_type || tree_type === "Order Type") return []; + return frappe.db.get_link_options(tree_type, txt); + }, + depends_on: "eval:doc.tree_type != 'Order Type'", }, { fieldname: "doc_type", @@ -65,6 +85,19 @@ frappe.query_reports["Purchase Analytics"] = { default: "Monthly", reqd: 1, }, + { + fieldname: "curves", + label: __("Curves"), + fieldtype: "Select", + options: [ + { value: "select", label: __("Select") }, + { value: "all", label: __("All") }, + { value: "non-zeros", label: __("Non-Zeros") }, + { value: "total", label: __("Total Only") }, + ], + default: "select", + reqd: 1, + }, { fieldname: "show_aggregate_value_from_subsidiary_companies", label: __("Show Aggregate Value from Subsidiary Companies"), diff --git a/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py b/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py index 35cd9ebac58..57d955e52e6 100644 --- a/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py +++ b/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py @@ -43,6 +43,44 @@ class TestPurchaseAnalytics(ERPNextTestSuite): company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10" ) + def test_supplier_entity_filter(self): + filters = self._filters(tree_type="Supplier", entity=[SUPPLIER], curves="all") + base_total = flt(self._rows(filters).get(SUPPLIER, {}).get("total", 0.0)) + + po = self.make_po() + columns, data, _message, chart, *_rest = execute(filters) + + self.assertTrue(columns) + self.assertEqual({row["entity"] for row in data}, {SUPPLIER}) + self.assertAlmostEqual(data[0]["total"] - base_total, flt(po.base_net_total), places=2) + + supplier_name = frappe.db.get_value("Supplier", SUPPLIER, "supplier_name") + self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {supplier_name}) + + def test_parent_supplier_group_filter_preserves_rollup(self): + self.make_po() + filters = self._filters(tree_type="Supplier Group") + unfiltered = self._rows(filters) + filtered = self._rows(self._filters(tree_type="Supplier Group", entity=["All Supplier Groups"])) + + self.assertEqual(set(filtered), {"All Supplier Groups"}) + self.assertAlmostEqual( + filtered["All Supplier Groups"]["total"], + unfiltered["All Supplier Groups"]["total"], + places=2, + ) + + def test_supplier_group_entity_filter(self): + self.make_po() + unfiltered = self._rows(self._filters(tree_type="Supplier Group")) + filtered = self._rows(self._filters(tree_type="Supplier Group", entity=[SUPPLIER_GROUP])) + + self.assertEqual(set(filtered), {SUPPLIER_GROUP}) + self.assertEqual(filtered[SUPPLIER_GROUP]["indent"], 0) + self.assertAlmostEqual( + filtered[SUPPLIER_GROUP]["total"], unfiltered[SUPPLIER_GROUP]["total"], places=2 + ) + def test_supplier_group_tree_rolls_up_to_root(self): filters = self._filters(tree_type="Supplier Group") base = self._rows(filters) diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.js b/erpnext/selling/report/sales_analytics/sales_analytics.js index 7c89be801db..cfe4a057317 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.js +++ b/erpnext/selling/report/sales_analytics/sales_analytics.js @@ -2,6 +2,18 @@ // For license information, please see license.txt frappe.query_reports["Sales Analytics"] = { + // "All" reports on every doctype at once and forces the tree to Customer + entity_tree_type() { + const doc_type = frappe.query_report.get_filter_value("doc_type"); + return doc_type === "All" ? "Customer" : frappe.query_report.get_filter_value("tree_type"); + }, + reset_entity_filter() { + const entity_filter = frappe.query_report.get_filter("entity"); + if (!entity_filter) return; + entity_filter.df.label = __(this.entity_tree_type()); + entity_filter.set_value([]); + entity_filter.refresh(); + }, filters: [ { fieldname: "tree_type", @@ -18,6 +30,21 @@ frappe.query_reports["Sales Analytics"] = { ], default: "Customer", reqd: 1, + on_change: function () { + frappe.query_reports["Sales Analytics"].reset_entity_filter(); + frappe.query_report.refresh(); + }, + }, + { + fieldname: "entity", + label: __("Entity"), + fieldtype: "MultiSelectList", + get_data: function (txt) { + const tree_type = frappe.query_reports["Sales Analytics"].entity_tree_type(); + if (!tree_type || tree_type === "Order Type") return []; + return frappe.db.get_link_options(tree_type, txt); + }, + depends_on: "eval:doc.tree_type != 'Order Type'", }, { fieldname: "doc_type", @@ -34,6 +61,10 @@ frappe.query_reports["Sales Analytics"] = { ], default: "Sales Invoice", reqd: 1, + on_change: function () { + frappe.query_reports["Sales Analytics"].reset_entity_filter(); + frappe.query_report.refresh(); + }, }, { fieldname: "value_quantity", diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index 9eb879681ee..6195b431b43 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -53,6 +53,7 @@ def append_report(dt, org, new): class Analytics: def __init__(self, filters=None): self.filters = frappe._dict(filters or {}) + self.entities = self.filters.get("entity") or [] if self.filters.doc_type == "Payment Entry" and self.filters.value_quantity == "Quantity": frappe.throw(_("Only Value available for Payment Entry")) self.date_field = ( @@ -102,6 +103,7 @@ class Analytics: self.update_company_list_for_parent_company() self.get_columns() self.get_data() + self.filter_data_by_entities() self.get_chart_data() # Skipping total row for tree-view reports @@ -395,6 +397,23 @@ class Analytics: ignore_permissions=False, ).run(as_dict=True) + def filter_data_by_entities(self): + if not self.entities: + return + + entities = set(self.entities) + selected_data = [] + for row in self.data: + if row["entity"] not in entities: + continue + + row = row.copy() + if "indent" in row: + row["indent"] = 0 + selected_data.append(row) + + self.data = selected_data + def get_rows(self): self.data = [] self.get_periodic_data() diff --git a/erpnext/selling/report/sales_analytics/test_sales_analytics.py b/erpnext/selling/report/sales_analytics/test_sales_analytics.py index 9d7ad3f8dad..489c5eb42a9 100644 --- a/erpnext/selling/report/sales_analytics/test_sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/test_sales_analytics.py @@ -67,6 +67,44 @@ class TestSalesAnalytics(ERPNextTestSuite): def _row_by_entity(self, data): return {row["entity"]: row for row in data} + def test_customer_entity_filter(self): + _columns, data, _message, chart, *_rest = execute( + self._base_filters(tree_type="Customer", entity=[CUSTOMER], curves="all") + ) + + self.assertEqual({row["entity"] for row in data}, {CUSTOMER}) + self.assertAlmostEqual(data[0]["total"], self._expected_value_total(), places=2) + self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {CUSTOMER}) + + def test_parent_customer_group_filter_preserves_rollup(self): + _columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group")) + _columns, filtered_data, *_rest = execute( + self._base_filters(tree_type="Customer Group", entity=["All Customer Groups"]) + ) + + unfiltered = self._row_by_entity(unfiltered_data) + filtered = self._row_by_entity(filtered_data) + self.assertEqual(set(filtered), {"All Customer Groups"}) + self.assertAlmostEqual( + filtered["All Customer Groups"]["total"], + unfiltered["All Customer Groups"]["total"], + places=2, + ) + + def test_customer_group_entity_filter(self): + _columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group")) + _columns, filtered_data, *_rest = execute( + self._base_filters(tree_type="Customer Group", entity=[CUSTOMER_GROUP]) + ) + + unfiltered = self._row_by_entity(unfiltered_data) + filtered = self._row_by_entity(filtered_data) + self.assertEqual(set(filtered), {CUSTOMER_GROUP}) + self.assertEqual(filtered[CUSTOMER_GROUP]["indent"], 0) + self.assertAlmostEqual( + filtered[CUSTOMER_GROUP]["total"], unfiltered[CUSTOMER_GROUP]["total"], places=2 + ) + def test_customer_group_tree_rolls_up_to_root(self): """tree_type='Customer Group' drives get_groups (tree get_all ordered by lft) and get_rows_by_group, rolling child values up to the 'All Customer Groups' root."""