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 new file mode 100644 index 00000000000..57d955e52e6 --- /dev/null +++ b/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py @@ -0,0 +1,131 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.utils import flt + +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.buying.report.purchase_analytics.purchase_analytics import execute +from erpnext.tests.utils import ERPNextTestSuite + +COMPANY = "_Test Company" +SUPPLIER = "_Test Supplier" +SUPPLIER_GROUP = "_Test Supplier Group" +# A historical window that ordinary test fixtures don't post into. +FROM_DATE = "2019-04-01" +TO_DATE = "2019-06-30" + + +class TestPurchaseAnalytics(ERPNextTestSuite): + """purchase_analytics reuses the shared Analytics engine; these tests lock its + wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees.""" + + def setUp(self): + frappe.set_user("Administrator") + + def _filters(self, **overrides): + filters = { + "doc_type": "Purchase Order", + "value_quantity": "Value", + "range": "Monthly", + "company": COMPANY, + "from_date": FROM_DATE, + "to_date": TO_DATE, + } + filters.update(overrides) + return frappe._dict(filters) + + def _rows(self, filters): + return {row["entity"]: row for row in execute(filters)[1]} + + def make_po(self, qty=4, rate=250): + return create_purchase_order( + 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) + base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0)) + + po = self.make_po(qty=4, rate=250) + rows = self._rows(filters) + + # supplier is remapped to its group; the root sits at indent 0 + self.assertIn(SUPPLIER_GROUP, rows) + self.assertIn("All Supplier Groups", rows) + self.assertNotIn(SUPPLIER, rows) + self.assertEqual(rows["All Supplier Groups"]["indent"], 0) + + self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2) + self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total)) + + def test_item_group_tree_rolls_up_to_root(self): + item_group = frappe.db.get_value("Item", "_Test Item", "item_group") + filters = self._filters(tree_type="Item Group") + base = self._rows(filters) + base_group = flt(base.get(item_group, {}).get("total", 0.0)) + + po = self.make_po(qty=4, rate=250) + rows = self._rows(filters) + + self.assertIn(item_group, rows) + self.assertIn("All Item Groups", rows) + # the raw item code must not leak as its own entity; the root sits at indent 0 + self.assertNotIn("_Test Item", rows) + self.assertEqual(rows["All Item Groups"]["indent"], 0) + self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2) + self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total)) + + def test_supplier_group_by_quantity(self): + filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity") + base = self._rows(filters) + base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0)) + base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0)) + + po = self.make_po(qty=7, rate=100) + rows = self._rows(filters) + + self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2) + # the quantity must roll up to the root too, not just the leaf group + self.assertAlmostEqual( + rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2 + ) 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 e36690b4384..ecd3d38b022 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 new file mode 100644 index 00000000000..489c5eb42a9 --- /dev/null +++ b/erpnext/selling/report/sales_analytics/test_sales_analytics.py @@ -0,0 +1,236 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + + +import frappe +from frappe.utils import flt + +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.selling.report.sales_analytics.sales_analytics import execute +from erpnext.tests.utils import ERPNextTestSuite + +# Bootstrap masters reused as-is (see erpnext/tests/utils.py): +# "_Test Customer" -> customer_group "_Test Customer Group", territory "_Test Territory" +# "_Test Supplier" -> supplier_group "_Test Supplier Group" (child of "All Supplier Groups") +# Sales Order.order_type defaults to "Sales" (reqd Select field) +COMPANY = "_Test Company" +CUSTOMER = "_Test Customer" +CUSTOMER_GROUP = "_Test Customer Group" +TERRITORY = "_Test Territory" +SUPPLIER = "_Test Supplier" +SUPPLIER_GROUP = "_Test Supplier Group" +FROM_DATE = "2019-04-01" +TO_DATE = "2019-06-30" + + +class TestSalesAnalytics(ERPNextTestSuite): + def setUp(self): + frappe.set_user("Administrator") + # Two submitted Sales Orders for the bootstrap customer inside the report window. + # These roll up into the tree roots the converted tree/order-type queries build. + self.orders = [ + make_sales_order( + company=COMPANY, + customer=CUSTOMER, + qty=5, + rate=100, + transaction_date="2019-04-10", + ), + make_sales_order( + company=COMPANY, + customer=CUSTOMER, + qty=3, + rate=100, + transaction_date="2019-05-15", + ), + ] + + def _base_filters(self, **overrides): + filters = { + "doc_type": "Sales Order", + "value_quantity": "Value", + "range": "Monthly", + "company": COMPANY, + "from_date": FROM_DATE, + "to_date": TO_DATE, + } + filters.update(overrides) + return filters + + def _expected_value_total(self): + return sum(flt(so.base_net_total) for so in self.orders) + + def _expected_qty_total(self): + return sum(flt(so.total_qty) for so in self.orders) + + 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.""" + columns, data, *_ = execute(self._base_filters(tree_type="Customer Group")) + + self.assertTrue(columns) + self.assertTrue(data) + + rows = self._row_by_entity(data) + # The whole tree is returned, so both the root and the customer's own group appear. + self.assertIn("All Customer Groups", rows) + self.assertIn(CUSTOMER_GROUP, rows) + + expected = self._expected_value_total() + self.assertGreater(expected, 0) + # Leaf group holds the orders; root receives the same total via roll-up. + self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected, places=2) + self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected, places=2) + # Roots of a tree report sit at indent 0. + self.assertEqual(rows["All Customer Groups"]["indent"], 0) + + def test_territory_tree_rolls_up_to_root(self): + """tree_type='Territory' exercises the same tree path against the Territory tree.""" + columns, data, *_ = execute(self._base_filters(tree_type="Territory")) + + self.assertTrue(columns) + rows = self._row_by_entity(data) + self.assertIn("All Territories", rows) + self.assertIn(TERRITORY, rows) + + expected = self._expected_value_total() + self.assertAlmostEqual(rows[TERRITORY]["total"], expected, places=2) + self.assertAlmostEqual(rows["All Territories"]["total"], expected, places=2) + + def test_order_type_synthetic_tree(self): + """tree_type='Order Type' drives get_teams: distinct order_type rebuilt in Python + under a synthetic 'Order Types' root, then rolled up via get_rows_by_group.""" + columns, data, *_ = execute(self._base_filters(tree_type="Order Type")) + + self.assertTrue(columns) + rows = self._row_by_entity(data) + # Synthetic root plus the default order_type the bootstrap Sales Orders carry. + self.assertIn("Order Types", rows) + self.assertIn("Sales", rows) + self.assertEqual(rows["Order Types"]["indent"], 0) + + expected = self._expected_value_total() + self.assertAlmostEqual(rows["Sales"]["total"], expected, places=2) + self.assertAlmostEqual(rows["Order Types"]["total"], expected, places=2) + + def test_order_type_leaf_rows_in_sorted_order(self): + """get_teams fetches distinct order_types; frappe drops the SQL ORDER BY for distinct queries on + postgres, so the report sorts the order-type rows in python (key=str.casefold) to keep them in a + deterministic, case-insensitive order identical on both engines.""" + for order_type in ("Shopping Cart", "Maintenance", "Sales"): # created out of sorted order + so = make_sales_order( + company=COMPANY, + customer=CUSTOMER, + qty=1, + rate=100, + transaction_date="2019-04-12", + do_not_submit=True, + ) + so.order_type = order_type + so.submit() + + columns, data, *_ = execute(self._base_filters(tree_type="Order Type")) + + mine = {"Sales", "Maintenance", "Shopping Cart"} + leaves = [row["entity"] for row in data if row.get("entity") in mine] + # the order-type rows must appear in casefold-sorted order on both engines + self.assertEqual(leaves, sorted(leaves, key=str.casefold)) + self.assertEqual(set(leaves), mine) + + def test_customer_group_by_quantity(self): + """value_quantity='Quantity' switches the selected value column (total_qty).""" + _columns, data, *_ = execute( + self._base_filters(tree_type="Customer Group", value_quantity="Quantity") + ) + + rows = self._row_by_entity(data) + self.assertIn(CUSTOMER_GROUP, rows) + + expected_qty = self._expected_qty_total() + self.assertGreater(expected_qty, 0) + self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected_qty, places=2) + self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected_qty, places=2) + + def test_supplier_group_tree_maps_supplier_to_group(self): + """tree_type='Supplier Group' (doc_type='Purchase Order') exercises + get_supplier_parent_child_map: the query selects 'supplier' as entity, then + get_periodic_data remaps each supplier to its group via the parent->child map + built by frappe.get_all('Supplier', ['name', 'supplier_group'], as_list=True). + The group total then rolls up into the 'All Supplier Groups' root.""" + # Baseline the report before adding our Purchase Order so the assertion is + # robust to any pre-existing rows in the historical window. + base_filters = self._base_filters(tree_type="Supplier Group", doc_type="Purchase Order") + _columns, base_data, *_ = execute(base_filters) + base_rows = self._row_by_entity(base_data) + base_group_total = flt(base_rows.get(SUPPLIER_GROUP, {}).get("total", 0.0)) + + po = create_purchase_order( + company=COMPANY, + supplier=SUPPLIER, + qty=4, + rate=250, + transaction_date="2019-04-10", + ) + po_value = flt(po.base_net_total) + self.assertGreater(po_value, 0) + + columns, data, *_ = execute(base_filters) + + self.assertTrue(columns) + self.assertTrue(data) + + rows = self._row_by_entity(data) + # The supplier was remapped to its group; both the leaf group and the tree + # root appear as entities (no raw supplier name leaks into the output). + self.assertIn(SUPPLIER_GROUP, rows) + self.assertIn("All Supplier Groups", rows) + self.assertNotIn(SUPPLIER, rows) + # Roots of a tree report sit at indent 0. + self.assertEqual(rows["All Supplier Groups"]["indent"], 0) + + # The new PO lands in the supplier's group via the parent->child map. + self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group_total, po_value, places=2) + # Roll-up: the root aggregates every group, so it covers at least this PO. + self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), po_value)