From 0dddd00073d44ce859e327a4e354d7168dafc473 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 1/9] feat(analytics): filter sales and purchase analytics by entity (#58402) Co-authored-by: Mihir Kandoi (cherry picked from commit 3f29cdf8d2683e2a558bca3d9bff966fd3d99fa8) # Conflicts: # erpnext/buying/report/purchase_analytics/purchase_analytics.js # erpnext/buying/report/purchase_analytics/test_purchase_analytics.py # erpnext/selling/report/sales_analytics/sales_analytics.py # erpnext/selling/report/sales_analytics/test_sales_analytics.py --- .../purchase_analytics/purchase_analytics.js | 41 +++ .../test_purchase_analytics.py | 131 ++++++++++ .../report/sales_analytics/sales_analytics.js | 31 +++ .../report/sales_analytics/sales_analytics.py | 24 ++ .../sales_analytics/test_sales_analytics.py | 236 ++++++++++++++++++ 5 files changed, 463 insertions(+) create mode 100644 erpnext/buying/report/purchase_analytics/test_purchase_analytics.py create mode 100644 erpnext/selling/report/sales_analytics/test_sales_analytics.py diff --git a/erpnext/buying/report/purchase_analytics/purchase_analytics.js b/erpnext/buying/report/purchase_analytics/purchase_analytics.js index 23a188057d9..85269ce8379 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,27 @@ frappe.query_reports["Purchase Analytics"] = { default: "Monthly", reqd: 1, }, +<<<<<<< HEAD +======= + { + 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"), + fieldtype: "Check", + }, +>>>>>>> 3f29cdf (feat(analytics): filter sales and purchase analytics by entity (#58402)) ], get_datatable_options(options) { return Object.assign(options, { 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 96d91374c61..c945d4b02ab 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", @@ -26,6 +53,10 @@ frappe.query_reports["Sales Analytics"] = { options: ["Sales Order", "Delivery Note", "Sales Invoice"], 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 5d4478f8dd6..32ea5f8a7f8 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -18,6 +18,12 @@ def execute(filters=None): class Analytics: def __init__(self, filters=None): self.filters = frappe._dict(filters or {}) +<<<<<<< HEAD +======= + 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")) +>>>>>>> 3f29cdf (feat(analytics): filter sales and purchase analytics by entity (#58402)) self.date_field = ( "transaction_date" if self.filters.doc_type in ["Sales Order", "Purchase Order"] @@ -61,6 +67,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 @@ -325,6 +332,23 @@ class Analytics: filters=filters, ) + 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) From 605918d3a28babd44705279b9eb06050f976ac9e Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 26 Aug 2026 16:10:33 +0530 Subject: [PATCH 2/9] fix: resolve analytics backport conflicts --- .../purchase_analytics/purchase_analytics.js | 8 --- .../test_purchase_analytics.py | 4 +- .../report/sales_analytics/sales_analytics.py | 5 -- .../sales_analytics/test_sales_analytics.py | 61 ++++++++++++------- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/erpnext/buying/report/purchase_analytics/purchase_analytics.js b/erpnext/buying/report/purchase_analytics/purchase_analytics.js index 85269ce8379..b5ac62ed532 100644 --- a/erpnext/buying/report/purchase_analytics/purchase_analytics.js +++ b/erpnext/buying/report/purchase_analytics/purchase_analytics.js @@ -85,8 +85,6 @@ frappe.query_reports["Purchase Analytics"] = { default: "Monthly", reqd: 1, }, -<<<<<<< HEAD -======= { fieldname: "curves", label: __("Curves"), @@ -100,12 +98,6 @@ frappe.query_reports["Purchase Analytics"] = { default: "select", reqd: 1, }, - { - fieldname: "show_aggregate_value_from_subsidiary_companies", - label: __("Show Aggregate Value from Subsidiary Companies"), - fieldtype: "Check", - }, ->>>>>>> 3f29cdf (feat(analytics): filter sales and purchase analytics by entity (#58402)) ], get_datatable_options(options) { return Object.assign(options, { diff --git a/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py b/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py index 57d955e52e6..ffa887aad09 100644 --- a/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py +++ b/erpnext/buying/report/purchase_analytics/test_purchase_analytics.py @@ -2,11 +2,11 @@ # See license.txt import frappe +from frappe.tests.utils import FrappeTestCase 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" @@ -16,7 +16,7 @@ FROM_DATE = "2019-04-01" TO_DATE = "2019-06-30" -class TestPurchaseAnalytics(ERPNextTestSuite): +class TestPurchaseAnalytics(FrappeTestCase): """purchase_analytics reuses the shared Analytics engine; these tests lock its wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees.""" diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index 32ea5f8a7f8..edab7420df7 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -18,12 +18,7 @@ def execute(filters=None): class Analytics: def __init__(self, filters=None): self.filters = frappe._dict(filters or {}) -<<<<<<< HEAD -======= 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")) ->>>>>>> 3f29cdf (feat(analytics): filter sales and purchase analytics by entity (#58402)) self.date_field = ( "transaction_date" if self.filters.doc_type in ["Sales Order", "Purchase Order"] diff --git a/erpnext/selling/report/sales_analytics/test_sales_analytics.py b/erpnext/selling/report/sales_analytics/test_sales_analytics.py index 489c5eb42a9..81880238494 100644 --- a/erpnext/selling/report/sales_analytics/test_sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/test_sales_analytics.py @@ -3,12 +3,12 @@ import frappe +from frappe.tests.utils import FrappeTestCase 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" @@ -24,28 +24,48 @@ FROM_DATE = "2019-04-01" TO_DATE = "2019-06-30" -class TestSalesAnalytics(ERPNextTestSuite): +class TestSalesAnalytics(FrappeTestCase): def setUp(self): frappe.set_user("Administrator") + self.created_docs = [] # 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", - ), + self.make_so(qty=5, rate=100, transaction_date="2019-04-10"), + self.make_so(qty=3, rate=100, transaction_date="2019-05-15"), ] + def tearDown(self): + for doctype, name in reversed(self.created_docs): + if not frappe.db.exists(doctype, name): + continue + + doc = frappe.get_doc(doctype, name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc(doctype, name, force=True) + + super().tearDown() + + def make_so(self, qty, rate, transaction_date, order_type=None): + so = make_sales_order( + company=COMPANY, + customer=CUSTOMER, + qty=qty, + rate=rate, + transaction_date=transaction_date, + do_not_save=True, + ) + # v15's test helper does not populate these hidden analytics dimensions. + so.customer_group = CUSTOMER_GROUP + so.territory = TERRITORY + if order_type: + so.order_type = order_type + so.insert() + so.submit() + self.created_docs.append((so.doctype, so.name)) + return so + def _base_filters(self, **overrides): filters = { "doc_type": "Sales Order", @@ -160,16 +180,12 @@ class TestSalesAnalytics(ERPNextTestSuite): 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, + self.make_so( qty=1, rate=100, transaction_date="2019-04-12", - do_not_submit=True, + order_type=order_type, ) - so.order_type = order_type - so.submit() columns, data, *_ = execute(self._base_filters(tree_type="Order Type")) @@ -213,6 +229,7 @@ class TestSalesAnalytics(ERPNextTestSuite): rate=250, transaction_date="2019-04-10", ) + self.created_docs.append((po.doctype, po.name)) po_value = flt(po.base_net_total) self.assertGreater(po_value, 0) From 17208e7434aff8488a303a4348beb73cec7d613e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 27 Aug 2026 13:08:25 +0530 Subject: [PATCH 3/9] fix: clarify duplicate internal party messages (#58469) (cherry picked from commit 4d4cf034b5b294d703fa13a46a1b236ab3fb49b7) # Conflicts: # erpnext/selling/doctype/customer/customer.py --- erpnext/buying/doctype/supplier/supplier.py | 12 +++++++++--- erpnext/selling/doctype/customer/customer.py | 13 +++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py index 0b463a36530..7740561ac2b 100644 --- a/erpnext/buying/doctype/supplier/supplier.py +++ b/erpnext/buying/doctype/supplier/supplier.py @@ -10,6 +10,7 @@ from frappe.contacts.address_and_contact import ( load_address_and_contact, ) from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options +from frappe.utils import get_link_to_form from erpnext.accounts.party import ( get_dashboard_info, @@ -177,10 +178,15 @@ class Supplier(TransactionBase): ) if internal_supplier: + internal_supplier_link = get_link_to_form("Supplier", internal_supplier) frappe.throw( - _("Internal Supplier for company {0} already exists").format( - frappe.bold(self.represents_company) - ) + _( + "Internal Supplier {0} already exists for {1}. Disable it to make this Supplier internal." + ).format( + internal_supplier_link, + frappe.bold(self.represents_company), + ), + title=_("Internal Supplier Already Exists"), ) def create_primary_contact(self): diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 4df15cac65e..8245c75770a 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -14,7 +14,7 @@ from frappe.contacts.address_and_contact import ( from frappe.model.mapper import get_mapped_doc from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options from frappe.model.utils.rename_doc import update_linked_doctypes -from frappe.utils import cint, cstr, flt, get_formatted_email, today +from frappe.utils import cint, cstr, flt, get_formatted_email, get_link_to_form, today from frappe.utils.deprecations import deprecated from frappe.utils.user import get_users_with_role @@ -227,10 +227,15 @@ class Customer(TransactionBase): ) if internal_customer: + internal_customer_link = get_link_to_form("Customer", internal_customer) frappe.throw( - _("Internal Customer for company {0} already exists").format( - frappe.bold(self.represents_company) - ) + _( + "Internal Customer {0} already exists for {1}. Disable it to make this Customer internal." + ).format( + internal_customer_link, + frappe.bold(self.represents_company), + ), + title=_("Internal Customer Already Exists"), ) def on_update(self): From b00062df8658951d23fdaee9c692e9a93d953a70 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:23:10 +0530 Subject: [PATCH 4/9] fix(stock): reset bin when a repost finds no stock ledger entries (#58434) * fix(stock): reset bin when a repost finds no stock ledger entries `build()` wipes `prev_sle_dict` in `initialize_reposting()` before `update_bin()` runs, so a repost over an item and warehouse whose ledger is empty writes no bin at all. `actual_qty`, `stock_value` and `valuation_rate` keep their last values, bin totals drift from the stock balance, and reposting again cannot heal it. Reset those bins as a terminal step in `update_bin()`, guarded by a re-check that no live SLE exists so ordinary valuation is untouched. Cancel is already correct on this branch, since `initialize_previous_data()` seeds zeros when there is no previous SLE and the sle_id path never calls `initialize_reposting()`. That seeding stays; this is a repost-path fix only. develop drops it in #58362 because the `cancelled` guard makes it dead code there. * test(stock): cover bin reset when the stock ledger is empty --- erpnext/stock/doctype/bin/test_bin.py | 62 +++++++++++++++++++++++++++ erpnext/stock/stock_ledger.py | 25 +++++++++++ 2 files changed, 87 insertions(+) diff --git a/erpnext/stock/doctype/bin/test_bin.py b/erpnext/stock/doctype/bin/test_bin.py index e4f5565cd75..36f06572cf5 100644 --- a/erpnext/stock/doctype/bin/test_bin.py +++ b/erpnext/stock/doctype/bin/test_bin.py @@ -28,6 +28,68 @@ class TestBin(FrappeTestCase): frappe.db.rollback() + def test_repost_resets_bin_without_sle(self): + """A repost must zero the bin when the ledger is empty, e.g. after entries were deleted.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + from erpnext.stock.stock_ledger import update_entries_after + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + + # deleting a transaction with `delete_linked_ledger_entries` on drops its entries outright + frappe.db.delete("Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse}) + + update_entries_after( + { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": "1900-01-01", + "posting_time": "00:01", + } + ) + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + + def test_cancelling_last_entry_resets_bin(self): + """Cancelling the only voucher must clear stock value, not just quantity.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + + se.cancel() + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + + def test_deleting_last_voucher_resets_bin(self): + """Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared.""" + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item_code = make_item().name + warehouse = "_Test Warehouse - _TC" + delete_entries = frappe.get_single_value("Accounts Settings", "delete_linked_ledger_entries") + frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1) + + try: + se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100) + se.cancel() + frappe.delete_doc("Stock Entry", se.name, force=1) + finally: + frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", delete_entries) + + bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse}) + self.assertEqual(bin.actual_qty, 0) + self.assertEqual(bin.valuation_rate, 0) + self.assertEqual(bin.stock_value, 0) + def test_index_exists(self): indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1) if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7a9bc900307..e57da3cd71b 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1750,6 +1750,31 @@ class update_entries_after: frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True) + self.reset_bin_without_stock_ledger_entries() + + def reset_bin_without_stock_ledger_entries(self): + """Reset the bin when its ledger has no entries left, a repost never covers that case.""" + item_code, warehouse = self.args.get("item_code"), self.args.get("warehouse") + if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict: + return + + if frappe.db.count( + "Stock Ledger Entry", + {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, + ): + return + + bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) + if not bin_name: + return + + frappe.db.set_value( + "Bin", + bin_name, + {"actual_qty": 0.0, "stock_value": 0.0, "valuation_rate": 0.0}, + update_modified=True, + ) + def get_sle_against_current_voucher(kwargs): kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) From 099a5a71d92a8b4b877094ceb7986074a976983c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 27 Aug 2026 18:09:48 +0530 Subject: [PATCH 5/9] fix(manufacturing): preserve job card qty in mr transfer (backport #58482) (#58498) --- .../doctype/job_card/test_job_card.py | 38 +++++++++ .../material_request/material_request.py | 2 + .../stock/doctype/stock_entry/stock_entry.py | 77 ++++++++++++++++--- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index ceb5ce299eb..9d50a57b8be 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -206,6 +206,43 @@ class TestJobCard(FrappeTestCase): # transfer was made for 2 fg qty in first transfer Stock Entry self.assertEqual(transfer_entry_2.fg_completed_qty, 0) + def test_material_request_stock_entry_uses_job_card_coverage(self): + from erpnext.stock.doctype.material_request.material_request import make_stock_entry + + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + mr = make_material_request(job_card.name) + mr.schedule_date = today() + for row in mr.items: + row.qty = flt(row.qty) / 2 + row.stock_qty = flt(row.stock_qty) / 2 + mr.submit() + + stock_entry = make_stock_entry(mr.name) + self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2) + + selected_row = mr.items[0] + try: + frappe.flags.selected_children = {"items": [selected_row.name]} + selected_stock_entry = make_stock_entry(mr.name) + finally: + frappe.flags.selected_children = None + + self.assertEqual( + [row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item] + ) + self.assertEqual(selected_stock_entry.fg_completed_qty, 0) + + for row in mr.items: + transferred_qty = flt(row.stock_qty) / 2 + frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty) + frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty) + mr.reload() + + repeated_stock_entry = make_stock_entry(mr.name) + self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4) + @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1}) def test_job_card_excess_material_transfer(self): "Test transferring more than required RM against Job Card." @@ -616,6 +653,7 @@ class TestJobCard(FrappeTestCase): self.assertEqual(ste.job_card, job_card_name) self.assertEqual(ste.from_bom, 1.0) self.assertEqual(ste.bom_no, work_order.bom_no) + self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity")) def test_job_card_proccess_qty_and_completed_qty(self): from erpnext.manufacturing.doctype.routing.test_routing import ( diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index e4738473c87..52c1ec2cffd 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -791,6 +791,8 @@ def make_stock_entry(source_name, target_doc=None): target.fg_completed_qty = job_card_details[0].for_quantity target.from_bom = 1 + target.cap_completed_qty_to_material_coverage() + doclist = get_mapped_doc( "Material Request", source_name, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 62b27bf1059..c2f991321c2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1205,17 +1205,26 @@ class StockEntry(StockController): if transfer_limit_qty < to_transfer_qty: return - required_qty, transferred_qty = self._get_work_order_material_qty() + self.cap_completed_qty_to_material_coverage() + + def cap_completed_qty_to_material_coverage(self): + required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data() if not required_qty: return - covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty) + covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) for row in self.items: - item_code = row.original_item or row.item_code - if row.s_warehouse and item_code in required_qty: - transferred_qty[item_code] += flt(row.qty) * flt(row.conversion_factor or 1) + if self.job_card: + material_reference = row.job_card_item + transferred = flt(row.qty) + else: + material_reference = row.original_item or row.item_code + transferred = flt(row.qty) * flt(row.conversion_factor or 1) - covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty) + if material_reference in required_qty and (self.job_card or row.s_warehouse): + transferred_qty[material_reference] += transferred + + covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision) covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty")) self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry) @@ -1230,6 +1239,49 @@ class StockEntry(StockController): return False return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card") + def _get_material_coverage_data(self): + if self.job_card: + return self._get_job_card_material_qty() + return self._get_work_order_material_qty() + + def _get_job_card_material_qty(self): + job_card = frappe.get_doc("Job Card", self.job_card) + required_qty = {} + transferred_qty = {} + for row in job_card.items: + if flt(row.required_qty) <= 0: + continue + required_qty[row.name] = flt(row.required_qty) + transferred_qty[row.name] = flt(row.transferred_qty) + + return ( + required_qty, + transferred_qty, + self._get_job_card_target_qty(job_card), + job_card.precision("required_qty", "items"), + ) + + def _get_job_card_target_qty(self, job_card): + required_by_item = {} + for row in job_card.items: + required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty) + + work_order_required_by_item = {} + work_order = frappe.get_doc("Work Order", job_card.work_order) + for row in work_order.required_items: + if job_card.operation != row.operation: + continue + work_order_required_by_item[row.item_code] = work_order_required_by_item.get( + row.item_code, 0.0 + ) + flt(row.required_qty) + + target_qty = [ + item_required * flt(work_order.qty) / work_order_required_by_item[item_code] + for item_code, item_required in required_by_item.items() + if work_order_required_by_item.get(item_code) + ] + return min(target_qty) if target_qty else job_card.for_quantity + def _get_work_order_material_qty(self): required_qty = {} transferred_qty = {} @@ -1241,15 +1293,20 @@ class StockEntry(StockController): transferred_qty[row.item_code] = max( transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty) ) - return required_qty, transferred_qty + return ( + required_qty, + transferred_qty, + self.pro_doc.qty, + self.pro_doc.precision("required_qty", "required_items"), + ) - def _get_covered_work_order_qty(self, required_qty, transferred_qty): + def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision): min_fraction = get_minimum_material_coverage_fraction( required_qty, transferred_qty, - self.pro_doc.precision("required_qty", "required_items"), + precision, ) - return min_fraction * flt(self.pro_doc.qty) + return min_fraction * flt(target_qty) def _validate_no_excess_transfer(self): if self.is_return: From 5a518951e45a18d13f450be6e7fadc60d78929b9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:24:40 +0000 Subject: [PATCH 6/9] fix(projects): ignore cancelled invoices in timesheet portal (backport #58501) (#58503) Co-authored-by: Mihir Kandoi --- erpnext/projects/doctype/timesheet/timesheet.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index bf3116cb409..75520300abd 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -546,8 +546,14 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 customer = contact.get_link_for("Customer") if customer: - sales_invoices = frappe.get_all("Sales Invoice", filters={"customer": customer}, pluck="name") + sales_invoices = frappe.get_all( + "Sales Invoice", + filters={"customer": customer, "docstatus": ["!=", 2]}, + pluck="name", + ) projects = frappe.get_all("Project", filters={"customer": customer}, pluck="name") + if not (sales_invoices or projects): + return [] # Return timesheet related data to web portal. table = frappe.qb.DocType("Timesheet") @@ -577,10 +583,7 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20 if projects: conditions.append(child_table.project.isin(projects)) - if conditions: - query = query.where(frappe.qb.terms.Criterion.any(conditions)) - - return query.run(as_dict=True) + return query.where(frappe.qb.terms.Criterion.any(conditions)).run(as_dict=True) else: return {} From 0e7be81f86f980bcf3956cae1b0b723c75668503 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:34:55 +0530 Subject: [PATCH 7/9] Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)" (cherry picked from commit 8287906a712e7caedffd32e2fb481537ff346047) # Conflicts: # erpnext/stock/stock_ledger.py --- erpnext/stock/stock_ledger.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index e57da3cd71b..75ce790cf67 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1758,9 +1758,14 @@ class update_entries_after: if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict: return +<<<<<<< HEAD if frappe.db.count( "Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, +======= + if frappe.db.exists( + "Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0} +>>>>>>> 8287906 (Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)") ): return From c6717fb7583180231802d5adb2e319f58ae356a5 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 27 Aug 2026 22:51:14 +0530 Subject: [PATCH 8/9] fix: resolve conflicts --- erpnext/stock/stock_ledger.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 75ce790cf67..67cc1e30fd8 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1758,14 +1758,8 @@ class update_entries_after: if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict: return -<<<<<<< HEAD - if frappe.db.count( - "Stock Ledger Entry", - {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, -======= if frappe.db.exists( "Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0} ->>>>>>> 8287906 (Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)") ): return From d87a4b2541cf52c28890dcd7713017d4e725cda9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:26:19 +0200 Subject: [PATCH 9/9] fix: translate doctype in map msg (backport #58515) (#58516) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/public/js/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 2f66c0720d8..4ec8bddb6b1 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -982,7 +982,7 @@ erpnext.utils.map_current_doc = function (opts) { if (already_set) { frappe.msgprint( - __("You have already selected items from {0} {1}", [opts.source_doctype, src]) + __("You have already selected items from {0} {1}", [__(opts.source_doctype), src]) ); return; }