From f5ce20f122c35796af6a114f0deadb69f419932c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:35:24 +0530 Subject: [PATCH 01/56] fix: keep Currency and Price List section open for foreign currency (backport #58074) (#58112) * fix: keep Currency and Price List section open for foreign currency (#58074) The section is marked collapsible with no condition, so it always rendered collapsed. When the transaction currency differs from the company currency the exchange rate is relevant and was hidden behind a click. Adds collapsible_depends_on so the section starts expanded whenever the transaction currency differs from the company currency, and stays collapsed otherwise. (cherry picked from commit 4a8342496e526f92cc92b5457427df5c459a7286) # Conflicts: # erpnext/accounts/doctype/pos_invoice/pos_invoice.json # erpnext/accounts/doctype/sales_invoice/sales_invoice.json # erpnext/buying/doctype/purchase_order/purchase_order.json # erpnext/buying/doctype/supplier_quotation/supplier_quotation.json # erpnext/selling/doctype/sales_order/sales_order.json # erpnext/stock/doctype/delivery_note/delivery_note.json # erpnext/stock/doctype/purchase_receipt/purchase_receipt.json * fix: resolve unresolved cherry-pick conflict markers in modified timestamps Mergify's automatic cherry-pick of #58074 left conflict markers committed on the modified field, breaking JSON parsing. --------- Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> Co-authored-by: Jatin3128 --- erpnext/accounts/doctype/pos_invoice/pos_invoice.json | 3 ++- .../accounts/doctype/purchase_invoice/purchase_invoice.json | 3 ++- erpnext/accounts/doctype/sales_invoice/sales_invoice.json | 3 ++- erpnext/buying/doctype/purchase_order/purchase_order.json | 3 ++- .../buying/doctype/supplier_quotation/supplier_quotation.json | 3 ++- erpnext/selling/doctype/quotation/quotation.json | 3 ++- erpnext/selling/doctype/sales_order/sales_order.json | 3 ++- erpnext/stock/doctype/delivery_note/delivery_note.json | 3 ++- erpnext/stock/doctype/purchase_receipt/purchase_receipt.json | 3 ++- 9 files changed, 18 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 42f62147129..a0adf4e1133 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -499,6 +499,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "depends_on": "customer", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", @@ -1571,7 +1572,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2026-02-22 04:18:50.691218", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 058b8c8613b..a24c81544a5 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -502,6 +502,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1663,7 +1664,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2026-08-05 15:40:16.519774", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index a5abf3dd32f..d4efe4d0c15 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -597,6 +597,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "depends_on": "customer", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", @@ -2198,7 +2199,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2026-04-06 22:30:28.513139", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 7d92a197a82..654e92f763e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -402,6 +402,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1307,7 +1308,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-07-28 12:20:11.284370", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 6682c7e3585..b7edb2d2980 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -257,6 +257,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -938,7 +939,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-03-03 17:39:38.459977", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index 58f18dd731a..411632005a5 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -346,6 +346,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1107,7 +1108,7 @@ "idx": 82, "is_submittable": 1, "links": [], - "modified": "2026-05-30 17:40:02.667637", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 876cc5bb760..3d68495c5ec 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -478,6 +478,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "hide_days": 1, @@ -1679,7 +1680,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-07-28 12:20:44.130918", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 06623192797..0c2d2242ab6 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -428,6 +428,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1404,7 +1405,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2026-02-03 12:27:19.055918", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index 83243056c68..9a7ac741b5d 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -365,6 +365,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)", "fieldname": "currency_and_price_list", "fieldtype": "Section Break", "label": "Currency and Price List", @@ -1301,7 +1302,7 @@ "idx": 261, "is_submittable": 1, "links": [], - "modified": "2025-11-27 16:46:30.210628", + "modified": "2026-08-12 12:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", From bc071d7793573006f8edb9730fd75fde6ee652cf Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Wed, 26 Aug 2026 11:26:27 +0530 Subject: [PATCH 02/56] fix(stock): validate serial inventory dimensions (#58394) * fix(stock): validate serial inventory dimensions * test(stock): cover serial inventory dimensions (cherry picked from commit c940bd1e66075f77c78c0eca2f58141f97235804) # Conflicts: # erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py # erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py --- .../test_inventory_dimension.py | 207 +++++++++++++++++- .../stock_ledger_entry/stock_ledger_entry.py | 92 +++++++- 2 files changed, 290 insertions(+), 9 deletions(-) diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 29e811ea4c4..52652cdd840 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -13,10 +13,13 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( DoNotChangeError, delete_dimension, ) -from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import InventoryDimensionNegativeStockError +from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import ( + InventoryDimensionNegativeStockError, + SerialNoInventoryDimensionError, +) from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -504,6 +507,193 @@ class TestInventoryDimension(FrappeTestCase): self.assertEqual(site_name, "Site 1") + def test_serial_no_cannot_be_issued_from_incorrect_inventory_dimension(self): + item = make_item( + "Test Serialized Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + receipt = make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + receipt.items[0].to_serial_rack = "Rack 1" + receipt.save() + receipt.submit() + + transfer = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + transfer.items[0].serial_rack = "Rack 1" + transfer.items[0].to_serial_rack = "Rack 2" + transfer.save() + transfer.submit() + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.items[0].serial_rack = "Rack 1" + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + self.assertFalse( + frappe.db.exists( + "Stock Ledger Entry", + {"voucher_no": issue.name, "is_cancelled": 0}, + ) + ) + + def test_serial_no_cannot_move_from_empty_inventory_dimension(self): + item = make_item( + "Test Serialized Empty Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Empty Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Empty Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Empty Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + ) + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.items[0].empty_serial_rack = "Rack 1" + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + + def test_serial_no_cannot_be_issued_without_inventory_dimension(self): + item = make_item( + "Test Serialized Required Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Required Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Required Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Required Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + receipt = make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + receipt.items[0].to_required_serial_rack = "Rack 1" + receipt.save() + receipt.submit() + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + self.assertFalse( + frappe.db.exists( + "Stock Ledger Entry", + {"voucher_no": issue.name, "is_cancelled": 0}, + ) + ) + + def test_serial_no_inventory_dimension_with_legacy_inward_sle(self): + item = make_item( + "Test Serialized Legacy Inventory Dimension Item", + {"has_serial_no": 1, "is_stock_item": 1}, + ) + serial_no = "Test Serialized Legacy Inventory Dimension Serial No" + warehouse = create_warehouse("Serialized Legacy Inventory Dimension Warehouse") + + create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Legacy Serial Rack", + reference_document="Rack", + validate_negative_stock=0, + ) + + receipt = make_stock_entry( + item_code=item.name, + to_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + receipt.items[0].to_legacy_serial_rack = "Rack 2" + receipt.save() + receipt.submit() + + frappe.db.set_value( + "Stock Ledger Entry", + {"voucher_no": receipt.name, "actual_qty": (">", 0), "is_cancelled": 0}, + {"serial_and_batch_bundle": None, "serial_no": f"Other Legacy Serial, {serial_no}"}, + ) + + issue = make_stock_entry( + item_code=item.name, + from_warehouse=warehouse, + qty=1, + serial_no=serial_no, + use_serial_batch_fields=1, + do_not_submit=True, + ) + issue.items[0].legacy_serial_rack = "Rack 1" + issue.save() + + self.assertRaises(SerialNoInventoryDimensionError, issue.submit) + def test_validate_negative_stock_with_multiple_dimension(self): frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 0) item_code = "Test Negative Multi Inventory Dimension Item" @@ -709,13 +899,16 @@ def create_inventory_dimension(**args): args = frappe._dict(args) if frappe.db.exists("Inventory Dimension", args.dimension_name): - return frappe.get_doc("Inventory Dimension", args.dimension_name) + doc = frappe.get_doc("Inventory Dimension", args.dimension_name) + else: + doc = frappe.new_doc("Inventory Dimension") + doc.update(args) - doc = frappe.new_doc("Inventory Dimension") - doc.update(args) + if not args.do_not_save: + doc.insert(ignore_permissions=True) - if not args.do_not_save: - doc.insert(ignore_permissions=True) + frappe.local.inventory_dimensions = {} + frappe.local.document_wise_inventory_dimensions = {} return doc diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 5344636dfaa..7e7a76fb9df 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -2,19 +2,21 @@ # License: GNU General Public License v3. See license.txt +import re from datetime import date import frappe from frappe import _, bold from frappe.core.doctype.role.role import get_users from frappe.model.document import Document -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import Concat_ws, Sum from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions -from erpnext.stock.serial_batch_bundle import SerialBatchBundle +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_parsed_serial_nos +from erpnext.stock.serial_batch_bundle import SerialBatchBundle, get_serial_nos from erpnext.stock.stock_ledger import get_previous_sle @@ -30,6 +32,10 @@ class InventoryDimensionNegativeStockError(frappe.ValidationError): pass +class SerialNoInventoryDimensionError(frappe.ValidationError): + pass + + exclude_from_linked_with = True @@ -98,6 +104,7 @@ class StockLedgerEntry(Document): self.block_transactions_against_group_warehouse() self.validate_with_last_transaction_posting_time() self.validate_inventory_dimension_negative_stock() + self.validate_serial_no_inventory_dimension() def set_posting_datetime(self): from erpnext.stock.utils import get_combine_datetime @@ -172,6 +179,87 @@ class StockLedgerEntry(Document): return inv_dimension_dict + def validate_serial_no_inventory_dimension(self): + if self.is_cancelled or self.actual_qty >= 0: + return + + dimensions = get_inventory_dimensions() + if not dimensions: + return + + serial_nos = get_serial_nos(self.serial_and_batch_bundle) + if not serial_nos and self.serial_no: + serial_nos = get_parsed_serial_nos(self.serial_no) + + if not serial_nos: + return + + for serial_no, values in self.get_last_inward_dimensions(serial_nos, dimensions).items(): + mismatches = [] + for dimension in dimensions: + fieldname = dimension.fieldname + expected_value = values.get(fieldname) + if expected_value != self.get(fieldname): + mismatches.append( + _('{0}: expected "{1}", got "{2}"').format( + dimension.dimension_name, + expected_value or _("Not Set"), + self.get(fieldname), + ) + ) + + if mismatches: + frappe.throw( + _("Serial No {0} is not available in the selected inventory dimensions: {1}").format( + frappe.bold(serial_no), frappe.bold(", ".join(mismatches)) + ), + title=_("Incorrect Inventory Dimension"), + exc=SerialNoInventoryDimensionError, + ) + + def get_last_inward_dimensions(self, serial_nos, dimensions): + sle = frappe.qb.DocType("Stock Ledger Entry") + serial_entry = frappe.qb.DocType("Serial and Batch Entry") + dimension_fields = [sle[dimension.fieldname].as_(dimension.fieldname) for dimension in dimensions] + escaped_serial_nos = [re.escape(serial_no) for serial_no in serial_nos] + legacy_serial_pattern = r"[\n,][[:space:]]*(" + "|".join(escaped_serial_nos) + r")[[:space:]]*[\n,]" + legacy_serial_condition = ( + sle.serial_and_batch_bundle.isnull() | (sle.serial_and_batch_bundle == "") + ) & Concat_ws("", "\n", sle.serial_no, "\n").regexp(legacy_serial_pattern) + + rows = ( + frappe.qb.from_(sle) + .left_join(serial_entry) + .on(serial_entry.parent == sle.serial_and_batch_bundle) + .select( + serial_entry.serial_no.as_("bundle_serial_no"), + sle.serial_no.as_("legacy_serial_nos"), + *dimension_fields, + ) + .where( + (serial_entry.serial_no.isin(serial_nos) | legacy_serial_condition) + & (sle.item_code == self.item_code) + & (sle.actual_qty > 0) + & (sle.is_cancelled == 0) + & (sle.posting_datetime <= self.posting_datetime) + ) + .orderby(sle.posting_datetime, order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) + ).run(as_dict=True) + + serial_nos = set(serial_nos) + last_inward_dimensions = {} + for row in rows: + row_serial_nos = ( + [row.bundle_serial_no] + if row.bundle_serial_no + else get_parsed_serial_nos(row.legacy_serial_nos) + ) + for serial_no in serial_nos.intersection(row_serial_nos): + last_inward_dimensions.setdefault(serial_no, row) + + return last_inward_dimensions + def on_submit(self): self.check_stock_frozen_date() From de260bd43c291376abeaa11e8cb1019e3de76f7a Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Mon, 24 Aug 2026 11:08:51 +0530 Subject: [PATCH 03/56] fix(company): throw if linked to demo_company field Throws an error msg if user is deleting demo company directly. (cherry picked from commit aaa3100efc8587d7604c6794d3535a828672f70f) --- erpnext/setup/doctype/company/company.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 05b3ce7aa7b..8d5565b3180 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -667,6 +667,13 @@ class Company(NestedSet): """ Trash accounts and cost centers for this company if no gl entry exists """ + if frappe.db.get_single_value("Global Defaults", "demo_company") == self.name: + frappe.throw( + _("{0} is the site's Demo Company and cannot be deleted directly. Use {1} instead.").format( + bold(self.name), bold(_("Delete Demo Data")) + ) + ) + NestedSet.validate_if_child_exists(self) frappe.utils.nestedset.update_nsm(self) From 22377fa86d370ab4182e6a62babc025d541f0f2b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 25 Aug 2026 17:04:52 +0530 Subject: [PATCH 04/56] fix(stock): keep pick list links when refetching stock entry items (cherry picked from commit 01e0844d401ad2d3fa1468ce6f3455f0ca5ef05d) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.js --- erpnext/stock/doctype/pick_list/pick_list.py | 1 + .../stock/doctype/pick_list/test_pick_list.py | 52 +++++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.js | 36 +++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 3 ++ 4 files changed, 92 insertions(+) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 7a2dd32291a..144e486d94e 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1508,6 +1508,7 @@ def add_product_bundles_to_delivery_note( @frappe.whitelist() def create_stock_entry(pick_list: str | dict): pick_list = frappe.get_doc(frappe.parse_json(pick_list)) + pick_list.check_permission("read") validate_item_locations(pick_list) stock_entry = frappe.new_doc("Stock Entry") diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 8ed07e61381..d777e0862e7 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1078,6 +1078,58 @@ class TestPickList(FrappeTestCase): self.assertEqual(pick_list.locations[0].transferred_qty, 4) self.assertEqual(pick_list.status, "Partially Transferred") + def test_get_items_keeps_pick_list_rows_on_stock_entry(self): + """Entering fg_completed_qty on a Stock Entry mapped from a Pick List triggers get_items(); + it must not refetch from the BOM, or the pick_list_item links transferred_qty rides on are + lost and the Pick List stays Open with every row offered again.""" + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import ( + create_pick_list as pick_list_for_wo, + ) + from erpnext.manufacturing.doctype.work_order.work_order import make_work_order + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + source_warehouse = create_warehouse("_Test Partial Transfer Source") + wip_warehouse = create_warehouse("_Test Partial Transfer WIP", company="_Test Company") + fg_warehouse = create_warehouse("_Test Partial Transfer FG", company="_Test Company") + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1}).name + bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item]) + make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=100) + + wo = make_work_order(item=fg_item, qty=10, bom_no=bom.name, company="_Test Company") + wo.required_items[0].source_warehouse = source_warehouse + wo.wip_warehouse = wip_warehouse + wo.fg_warehouse = fg_warehouse + wo.submit() + + pick_list = pick_list_for_wo(wo.name, for_qty=wo.qty) + pick_list.save().submit() + self.assertEqual(pick_list.status, "Open") + + se = frappe.get_doc(create_stock_entry(pick_list.as_dict())) + self.assertTrue(all(row.pick_list_item for row in se.items)) + self.assertEqual(se.fg_completed_qty, 0) + + se.fg_completed_qty = 4 + se.get_items() + self.assertEqual(len(se.items), len(pick_list.locations)) + self.assertTrue(all(row.pick_list_item for row in se.items)) + se.fg_completed_qty = 0 + + for row in se.items: + row.qty = 4 + se.save().submit() + self.assertEqual(se.fg_completed_qty, 0) + + pick_list.reload() + self.assertEqual(pick_list.locations[0].transferred_qty, 4) + self.assertEqual(pick_list.status, "Partially Transferred") + + next_se = frappe.get_doc(create_stock_entry(pick_list.as_dict())) + self.assertEqual(len(next_se.items), 1) + self.assertEqual(next_se.items[0].qty, 6) + def test_create_second_delivery_note_with_fully_delivered_location(self): # When one pick list item is fully delivered by the first Delivery Note # and another item is still pending, creating a second Delivery Note from diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index e2db1c7c98a..413c25987f4 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -181,6 +181,32 @@ frappe.ui.form.on("Stock Entry", { if (!check_should_not_attach_bom_items(frm.doc.bom_no)) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } +<<<<<<< HEAD +======= + + if (frm.doc.purpose == "Receive from Customer") { + frm.set_query("against_fg", "items", function () { + return { + query: "erpnext.controllers.subcontracting_inward_controller.get_fg_reference_names", + filters: { + parent: frm.doc.subcontracting_inward_order, + }, + }; + }); + } + + if (frm.doc.job_card && frm.doc.purpose === "Manufacture") { + frm.set_df_property("fg_completed_qty", "read_only", 1); + frm.set_df_property("get_items", "hidden", 1); + } + + if (frm.doc.pick_list) { + frm.set_df_property("get_items", "hidden", 1); + if (!frm.doc.job_card) { + frm.set_df_property("fg_completed_qty", "read_only", 1); + } + } +>>>>>>> 01e0844 (fix(stock): keep pick list links when refetching stock entry items) }, setup_quality_inspection: function (frm) { @@ -1356,10 +1382,20 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle ) { frappe.model.remove_from_locals("Work Order", this.frm.doc.work_order); } + + if (this.frm.doc.pick_list) { + frappe.model.remove_from_locals("Pick List", this.frm.doc.pick_list); + } } fg_completed_qty() { +<<<<<<< HEAD this.get_items(); +======= + if (!this.frm.doc.job_card && !this.frm.doc.pick_list) { + this.get_items(); + } +>>>>>>> 01e0844 (fix(stock): keep pick list links when refetching stock entry items) } get_items() { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0541193184c..62b27bf1059 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2576,6 +2576,9 @@ class StockEntry(StockController): @frappe.whitelist() def get_items(self): + if self.pick_list: + return + self.set("items", []) self.validate_work_order() From e126c4597b9b2a0760f704e91441ec33c71312a6 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 26 Aug 2026 13:08:35 +0530 Subject: [PATCH 05/56] fix: resolve stock entry backport conflicts --- .../stock/doctype/stock_entry/stock_entry.js | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 413c25987f4..dcbcd292647 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -181,24 +181,6 @@ frappe.ui.form.on("Stock Entry", { if (!check_should_not_attach_bom_items(frm.doc.bom_no)) { erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); } -<<<<<<< HEAD -======= - - if (frm.doc.purpose == "Receive from Customer") { - frm.set_query("against_fg", "items", function () { - return { - query: "erpnext.controllers.subcontracting_inward_controller.get_fg_reference_names", - filters: { - parent: frm.doc.subcontracting_inward_order, - }, - }; - }); - } - - if (frm.doc.job_card && frm.doc.purpose === "Manufacture") { - frm.set_df_property("fg_completed_qty", "read_only", 1); - frm.set_df_property("get_items", "hidden", 1); - } if (frm.doc.pick_list) { frm.set_df_property("get_items", "hidden", 1); @@ -206,7 +188,6 @@ frappe.ui.form.on("Stock Entry", { frm.set_df_property("fg_completed_qty", "read_only", 1); } } ->>>>>>> 01e0844 (fix(stock): keep pick list links when refetching stock entry items) }, setup_quality_inspection: function (frm) { @@ -1389,13 +1370,9 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle } fg_completed_qty() { -<<<<<<< HEAD - this.get_items(); -======= - if (!this.frm.doc.job_card && !this.frm.doc.pick_list) { + if (!this.frm.doc.pick_list) { this.get_items(); } ->>>>>>> 01e0844 (fix(stock): keep pick list links when refetching stock entry items) } get_items() { From 228ab2d97e1e63a5da77da9d2bf92f631b7cb36c Mon Sep 17 00:00:00 2001 From: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:14:25 +0530 Subject: [PATCH 06/56] fix: keep source rate on re-fetch when maintain same rate is enabled (backport #57479) (#58332) * fix: keep source rate on re-fetch when maintain same rate is enabled (backport #57479) With "maintain same rate" on, re-fetching item details on a row mapped from a source document (e.g. a Purchase Order) pulled the latest Item Price, giving a rate the document can never be saved with. Skip the price list fetch for such rows and keep the source rate, both for a single-row re-fetch and the bulk apply_price_list path (price list / party / conversion rate change). The rate is read from the linked source row in the database (not the mutable target row) and permission-checked against the source document, so an unsaved edit can't lock in a different rate and a crafted request can't disclose another document's pricing. Fixes frappe/erpnext#57436 * fix: resolve linter findings in get_item_details Add missing type hints on the whitelisted get_item_details signature and rename maintain_same_rate_enabled's sole "args" parameter, both flagged by the semgrep security/code-quality rules. Also drops an extra blank line that ruff-format rejected. * fix: widen get_item_details doc type hint to include Document accounts_controller.py calls get_item_details(args, self, ...) during validate, passing the transaction Document itself, not a dict/JSON string. The narrower hint tripped Frappe's runtime argument type validation on every whitelisted call with a live Document, failing test-record creation across the suite. --- erpnext/stock/get_item_details.py | 111 +++++++-- erpnext/stock/tests/test_get_item_details.py | 245 +++++++++++++++++++ 2 files changed, 341 insertions(+), 15 deletions(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index c9fa5fe6e16..6c1d658481a 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _, throw from frappe.model import child_table_fields, default_fields +from frappe.model.document import Document from frappe.model.meta import get_field_precision from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import IfNull, Sum @@ -34,11 +35,34 @@ purchase_doctypes = [ "Purchase Invoice", ] +maintain_same_rate_source_fields = { + "Purchase Order": {"supplier_quotation_item": "Supplier Quotation Item"}, + "Purchase Receipt": {"purchase_order_item": "Purchase Order Item"}, + "Purchase Invoice": {"po_detail": "Purchase Order Item", "pr_detail": "Purchase Receipt Item"}, + "Sales Order": {"quotation_item": "Quotation Item"}, + "Delivery Note": {"so_detail": "Sales Order Item", "si_detail": "Sales Invoice Item"}, + "Sales Invoice": {"so_detail": "Sales Order Item", "dn_detail": "Delivery Note Item"}, +} + +LOCKED_RATE_FIELDS = [ + "price_list_rate", + "rate", + "discount_percentage", + "discount_amount", + "margin_type", + "margin_rate_or_amount", +] + NOT_APPLICABLE_TAX = "N/A" @frappe.whitelist() -def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True): +def get_item_details( + args: dict | str, + doc: Document | dict | str | None = None, + for_validate: bool | str = False, + overwrite_warehouse: bool | str = True, +): """ args = { "item_code": "", @@ -100,16 +124,20 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru if args.get("doctype") in ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]: args.customer = None - out.update(get_price_list_rate(args, item)) + source_row = get_rate_locked_source_row(args, doc) + if source_row: + lock_source_rate(out, source_row) + else: + out.update(get_price_list_rate(args, item)) - if ( - not out.price_list_rate - and args.transaction_type == "selling" - and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list") - ): - fallback_args = args.copy() - fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list") - out.update(get_price_list_rate(fallback_args, item)) + if ( + not out.price_list_rate + and args.transaction_type == "selling" + and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list") + ): + fallback_args = args.copy() + fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list") + out.update(get_price_list_rate(fallback_args, item)) args.customer = current_customer @@ -124,9 +152,8 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru if args.get(key) is None: args[key] = value - data = get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate) - - out.update(data) + if not source_row: + out.update(get_pricing_rule_for_item(args, doc=doc, for_validate=for_validate)) if ( frappe.db.get_single_value("Stock Settings", "auto_create_serial_and_batch_bundle_for_outward") @@ -156,6 +183,52 @@ def remove_standard_fields(details): return details +def get_rate_locked_source_row(args, doc): + """Reads the source row from the DB, not the mutable target row, so an unsaved edit can't override the locked rate.""" + if isinstance(doc, str): + doc = json.loads(doc) + + source_fields = maintain_same_rate_source_fields.get(args.parenttype or args.doctype) + if not source_fields or not doc or args.get("is_return") or not maintain_same_rate_enabled(args): + return None + + row = next((d for d in doc.get("items") or [] if d.get("name") == args.child_docname), None) + if not row: + return None + + for link_field, source_doctype in source_fields.items(): + if source_name := row.get(link_field): + # don't leak another document's pricing to a caller without read access + source = frappe.db.get_value( + source_doctype, source_name, [*LOCKED_RATE_FIELDS, "parent", "parenttype"], as_dict=True + ) + if source and frappe.has_permission(source.parenttype, doc=source.parent): + return source + return None + return None + + +def maintain_same_rate_enabled(transaction_args): + if (transaction_args.parenttype or transaction_args.doctype) in purchase_doctypes: + if transaction_args.get("is_internal_supplier"): + return False + return bool(cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate"))) + + if transaction_args.get("is_internal_customer"): + return False + return bool(cint(frappe.get_cached_value("Selling Settings", "None", "maintain_same_sales_rate"))) + + +def lock_source_rate(out, source_row): + """Copies the full pricing block so a manual discount or margin on the source row survives.""" + out.price_list_rate = flt(source_row.get("price_list_rate")) or flt(source_row.get("rate")) + out.rate = flt(source_row.get("rate")) + out.discount_percentage = flt(source_row.get("discount_percentage")) + out.discount_amount = flt(source_row.get("discount_amount")) + out.margin_type = source_row.get("margin_type") + out.margin_rate_or_amount = flt(source_row.get("margin_rate_or_amount")) + + def set_valuation_rate(out, args): if frappe.db.exists("Product Bundle", {"name": args.item_code, "disabled": 0}, cache=True): valuation_rate = 0.0 @@ -1522,13 +1595,21 @@ def apply_price_list(args, as_doc=False, doc=None): def apply_price_list_on_item(args, doc=None): item_doc = frappe.db.get_value("Item", args.item_code, ["name", "variant_of"], as_dict=1) - item_details = get_price_list_rate(args, item_doc) + + source_row = get_rate_locked_source_row(args, doc) + if source_row: + item_details = frappe._dict() + lock_source_rate(item_details, source_row) + else: + item_details = get_price_list_rate(args, item_doc) args.conversion_factor = flt(args.conversion_factor) or get_conversion_factor( args.item_code, args.uom ).get("conversion_factor", 1) args.stock_qty = flt(args.qty) * flt(args.conversion_factor) - item_details.update(get_pricing_rule_for_item(args, doc=doc)) + + if not source_row: + item_details.update(get_pricing_rule_for_item(args, doc=doc)) return item_details diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index d0b725b9179..398d7e603b5 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -177,3 +177,248 @@ class TestGetItemDetail(FrappeTestCase): dn.save() self.assertEqual(dn.items[0].batch_no, "BATCH01") self.assertEqual(dn.items[0].rate, 50) + + def test_maintain_same_rate_keeps_source_rate_on_refetch(self): + """#57436: with "maintain same rate" on, re-fetching a PR row mapped from a + PO must keep the PO rate instead of pulling a newer, higher Item Price. + + The rate is validated on save, so it can never persist changed; assert the + fetched rate directly to prove the newer Item Price is never picked up. + """ + from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.doctype.item.test_item import make_item + + def set_maintain_same_rate(value): + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", value) + frappe.clear_cache(doctype="Buying Settings") + + set_maintain_same_rate(1) + + item_code = make_item(properties={"is_stock_item": 1}).name + po = create_purchase_order(item_code=item_code, qty=1, rate=100) + + # The PO may auto-insert an Item Price at 100; bump it to the newer, higher rate. + item_price = frappe.db.get_value( + "Item Price", {"item_code": item_code, "price_list": "Standard Buying"} + ) + if item_price: + frappe.db.set_value("Item Price", item_price, "price_list_rate", 120) + else: + frappe.get_doc( + { + "doctype": "Item Price", + "price_list": "Standard Buying", + "item_code": item_code, + "price_list_rate": 120, + } + ).insert() + + pr = make_purchase_receipt(po.name) + pr.insert() + + def fetch_price_list_rate(): + args = frappe._dict( + { + "item_code": item_code, + "doctype": "Purchase Receipt", + "name": pr.name, + "company": pr.company, + "supplier": pr.supplier, + "currency": pr.currency, + "conversion_rate": 1.0, + "price_list": "Standard Buying", + "price_list_currency": pr.currency, + "plc_conversion_rate": 1.0, + "warehouse": pr.items[0].warehouse, + "uom": pr.items[0].uom, + "stock_uom": pr.items[0].stock_uom, + "qty": pr.items[0].qty, + "child_doctype": pr.items[0].doctype, + "child_docname": pr.items[0].name, + "is_return": 0, + "is_internal_supplier": 0, + "ignore_pricing_rule": 1, + } + ) + return get_item_details(args, pr).get("price_list_rate") + + # Rate stays at the PO rate; the newer Item Price (120) is not fetched. + self.assertEqual(fetch_price_list_rate(), 100) + + # Control: without the setting the newer Item Price would be fetched. + set_maintain_same_rate(0) + self.assertEqual(fetch_price_list_rate(), 120) + + def test_apply_price_list_keeps_source_rate_when_maintain_same_rate(self): + """#57436: the bulk apply_price_list path (price list / party / conversion rate + change) must also keep the source rate on mapped rows, not just re-fetch of a + single row. Here a PR row carries its PO rate (175) while the current price list + rate is 100; the bulk apply must keep 175. + """ + from frappe.utils import flt, nowdate + + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.get_item_details import apply_price_list + + item_code = "_Test Item" + price_list = "_Test Buying Price List" + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + try: + po = create_purchase_order(item_code=item_code, rate=175, qty=1) + + row_name = "pr-row-1" + pr_doc = { + "doctype": "Purchase Receipt", + "items": [ + { + "name": row_name, + "item_code": item_code, + "purchase_order_item": po.items[0].name, + "price_list_rate": 175, + "rate": 175, + } + ], + } + args = frappe._dict( + doctype="Purchase Receipt", + supplier=po.supplier, + company=po.company, + currency=po.currency, + conversion_rate=1.0, + price_list=price_list, + plc_conversion_rate=1.0, + transaction_date=nowdate(), + items=[ + frappe._dict( + doctype="Purchase Receipt Item", + parenttype="Purchase Receipt", + item_code=item_code, + child_docname=row_name, + qty=1, + uom=po.items[0].uom, + stock_uom=po.items[0].stock_uom, + conversion_factor=1.0, + ) + ], + ) + + result = apply_price_list(args, doc=pr_doc) + self.assertEqual(flt(result["children"][0].get("price_list_rate")), 175) + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") + + def test_maintain_same_rate_keeps_source_discount_on_refetch(self): + """A mapped source row with a discount has rate != price_list_rate. Re-fetch must + return the source's rate and discount, not just the pre-discount price, or the + recomputed rate diverges from the reference and fails maintain-same-rate on save. + """ + from frappe.utils import flt + + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + item_code = "_Test Item" + price_list = "_Test Buying Price List" + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + try: + # source PO carries the discount: list rate 100, 10% off, effective rate 90 + frappe.flags.dont_fetch_price_list_rate = True + po = create_purchase_order(item_code=item_code, qty=1, do_not_save=True) + po.buying_price_list = price_list + po.items[0].price_list_rate = 100 + po.items[0].discount_percentage = 10 + po.items[0].rate = 90 + po.insert() + po.submit() + frappe.flags.dont_fetch_price_list_rate = False + + row_name = "pr-row-1" + pr_doc = { + "doctype": "Purchase Receipt", + "items": [ + {"name": row_name, "item_code": item_code, "purchase_order_item": po.items[0].name} + ], + } + args = frappe._dict( + item_code=item_code, + doctype="Purchase Receipt", + company=po.company, + supplier=po.supplier, + currency=po.currency, + conversion_rate=1.0, + price_list=price_list, + price_list_currency=po.currency, + plc_conversion_rate=1.0, + warehouse="_Test Warehouse - _TC", + uom=po.items[0].uom, + stock_uom=po.items[0].stock_uom, + qty=1, + child_docname=row_name, + is_return=0, + is_internal_supplier=0, + ignore_pricing_rule=1, + ) + + out = get_item_details(args, pr_doc) + self.assertEqual(flt(out.get("price_list_rate")), 100) + self.assertEqual(flt(out.get("rate")), 90) + self.assertEqual(flt(out.get("discount_percentage")), 10) + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") + frappe.flags.dont_fetch_price_list_rate = False + + def test_rate_lock_source_lookup_checks_permission(self): + """The lock reads source pricing via a direct DB read, so it must not disclose a + source document's pricing to a caller who cannot read that document. + """ + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.stock.get_item_details import get_rate_locked_source_row + + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.clear_cache(doctype="Buying Settings") + + role, email = "_Test Role Without PO Access", "_test_rate_lock_probe@example.com" + try: + po = create_purchase_order(item_code="_Test Item", qty=1, rate=90) + pr_doc = { + "doctype": "Purchase Receipt", + "items": [{"name": "r1", "item_code": "_Test Item", "purchase_order_item": po.items[0].name}], + } + args = frappe._dict(doctype="Purchase Receipt", child_docname="r1") + + # an authorized caller receives the source row + self.assertIsNotNone(get_rate_locked_source_row(args.copy(), dict(pr_doc))) + + if not frappe.db.exists("Role", role): + frappe.get_doc({"doctype": "Role", "role_name": role, "desk_access": 1}).insert( + ignore_permissions=True + ) + if not frappe.db.exists("User", email): + frappe.get_doc( + { + "doctype": "User", + "email": email, + "first_name": "Probe", + "send_welcome_email": 0, + "roles": [{"role": role}], + } + ).insert(ignore_permissions=True) + + frappe.set_user(email) + # a caller who cannot read the Purchase Order gets nothing + self.assertIsNone(get_rate_locked_source_row(args.copy(), dict(pr_doc))) + finally: + frappe.set_user("Administrator") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") From ccdaa92de8f717e9fb473321abed8a75206e9c45 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Wed, 26 Aug 2026 14:50:32 +0530 Subject: [PATCH 07/56] fix: reset hardcoded letter head on Incorrect Serial and Batch Bundle report --- .../incorrect_serial_and_batch_bundle.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json index 11e6e4ea3d4..be5c06d8b59 100644 --- a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json +++ b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.json @@ -9,9 +9,9 @@ "idx": 0, "is_standard": "Yes", "json": "{}", - "letter_head": "Test", + "letter_head": null, "letterhead": null, - "modified": "2025-02-03 15:39:47.613040", + "modified": "2026-08-26 14:39:19.102191", "modified_by": "Administrator", "module": "Stock", "name": "Incorrect Serial and Batch Bundle", 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 08/56] 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 09/56] 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 32d44c6e91b56111db427091ea3b53cc3350e521 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 27 Aug 2026 12:48:45 +0530 Subject: [PATCH 10/56] fix: validate items against source Sales Order in Material Request (cherry picked from commit 65466464df0fdc6de32be5bc7ad25db326081c69) --- .../material_request/material_request.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index eaf28dd4b96..e4738473c87 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -94,6 +94,22 @@ class MaterialRequest(BuyingController): def check_if_already_pulled(self): pass + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Sales Order": { + "ref_dn_field": "sales_order", + "compare_fields": [["company", "="]], + }, + "Sales Order Item": { + "ref_dn_field": "sales_order_item", + "compare_fields": [["item_code", "="], ["uom", "="], ["conversion_factor", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + } + ) + def validate_qty_against_so(self): so_items = {} # Format --> {'SO/00001': {'Item/001': 120, 'Item/002': 24}} for d in self.get("items"): @@ -136,6 +152,7 @@ class MaterialRequest(BuyingController): self.validate_schedule_date() self.check_for_on_hold_or_closed_status("Sales Order", "sales_order") + self.validate_with_previous_doc() self.validate_uom_is_integer("uom", "qty") self.validate_material_request_type() From 59a9819dae736947b3ba748ef411fde3d557702a Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 27 Aug 2026 12:48:45 +0530 Subject: [PATCH 11/56] test: block item change on Sales Order-sourced Material Request row (cherry picked from commit 07f8250e407080e43fefa9408e2e3e058978bbb4) # Conflicts: # erpnext/stock/doctype/material_request/test_material_request.py --- .../material_request/test_material_request.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 6ae289625bc..4c753c867a2 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -919,6 +919,18 @@ class TestMaterialRequest(FrappeTestCase): self.assertRaises(OverAllowanceError, mr.submit) + def test_item_change_on_sales_order_row_is_blocked(self): + from erpnext.selling.doctype.sales_order.sales_order import make_material_request + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + other_item = create_item("_Test MR Item Swap").name + so = make_sales_order() + mr = make_material_request(so.name) + mr.material_request_type = "Purchase" + # swapping the fetched item would leave a stale link to the SO row + mr.items[0].item_code = other_item + self.assertRaises(frappe.ValidationError, mr.insert) + def test_pending_qty_in_pick_list(self): """Test for pick list mapped doc qty from partially received Material Request Transfer""" import json From 17208e7434aff8488a303a4348beb73cec7d613e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 27 Aug 2026 13:08:25 +0530 Subject: [PATCH 12/56] 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 045d7086d10034ef3bad83acf301a6abec39cff9 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Thu, 27 Aug 2026 14:16:01 +0530 Subject: [PATCH 13/56] fix(accounts): validate frozen accounts in period closing voucher (#58477) --- .../doctype/period_closing_voucher/period_closing_voucher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index 8671213c3cc..89dd73af457 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -54,7 +54,7 @@ class PeriodClosingVoucher(AccountsController): if for_cancellation and is_immutable_ledger_enabled(): posting_date = getdate() - check_freezing_date(posting_date, self.company) + check_freezing_date(posting_date) def validate_start_and_end_date(self): self.fy_start_date, self.fy_end_date = frappe.db.get_value( From d62f6e715f2fa3fd30dac1fc52199bc8698f7cf8 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:46:30 +0000 Subject: [PATCH 14/56] fix: mark item tax templates as not applicable (backport #54673) (backport #54677) (#58462) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- .../setup_wizard/data/country_wise_tax.json | 102 +++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index 7ee3a79b4a0..000c115d6bc 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -1395,6 +1395,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1413,6 +1414,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1431,6 +1433,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1449,6 +1452,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1467,6 +1471,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1485,6 +1490,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1507,6 +1513,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1525,6 +1532,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1543,6 +1551,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1561,6 +1570,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1579,6 +1589,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1597,6 +1608,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1628,6 +1640,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1637,6 +1650,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1646,6 +1660,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1655,6 +1670,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1664,6 +1680,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1673,6 +1690,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1682,6 +1700,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1691,6 +1710,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1700,6 +1720,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1709,6 +1730,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1718,6 +1740,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1727,6 +1750,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -1735,6 +1759,7 @@ "account_number": "1433", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] @@ -2158,6 +2183,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2176,6 +2202,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2194,6 +2221,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2212,6 +2240,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2230,6 +2259,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2248,6 +2278,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2270,6 +2301,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2288,6 +2320,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2306,6 +2339,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2324,6 +2358,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2342,6 +2377,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2360,6 +2396,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2391,6 +2428,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2400,6 +2438,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2409,6 +2448,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2418,6 +2458,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2427,6 +2468,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2436,6 +2478,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2445,6 +2488,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2454,6 +2498,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2463,6 +2508,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2472,6 +2518,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2481,6 +2528,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2490,6 +2538,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2498,6 +2547,7 @@ "account_number": "1588", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] @@ -2921,6 +2971,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2939,6 +2990,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2957,6 +3009,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2975,6 +3028,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -2993,6 +3047,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3011,6 +3066,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3033,6 +3089,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3051,6 +3108,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3069,7 +3127,8 @@ "root_type": "Liability", "tax_rate": 19.00 }, - "tax_rate": 19.00 + "not_applicable": 1, + "tax_rate": 0.00 }, { "tax_type": { @@ -3087,6 +3146,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3105,6 +3165,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3123,6 +3184,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3154,6 +3216,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3163,6 +3226,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3172,6 +3236,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3181,6 +3246,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3190,6 +3256,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3199,6 +3266,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3208,6 +3276,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3217,6 +3286,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3226,6 +3296,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3235,6 +3306,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3244,6 +3316,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3253,6 +3326,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3261,6 +3335,7 @@ "account_number": "1550", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] @@ -3653,6 +3728,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3669,6 +3745,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3685,6 +3762,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3701,6 +3779,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3717,6 +3796,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3733,6 +3813,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3753,6 +3834,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3769,6 +3851,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3785,6 +3868,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3801,6 +3885,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3817,6 +3902,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3833,6 +3919,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3861,6 +3948,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3869,6 +3957,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3877,6 +3966,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3885,6 +3975,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3893,6 +3984,7 @@ "root_type": "Liability", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3901,6 +3993,7 @@ "root_type": "Liability", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3909,6 +4002,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3917,6 +4011,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3925,6 +4020,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3933,6 +4029,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3941,6 +4038,7 @@ "root_type": "Asset", "tax_rate": 19.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3949,6 +4047,7 @@ "root_type": "Asset", "tax_rate": 7.00 }, + "not_applicable": 1, "tax_rate": 0.00 }, { @@ -3956,6 +4055,7 @@ "account_name": "Entstandene Einfuhrumsatzsteuer", "root_type": "Asset" }, + "not_applicable": 1, "tax_rate": 0.00 } ] 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 15/56] 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 5851bb3b6e0cd8dfad6bcc285e591c09ad208559 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 27 Aug 2026 17:47:05 +0530 Subject: [PATCH 16/56] fix: sum item and its alternate transferred qty on work order required items --- erpnext/manufacturing/doctype/work_order/work_order.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 3397e7940de..3ae24b601cd 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1272,11 +1272,15 @@ class WorkOrder(Document): & (ste.purpose == "Material Transfer for Manufacture") & (ste.is_return == 0) ) - .groupby(ste_child.item_code) + .groupby(ste_child.item_code, ste_child.original_item) ) data = query.run(as_dict=1) or [] - transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data}) + # An item's own transfer and its substitutes both key to the original item, so sum them. + transferred_items = frappe._dict() + for d in data: + key = d.original_item or d.item_code + transferred_items[key] = flt(transferred_items.get(key)) + flt(d.qty) for row in self.required_items: row.db_set( From 70dff357364be4c19963c548842471b5a487a021 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 27 Aug 2026 17:47:05 +0530 Subject: [PATCH 17/56] test: transferred qty sums item and its alternate --- .../doctype/work_order/test_work_order.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index e453dcc26c3..069907c1b05 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3421,6 +3421,42 @@ class TestWorkOrder(FrappeTestCase): frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 0) + def test_transferred_qty_sums_item_and_its_alternate(self): + # Base item + its alternate transfers must sum onto the required row, not overwrite. + fg_item = "Test FG Item For Alternate Transferred Qty" + source_warehouse = "Stores - _TC" + raw_material = "Test RM For Alternate Transferred Qty" + alternate_item = "Alternate Test RM For Alternate Transferred Qty" + + make_item(fg_item, {"is_stock_item": 1}) + for item in [raw_material, alternate_item]: + make_item(item, {"is_stock_item": 1, "allow_alternative_item": 1}) + test_stock_entry.make_stock_entry(item_code=item, target=source_warehouse, qty=10, basic_rate=100) + + frappe.get_doc( + { + "doctype": "Item Alternative", + "item_code": raw_material, + "alternative_item_code": alternate_item, + "two_way": 1, + } + ).insert() + + make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=[raw_material]) + wo = make_wo_order_test_record(item=fg_item, qty=10, source_warehouse=source_warehouse) + + # 6 as the base item + frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 6)).submit() + # 4 as the alternate item, linked back to the base + alt_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 4)) + alt_transfer.items[0].item_code = alternate_item + alt_transfer.items[0].original_item = raw_material + alt_transfer.submit() + + wo.reload() + self.assertEqual(wo.required_items[0].transferred_qty, 10) + self.assertEqual(wo.material_transferred_for_manufacturing, 10) + def test_components_qty_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) From 099a5a71d92a8b4b877094ceb7986074a976983c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 27 Aug 2026 18:09:48 +0530 Subject: [PATCH 18/56] 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 19/56] 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 20/56] 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 21/56] 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 22/56] 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; } From 3edd8ee8be6c12400de3f83010f61938c6b0acbd Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Fri, 28 Aug 2026 11:48:07 +0530 Subject: [PATCH 23/56] refactor: remove bold from formatter --- erpnext/setup/doctype/company/company.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 8d5565b3180..569d06349f4 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -670,7 +670,7 @@ class Company(NestedSet): if frappe.db.get_single_value("Global Defaults", "demo_company") == self.name: frappe.throw( _("{0} is the site's Demo Company and cannot be deleted directly. Use {1} instead.").format( - bold(self.name), bold(_("Delete Demo Data")) + self.name, _("Delete Demo Data") ) ) From 1187fb8e012f920bd53ee8353648e76588ece38b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:42:02 +0000 Subject: [PATCH 24/56] fix(accounts): set pos profile on invoices respecting user permissions (backport #58508) (#58518) Co-authored-by: Diptanil Saha --- .../doctype/pos_profile/pos_profile.py | 60 +++++++++---------- erpnext/stock/get_item_details.py | 7 +++ 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 5edaa2b89c3..f7596c98d92 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -271,40 +271,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters): user = frappe.session["user"] company = filters.get("company") or frappe.defaults.get_user_default("company") - args = { - "user": user, - "start": start, - "company": company, - "page_len": page_len, - "txt": "%%%s%%" % txt, - } + allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name") - pos_profile = frappe.db.sql( - """select pf.name - from - `tabPOS Profile` pf, `tabPOS Profile User` pfu - where - pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s - and (pf.name like %(txt)s) - and pf.disabled = 0 limit %(page_len)s offset %(start)s""", - args, + if not allowed_pos_profiles: + return {} + + pf = frappe.qb.DocType("POS Profile") + pfu = frappe.qb.DocType("POS Profile User") + + pos_profile = ( + frappe.qb.from_(pf) + .inner_join(pfu) + .on(pfu.parent == pf.name) + .select(pf.name) + .where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0)) + .where(pf.name.isin(allowed_pos_profiles)) + .limit(page_len) + .offset(start) + .run() ) if not pos_profile: - del args["user"] - - pos_profile = frappe.db.sql( - """select pf.name - from - `tabPOS Profile` pf left join `tabPOS Profile User` pfu - on - pf.name = pfu.parent - where - ifnull(pfu.user, '') = '' - and pf.company = %(company)s - and pf.name like %(txt)s - and pf.disabled = 0""", - args, + pos_profile = ( + frappe.qb.from_(pf) + .left_join(pfu) + .on(pf.name == pfu.parent) + .select(pf.name) + .where( + (pfu.user.isnull() | (pfu.user == "")) + & (pf.company == company) + & pf.name.like(f"%{txt}%") + & (pf.disabled == 0) + & (pf.name.isin(allowed_pos_profiles)) + ) + .run() ) return pos_profile diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 6c1d658481a..b68cbc08174 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1418,6 +1418,11 @@ def get_pos_profile(company, pos_profile=None, user=None): if not user: user = frappe.session["user"] + allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name") + + if not allowed_pos_profiles: + return None + pf = frappe.qb.DocType("POS Profile") pfu = frappe.qb.DocType("POS Profile User") @@ -1427,6 +1432,7 @@ def get_pos_profile(company, pos_profile=None, user=None): .on(pf.name == pfu.parent) .select(pf.star) .where((pfu.user == user) & (pfu.default == 1)) + .where(pf.name.isin(allowed_pos_profiles)) ) if company: @@ -1441,6 +1447,7 @@ def get_pos_profile(company, pos_profile=None, user=None): .on(pf.name == pfu.parent) .select(pf.star) .where((pf.company == company) & (pf.disabled == 0)) + .where(pf.name.isin(allowed_pos_profiles)) ).run(as_dict=True) return pos_profile and pos_profile[0] or None From 0609b0656453d1cfdd7eef2a563e94a3bba0f5b9 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 27 Aug 2026 17:35:43 +0530 Subject: [PATCH 25/56] fix(selling): check sales order permission before work order creation (#58492) (cherry picked from commit a8ba713f8033ad0719861eaac0a00ac55d451c94) # Conflicts: # erpnext/selling/doctype/sales_order/mapper.py --- erpnext/selling/doctype/sales_order/sales_order.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 583486960c1..1b7b5dce5fb 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1663,8 +1663,10 @@ def is_product_bundle(item_code): @frappe.whitelist() -def make_work_orders(items, sales_order, company, project=None): +def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None): """Make Work Orders against the given Sales Order for the given `items`""" + frappe.has_permission("Sales Order", "read", sales_order, throw=True) + items = json.loads(items).get("items") out = [] From 33ebc7d7e31900dbe3f8450b50749cf2678bacf8 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 27 Aug 2026 17:34:27 +0530 Subject: [PATCH 26/56] fix(selling): check quotation write permission before marking lost (#58493) (cherry picked from commit 5f99a3418df290344e00da58a2325404fdc6d5e1) # Conflicts: # erpnext/selling/doctype/quotation/quotation.py --- erpnext/selling/doctype/quotation/quotation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 8366f61c6b3..e25ba017c00 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -257,7 +257,11 @@ class Quotation(SellingController): opp.set_status(status=status, update=True) @frappe.whitelist() - def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None): + def declare_enquiry_lost( + self, lost_reasons_list: list, competitors: list, detailed_reason: str | None = None + ): + self.check_permission("write") + if not (self.is_fully_ordered() or self.is_partially_ordered()): get_lost_reasons = frappe.get_list("Quotation Lost Reason", fields=["name"]) lost_reasons_lst = [reason.get("name") for reason in get_lost_reasons] From c4e7cfa6a0a90dda060701d563f7131c218a2683 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:42:27 +0000 Subject: [PATCH 27/56] fix: persist redistributed additional costs during stock entry repost (backport #58433) (#58532) * fix: persist redistributed additional costs during stock entry repost (#58433) * fix: persist redistributed additional costs during stock entry repost * test: cover additional cost persistence on stock entry recalculation (cherry picked from commit 074c84e8809a85aeb1f94be3432b78dcad0c8584) # Conflicts: # erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py * chore: fix conflicts Removed unused import 'flt' from test file. --------- Co-authored-by: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com> Co-authored-by: rohitwaghchaure --- .../test_repost_item_valuation.py | 52 ++++++++++++++++++- erpnext/stock/stock_ledger.py | 4 +- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 85c7372be59..a2847a120b5 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, call import frappe from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import add_days, add_to_date, now, nowdate, today +from frappe.utils import add_days, add_to_date, flt, now, nowdate, today from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.utils import repost_gle_for_stock_vouchers @@ -475,6 +475,56 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): # incoming rate after reposting should be 150 self.assertSLEs(se, [{"incoming_rate": 150}]) + def test_recalculate_stock_entry_additional_cost_updates_all_incoming_rows(self): + from erpnext.stock.stock_ledger import update_entries_after + + company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") + warehouse = "Stores - TCP1" + items = [ + self.make_item(f"_Test Repost Addl Cost {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C") + ] + + for item_code in items: + make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, rate=10) + + transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True) + transfer.from_warehouse = warehouse + transfer.to_warehouse = warehouse + transfer.items = [] + for item_code in items: + transfer.append( + "items", + { + "item_code": item_code, + "qty": 100, + "s_warehouse": warehouse, + "t_warehouse": warehouse, + "uom": "Nos", + "conversion_factor": 1, + }, + ) + transfer.append( + "additional_costs", + { + "expense_account": "Expenses Included In Valuation - TCP1", + "description": "freight", + "amount": 100, + }, + ) + transfer.insert() + transfer.submit() + + first_row = transfer.items[0] + frappe.db.set_value("Stock Entry Detail", first_row.name, "basic_rate", first_row.basic_rate + 1) + update_entries_after.recalculate_amounts_in_stock_entry(MagicMock(), transfer.name, first_row.name) + + transfer.load_from_db() + detail_additional_cost = sum(row.additional_cost for row in transfer.items) + net_added_to_stock = sum(row.amount - row.basic_amount for row in transfer.items) + + self.assertEqual(flt(detail_additional_cost, 2), flt(transfer.total_additional_costs, 2)) + self.assertEqual(flt(net_added_to_stock, 2), flt(transfer.total_additional_costs, 2)) + def test_repost_multi_line_moving_average_return(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 67cc1e30fd8..d4791ac5670 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1406,12 +1406,14 @@ class update_entries_after: stock_entry = frappe.get_doc("Stock Entry", voucher_no, for_update=True) stock_entry.calculate_rate_and_amount(reset_outgoing_rate=False, raise_error_if_no_rate=False) stock_entry.db_update() + update_additional_cost_rows = bool(stock_entry.get("additional_costs")) for d in stock_entry.items: - # Update only the row that matches the voucher_detail_no or the row containing the FG/Scrap Item. + # Additional costs are redistributed across all incoming rows. if ( d.name == voucher_detail_no or (not d.s_warehouse and d.t_warehouse) or stock_entry.purpose in ["Manufacture", "Repack"] + or (update_additional_cost_rows and d.t_warehouse) ): d.db_update() From 2b2a0056824864a29ac0c56b36386f787c7e2229 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 21 Aug 2026 13:18:26 +0530 Subject: [PATCH 28/56] fix(crm): check write permission in edit_note (cherry picked from commit eb49f51d29330bec3761d69c0ca12a79bce14fb0) # Conflicts: # erpnext/crm/utils.py --- erpnext/crm/utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 06d97b5173f..0323c3bfa0e 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -227,7 +227,14 @@ class CRMNote(Document): notify_mentions(self.doctype, self.name, note) @frappe.whitelist() +<<<<<<< HEAD def edit_note(self, note, row_id): +======= + def edit_note(self, note: str, row_id: str): + # db_update() skips the write check that save() does in add_note/delete_note + self.check_permission("write") + +>>>>>>> eb49f51 (fix(crm): check write permission in edit_note) for d in self.notes: if cstr(d.name) == row_id: d.note = note From f0ac82605c728f6d3c7f6697fab2572032d814f2 Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:06:10 +0530 Subject: [PATCH 29/56] chore: resolve merge conflicts --- erpnext/crm/utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index 0323c3bfa0e..15569ed61ec 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -227,14 +227,10 @@ class CRMNote(Document): notify_mentions(self.doctype, self.name, note) @frappe.whitelist() -<<<<<<< HEAD - def edit_note(self, note, row_id): -======= def edit_note(self, note: str, row_id: str): # db_update() skips the write check that save() does in add_note/delete_note self.check_permission("write") ->>>>>>> eb49f51 (fix(crm): check write permission in edit_note) for d in self.notes: if cstr(d.name) == row_id: d.note = note From 6a632d6d1c81120f20d7b18e6971c6f205614b57 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:55:45 +0530 Subject: [PATCH 30/56] refactor(stock): remove dead `warehouse_condition` branch on `get_stock_ledger_entries` (backport #58552) (#58553) Co-authored-by: Diptanil Saha --- erpnext/stock/stock_ledger.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index d4791ac5670..9e11762362c 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1892,9 +1892,6 @@ def get_stock_ledger_entries( else: conditions += " and warehouse = %(warehouse)s" - elif previous_sle.get("warehouse_condition"): - conditions += " and " + previous_sle.get("warehouse_condition") - if check_serial_no and previous_sle.get("serial_no"): # conditions += " and serial_no like {}".format(frappe.db.escape('%{0}%'.format(previous_sle.get("serial_no")))) serial_no = previous_sle.get("serial_no") From 2d46bb962c9c73cc952669217b17d237025e8818 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:56:06 +0000 Subject: [PATCH 31/56] fix(stock): load available serial no report (backport #58558) (#58561) fix(stock): load available serial no report (#58558) (cherry picked from commit 8b433945333ffa77784850c3b724876eb268a302) Co-authored-by: Pandiyan P --- erpnext/stock/report/available_serial_no/available_serial_no.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/report/available_serial_no/available_serial_no.js b/erpnext/stock/report/available_serial_no/available_serial_no.js index c69c6503de8..976d799532c 100644 --- a/erpnext/stock/report/available_serial_no/available_serial_no.js +++ b/erpnext/stock/report/available_serial_no/available_serial_no.js @@ -77,4 +77,4 @@ frappe.query_reports["Available Serial No"] = { }, }; -erpnext.utils.add_inventory_dimensions("Balance Serial No", 10); +erpnext.utils.add_inventory_dimensions("Available Serial No", 10); From 7454735595a53e09dea33ab6584bf898e4c147ec Mon Sep 17 00:00:00 2001 From: Henil Maru Date: Sun, 30 Aug 2026 15:56:38 +0530 Subject: [PATCH 32/56] fix: Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows (#58527) --- erpnext/manufacturing/doctype/work_order/work_order.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 3ae24b601cd..8beb365349d 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -258,7 +258,7 @@ class WorkOrder(Document): PackedItem = frappe.qb.DocType("Packed Item") ProductBundleItem = frappe.qb.DocType("Product Bundle Item") - so = ( + so_query = ( frappe.qb.from_(SalesOrder) .inner_join(SalesOrderItem) .on(SalesOrderItem.parent == SalesOrder.name) @@ -274,9 +274,13 @@ class WorkOrder(Document): | (ProductBundleItem.item_code == production_item) ) ) - .run(as_dict=1) ) + if self.sales_order_item: + so_query = so_query.where(SalesOrderItem.name == self.sales_order_item) + + so = so_query.run(as_dict=1) + if not so: so = ( frappe.qb.from_(SalesOrder) From 9c19b024db831148b7bf17c25ceba2508ac66ee6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 30 Aug 2026 16:44:14 +0530 Subject: [PATCH 33/56] fix(manufacturing): use packed row delivery date (#58569) --- .../doctype/work_order/work_order.py | 16 +++++- .../doctype/sales_order/test_sales_order.py | 55 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 8beb365349d..dd5a66e096c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -282,12 +282,15 @@ class WorkOrder(Document): so = so_query.run(as_dict=1) if not so: - so = ( + packed_so_query = ( frappe.qb.from_(SalesOrder) .inner_join(SalesOrderItem) .on(SalesOrderItem.parent == SalesOrder.name) .inner_join(PackedItem) - .on(PackedItem.parent == SalesOrder.name) + .on( + (PackedItem.parent == SalesOrder.name) + & (PackedItem.parent_detail_docname == SalesOrderItem.name) + ) .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) .where( (SalesOrder.name == self.sales_order) @@ -296,9 +299,16 @@ class WorkOrder(Document): & (SalesOrder.docstatus == 1) & (PackedItem.item_code == production_item) ) - .run(as_dict=1) ) + if self.sales_order_item: + packed_so_query = packed_so_query.where( + (PackedItem.name == self.sales_order_item) + | (SalesOrderItem.name == self.sales_order_item) + ) + + so = packed_so_query.run(as_dict=1) + if len(so): if not self.expected_delivery_date: self.expected_delivery_date = so[0].delivery_date diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index b71e2d6878a..8c0631c1b52 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1730,6 +1730,61 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): ) self.assertEqual(wo_qty[0][0], so_item_name.get(item)) + @change_settings("Selling Settings", {"allow_multiple_items": 1}) + def test_make_work_order_for_duplicate_product_bundle_rows(self): + from erpnext.selling.doctype.sales_order.sales_order import get_work_order_items + + bundle_item = make_item("_Test Work Order Product Bundle", {"is_stock_item": 0}).name + make_product_bundle(bundle_item, ["_Test FG Item"]) + + first_delivery_date = add_days(today(), 5) + second_delivery_date = add_days(today(), 10) + so = make_sales_order( + item_list=[ + { + "item_code": bundle_item, + "qty": 1, + "rate": 100, + "warehouse": "_Test Warehouse - _TC", + "delivery_date": first_delivery_date, + }, + { + "item_code": bundle_item, + "qty": 1, + "rate": 100, + "warehouse": "_Test Warehouse - _TC", + "delivery_date": second_delivery_date, + }, + ] + ) + + items = [ + { + "warehouse": item.get("warehouse"), + "item_code": item.get("item_code"), + "pending_qty": item.get("pending_qty"), + "sales_order_item": item.get("sales_order_item"), + "bom": item.get("bom"), + "description": item.get("description"), + } + for item in get_work_order_items(so.name) + ] + work_orders = make_work_orders(json.dumps({"items": items}), so.name, so.company) + + expected_delivery_dates = { + packed_item.name: next( + item.delivery_date for item in so.items if item.name == packed_item.parent_detail_docname + ) + for packed_item in so.packed_items + } + self.assertEqual(len(work_orders), 2) + for work_order_name in work_orders: + work_order = frappe.get_doc("Work Order", work_order_name) + self.assertEqual( + getdate(work_order.expected_delivery_date), + getdate(expected_delivery_dates[work_order.sales_order_item]), + ) + def test_advance_payment_entry_unlink_against_sales_order(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry From c656497aac76af82eea028e3e8cb8d5380385f0f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:36:29 +0000 Subject: [PATCH 34/56] fix(timesheet): scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport #58267) (#58576) Co-authored-by: Diptanil Saha --- .../projects/doctype/timesheet/timesheet.py | 143 +++++++++++------- 1 file changed, 90 insertions(+), 53 deletions(-) diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 75520300abd..a7c13ba8af0 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder.functions import Date from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours, time_diff_in_seconds from erpnext.controllers.queries import get_match_cond @@ -322,45 +323,62 @@ class Timesheet(Document): @frappe.whitelist() def get_projectwise_timesheet_data(project=None, parent=None, from_time=None, to_time=None): - condition = "" + tsd = frappe.qb.DocType("Timesheet Detail") + ts = frappe.qb.DocType("Timesheet") + + allowed_timesheets = frappe.get_list("Timesheet", pluck="name") + allowed_projects = frappe.get_list("Project", pluck="name") + + if not allowed_timesheets: + return [] + + query = ( + frappe.qb.from_(tsd) + .inner_join(ts) + .on(ts.name == tsd.parent) + .select( + tsd.name.as_("name"), + tsd.parent.as_("time_sheet"), + tsd.from_time.as_("from_time"), + tsd.to_time.as_("to_time"), + tsd.billing_hours.as_("billing_hours"), + tsd.billing_amount.as_("billing_amount"), + tsd.activity_type.as_("activity_type"), + tsd.description.as_("description"), + ts.currency.as_("currency"), + tsd.project_name.as_("project_name"), + ) + .where( + (tsd.parenttype == "Timesheet") + & (tsd.docstatus == 1) + & (tsd.is_billable == 1) + & tsd.sales_invoice.isnull() + & (tsd.parent.isin(allowed_timesheets)) + ) + ) + + if allowed_projects: + query = query.where((tsd.project.isin(allowed_projects)) | (tsd.project.isnull())) + else: + query = query.where(tsd.project.isnull()) + if project: - condition += "AND tsd.project = %(project)s " + query = query.where(tsd.project == project) if parent: - condition += "AND tsd.parent = %(parent)s " + query = query.where(tsd.parent == parent) if from_time and to_time: - condition += "AND CAST(tsd.from_time as DATE) BETWEEN %(from_time)s AND %(to_time)s" + query = query.where(Date(tsd.from_time).between(from_time, to_time)) - query = f""" - SELECT - tsd.name as name, - tsd.parent as time_sheet, - tsd.from_time as from_time, - tsd.to_time as to_time, - tsd.billing_hours as billing_hours, - tsd.billing_amount as billing_amount, - tsd.activity_type as activity_type, - tsd.description as description, - ts.currency as currency, - tsd.project_name as project_name - FROM `tabTimesheet Detail` tsd - INNER JOIN `tabTimesheet` ts - ON ts.name = tsd.parent - WHERE - tsd.parenttype = 'Timesheet' - AND tsd.docstatus = 1 - AND tsd.is_billable = 1 - AND tsd.sales_invoice is NULL - {condition} - ORDER BY tsd.from_time ASC - """ - - filters = {"project": project, "parent": parent, "from_time": from_time, "to_time": to_time} - - return frappe.db.sql(query, filters, as_dict=1) + return query.orderby(tsd.from_time).run(as_dict=1) @frappe.whitelist() def get_timesheet_detail_rate(timelog, currency): + allowed_timesheets = frappe.get_list("Timesheet", pluck="name") + + if not allowed_timesheets: + return 0.0 + ts = frappe.qb.DocType("Timesheet") ts_detail = frappe.qb.DocType("Timesheet Detail") @@ -368,10 +386,20 @@ def get_timesheet_detail_rate(timelog, currency): frappe.qb.from_(ts_detail) .inner_join(ts) .on(ts.name == ts_detail.parent) - .select(ts_detail.billing_amount.as_("billing_amount"), ts.currency.as_("currency")) - .where(ts_detail.name == timelog) + .select( + ts_detail.billing_amount.as_("billing_amount"), + ts.currency.as_("currency"), + ts.name.as_("timesheet"), + ) + .where((ts_detail.name == timelog) & ts_detail.parent.isin(allowed_timesheets)) + .limit(1) .run(as_dict=1) - )[0] + ) + + if not timelog_detail: + return 0.0 + + timelog_detail = timelog_detail[0] if timelog_detail.currency: exchange_rate = get_exchange_rate(timelog_detail.currency, currency) @@ -386,33 +414,42 @@ def get_timesheet(doctype, txt, searchfield, start, page_len, filters): if not filters: filters = {} - condition = "" - if filters.get("project"): - condition = "and tsd.project = %(project)s" + allowed_timesheets = frappe.get_list("Timesheet", pluck="name") - return frappe.db.sql( - f"""select distinct tsd.parent from `tabTimesheet Detail` tsd, - `tabTimesheet` ts where - ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and - tsd.docstatus = 1 and ts.total_billable_amount > 0 - and tsd.parent LIKE %(txt)s {condition} - order by tsd.parent limit %(page_len)s offset %(start)s""", - { - "txt": "%" + txt + "%", - "start": start, - "page_len": page_len, - "project": filters.get("project"), - }, + if not allowed_timesheets: + return [] + + tsd = frappe.qb.DocType("Timesheet Detail") + ts = frappe.qb.DocType("Timesheet") + + query = ( + frappe.qb.from_(tsd) + .inner_join(ts) + .on(tsd.parent == ts.name) + .select(tsd.parent) + .distinct() + .where( + ts.status.isin(["Submitted", "Payslip"]) + & (tsd.docstatus == 1) + & (ts.total_billable_amount > 0) + & tsd.parent.like(f"%{txt}%") + & tsd.parent.isin(allowed_timesheets) + ) ) + if filters.get("project"): + query = query.where(tsd.project == filters.get("project")) + + return query.orderby(tsd.parent).limit(page_len).offset(start).run() + @frappe.whitelist() -def get_timesheet_data(name, project): +def get_timesheet_data(name, project=None): data = None - if project and project != "": + if project: data = get_projectwise_timesheet_data(project, name) else: - data = frappe.get_all( + data = frappe.get_list( "Timesheet", fields=[ "(total_billable_amount - total_billed_amount) as billing_amt", From af8c010aa877c034a4818dccd1dcaef197e43100 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 21 Aug 2026 13:17:47 +0530 Subject: [PATCH 35/56] fix(projects): check read permission on source in create_duplicate_project (cherry picked from commit 8c8b282a2ef5f10a22acc5e009863b313f2450bf) --- erpnext/projects/doctype/project/project.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 80ef45c099d..b0cda8df8f8 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -596,6 +596,10 @@ def create_duplicate_project(prev_doc, project_name): prev_doc = json.loads(prev_doc) + # prev_doc is caller-supplied, but the tasks below are read from the db by name + if source_name := prev_doc.get("name"): + frappe.has_permission("Project", "read", source_name, throw=True) + if project_name == prev_doc.get("name"): frappe.throw(_("Use a name that is different from previous project name")) From 0ea03a688bad47b82a5002d75a5918d24d2ed5b3 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Mon, 31 Aug 2026 14:04:32 +0530 Subject: [PATCH 36/56] chore(projects): add type hints to create_duplicate_project --- erpnext/projects/doctype/project/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index b0cda8df8f8..0e9e9d11dae 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -590,7 +590,7 @@ def allow_to_make_project_update(project, time, frequency): @frappe.whitelist() -def create_duplicate_project(prev_doc, project_name): +def create_duplicate_project(prev_doc: str | dict, project_name: str): """Create duplicate project based on the old project""" import json From 7090c6fdab762565b778616d56c9fbb047d27c7a Mon Sep 17 00:00:00 2001 From: ljain112 Date: Thu, 27 Aug 2026 15:32:39 +0530 Subject: [PATCH 37/56] fix(taxes): skip tax addition for invoice created from opening invoice tool (cherry picked from commit e08a166281ad918a74a6e6c2be7f31535cfc5894) # Conflicts: # erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py # erpnext/accounts/services/taxes.py --- .../opening_invoice_creation_tool.py | 3 + .../test_opening_invoice_creation_tool.py | 54 +++ erpnext/accounts/services/taxes.py | 451 ++++++++++++++++++ 3 files changed, 508 insertions(+) create mode 100644 erpnext/accounts/services/taxes.py diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index 48551eab3b2..258643fc56e 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -282,6 +282,9 @@ def start_import(invoices): invoice_number = d.invoice_number doc = frappe.get_doc(d) doc.flags.ignore_mandatory = True + # the outstanding amount is entered inclusive of tax, so taxes must not + # be added on top of it + doc.flags.dont_auto_add_taxes = True doc.insert(set_name=invoice_number) doc.submit() frappe.db.commit() diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 9bb69cacd33..6fca0ce6d70 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -5,13 +5,18 @@ import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, today +<<<<<<< HEAD from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( create_dimension, disable_dimension, ) +======= +from erpnext.accounts.doctype.account.test_account import create_account +>>>>>>> e08a166 (fix(taxes): skip tax addition for invoice created from opening invoice tool) from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( get_temporary_opening_account, ) +from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule from erpnext.projects.doctype.project.test_project import make_project test_dependencies = ["Customer", "Supplier", "Accounting Dimension"] @@ -140,6 +145,55 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase): for invoice in invoices: self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC") + @ERPNextTestSuite.change_settings( + "Accounts Settings", + {"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0}, + ) + def test_opening_invoice_creation_without_taxes(self): + company = "_Test Opening Invoice Company" + template = frappe.get_doc( + { + "doctype": "Sales Taxes and Charges Template", + "company": company, + "title": "_Test Opening Invoice Tax", + "taxes": [ + { + "charge_type": "On Net Total", + "account_head": create_account( + account_name="_Test Opening Tax Account", + parent_account="Duties and Taxes - _TOIC", + account_type="Tax", + company=company, + ), + "description": "Test taxes", + "rate": 9, + } + ], + } + ).insert() + + # makes the template the default for the party, as it would be on a live site + make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1) + + tool = self.make_invoices(company=company, return_doc=True) + invoices = tool.make_invoices() + self.assertEqual(len(invoices), 2) + + # outstanding amount is entered inclusive of tax, so taxes must not be added on top of it + for invoice in invoices: + si = frappe.get_doc("Sales Invoice", invoice) + self.assertFalse(si.taxes) + self.assertEqual(si.grand_total, 200) + self.assertEqual(si.outstanding_amount, 200) + + # the same invoice created outside the tool keeps the default taxes, + # since adding them there is the user's decision + si = frappe.get_doc(tool.get_invoices()[0]) + si.flags.ignore_mandatory = True + si.insert() + self.assertTrue(si.taxes) + self.assertEqual(si.grand_total, 218) + def test_opening_entry_project_linking(self): doc = self.make_invoices( company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True diff --git a/erpnext/accounts/services/taxes.py b/erpnext/accounts/services/taxes.py new file mode 100644 index 00000000000..ebccf91f0a3 --- /dev/null +++ b/erpnext/accounts/services/taxes.py @@ -0,0 +1,451 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Tax helpers: TaxService class for doc-mutating operations, free functions for stateless utilities.""" + +import json + +import frappe +from frappe import _, throw +from frappe.utils import cint, flt, parse_json + +import erpnext +from erpnext.stock.get_item_details import ( + NOT_APPLICABLE_TAX, + _get_item_tax_template, + _get_item_tax_template_from_item_group, + get_item_tax_map, +) + + +class TaxService: + def __init__(self, doc): + self.doc = doc + + def set_taxes(self) -> None: + doc = self.doc + if not doc.meta.get_field("taxes"): + return + + tax_master_doctype = doc.meta.get_field("taxes_and_charges").options + + if (doc.is_new() or self.is_pos_profile_changed()) and not doc.get("taxes"): + if doc.company and not doc.get("taxes_and_charges"): + doc.taxes_and_charges = frappe.db.get_value( + tax_master_doctype, {"is_default": 1, "company": doc.company} + ) + self.append_taxes_from_master(tax_master_doctype) + + def is_pos_profile_changed(self) -> bool: + doc = self.doc + if ( + doc.doctype == "Sales Invoice" + and doc.is_pos + and doc.pos_profile != frappe.db.get_value("Sales Invoice", doc.name, "pos_profile") + ): + return True + + def set_taxes_and_charges(self) -> None: + doc = self.doc + if doc.doctype == "Material Request": + return + + if doc.get("taxes") or doc.get("is_pos"): + return + + # set by the Opening Invoice Creation Tool, where the outstanding amount + # entered against a party is already inclusive of tax + if doc.flags.dont_auto_add_taxes: + return + + if frappe.get_single_value( + "Accounts Settings", "add_taxes_from_taxes_and_charges_template" + ) and hasattr(doc, "taxes_and_charges"): + if tax_master_doctype := doc.meta.get_field("taxes_and_charges").options: + self.append_taxes_from_master(tax_master_doctype) + + if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): + self.append_taxes_from_item_tax_template() + + def append_taxes_from_master(self, tax_master_doctype=None) -> None: + doc = self.doc + if doc.get("taxes_and_charges"): + if not tax_master_doctype: + tax_master_doctype = doc.meta.get_field("taxes_and_charges").options + doc.extend("taxes", get_taxes_and_charges(tax_master_doctype, doc.get("taxes_and_charges"))) + + def append_taxes_from_item_tax_template(self) -> None: + doc = self.doc + if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): + return + + for row in doc.items: + item_tax_rate = row.get("item_tax_rate") + if not item_tax_rate: + continue + + if isinstance(item_tax_rate, str): + item_tax_rate = parse_json(item_tax_rate) + + for account_head, _rate in item_tax_rate.items(): + if not self.get_tax_row(account_head): + doc.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": account_head, + "rate": 0, + "description": account_head, + "set_by_item_tax_template": 1, + "category": "Total", + "add_deduct_tax": "Add", + }, + ) + + def get_tax_row(self, account_head): + for row in self.doc.taxes: + if row.account_head == account_head: + return row + + def set_other_charges(self) -> None: + self.doc.set("taxes", []) + self.set_taxes() + + def validate_enabled_taxes_and_charges(self) -> None: + doc = self.doc + taxes_and_charges_doctype = doc.meta.get_options("taxes_and_charges") + if doc.taxes_and_charges and frappe.get_cached_value( + taxes_and_charges_doctype, doc.taxes_and_charges, "disabled" + ): + frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, doc.taxes_and_charges)) + + def validate_tax_account_company(self) -> None: + doc = self.doc + for d in doc.get("taxes"): + if d.account_head: + tax_account_company = frappe.get_cached_value("Account", d.account_head, "company") + if tax_account_company != doc.company: + frappe.throw( + _("Row #{0}: Account {1} does not belong to company {2}").format( + d.idx, d.account_head, doc.company + ) + ) + + def get_tax_map(self) -> dict: + tax_map = {} + for tax in self.doc.get("taxes"): + tax_map.setdefault(tax.account_head, 0.0) + tax_map[tax.account_head] += tax.tax_amount + return tax_map + + def get_amount_and_base_amount(self, item, enable_discount_accounting): + doc = self.doc + amount = item.net_amount + base_amount = item.base_net_amount + + if ( + enable_discount_accounting + and doc.get("discount_amount") + and doc.get("additional_discount_account") + ): + if not hasattr(doc, "__has_distributed_discount_set"): + doc.__has_distributed_discount_set = any( + i.distributed_discount_amount for i in doc.get("items") + ) + + if not doc.__has_distributed_discount_set: + return item.amount, item.base_amount + + amount += item.distributed_discount_amount + base_amount += flt( + item.distributed_discount_amount * doc.get("conversion_rate"), + item.precision("distributed_discount_amount"), + ) + + return amount, base_amount + + def get_tax_amounts(self, tax, enable_discount_accounting): + doc = self.doc + amount = tax.tax_amount_after_discount_amount + base_amount = tax.base_tax_amount_after_discount_amount + + if ( + enable_discount_accounting + and doc.get("discount_amount") + and doc.get("additional_discount_account") + and doc.get("apply_discount_on") == "Grand Total" + ): + amount = tax.tax_amount + base_amount = tax.base_tax_amount + + return amount, base_amount + + +@frappe.whitelist() +def get_tax_rate(account_head: str) -> dict: + return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True) + + +@frappe.whitelist() +def get_default_taxes_and_charges( + master_doctype: str, tax_template: str | None = None, company: str | None = None +) -> dict | None: + if not company: + return {} + + if tax_template and company: + tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company") + if tax_template_company == company: + return + + default_tax = frappe.db.get_value(master_doctype, {"is_default": 1, "company": company}) + + return { + "taxes_and_charges": default_tax, + "taxes": get_taxes_and_charges(master_doctype, default_tax), + } + + +@frappe.whitelist() +def get_taxes_and_charges(master_doctype: str, master_name: str | None = None) -> list | None: + if not master_name: + return + from frappe.model import child_table_fields, default_fields + + tax_master = frappe.get_doc(master_doctype, master_name) + + taxes_and_charges = [] + for _i, tax in enumerate(tax_master.get("taxes")): + tax = tax.as_dict() + + for fieldname in default_fields + child_table_fields: + if fieldname in tax: + del tax[fieldname] + + taxes_and_charges.append(tax) + + return taxes_and_charges + + +def validate_conversion_rate( + currency: str, conversion_rate: float, conversion_rate_label: str, company: str +) -> None: + """Throw a validation error if conversion_rate is falsy.""" + company_currency = frappe.get_cached_value("Company", company, "default_currency") + + if not conversion_rate: + throw( + _("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format( + conversion_rate_label, currency, company_currency + ) + ) + + +def validate_taxes_and_charges(tax) -> None: + if tax.charge_type in ["Actual", "On Net Total", "On Paid Amount"] and tax.row_id: + frappe.throw( + _("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'") + ) + elif tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]: + if cint(tax.idx) == 1: + frappe.throw( + _( + "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" + ) + ) + elif not tax.row_id: + frappe.throw( + _("Please specify a valid Row ID for row {0} in table {1}").format(tax.idx, _(tax.doctype)) + ) + elif tax.row_id and cint(tax.row_id) >= cint(tax.idx): + frappe.throw( + _("Cannot refer row number greater than or equal to current row number for this Charge type") + ) + + if tax.charge_type == "Actual": + tax.rate = None + + +def validate_account_head(idx: int, account: str, company: str, context: str | None = None) -> None: + """Throw a ValidationError if the account belongs to a different company or is a group account.""" + if company != frappe.get_cached_value("Account", account, "company"): + frappe.throw( + _("Row {0}: The {3} Account {1} does not belong to the company {2}").format( + idx, frappe.bold(account), frappe.bold(company), context or "" + ), + title=_("Invalid Account"), + ) + + if frappe.get_cached_value("Account", account, "is_group"): + frappe.throw( + _( + "You selected the account group {1} as {2} Account in row {0}. Please select a single account." + ).format(idx, frappe.bold(account), context or ""), + title=_("Invalid Account"), + ) + + +def validate_cost_center(tax, doc) -> None: + if not tax.cost_center: + return + + company = frappe.get_cached_value("Cost Center", tax.cost_center, "company") + + if company != doc.company: + frappe.throw( + _("Row {0}: Cost Center {1} does not belong to Company {2}").format( + tax.idx, frappe.bold(tax.cost_center), frappe.bold(doc.company) + ), + title=_("Invalid Cost Center"), + ) + + +def validate_inclusive_tax(tax, doc) -> None: + def _on_previous_row_error(row_range): + throw( + _("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format( + tax.idx, row_range + ) + ) + + if cint(getattr(tax, "included_in_print_rate", None)): + if tax.charge_type == "Actual": + throw( + _("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format( + tax.idx + ) + ) + elif tax.charge_type == "On Previous Row Amount" and not cint( + doc.get("taxes")[cint(tax.row_id) - 1].included_in_print_rate + ): + _on_previous_row_error(tax.row_id) + elif tax.charge_type == "On Previous Row Total" and not all( + [cint(t.included_in_print_rate) for t in doc.get("taxes")[: cint(tax.row_id) - 1]] + ): + _on_previous_row_error("1 - %d" % (tax.row_id,)) + elif tax.get("category") == "Valuation": + frappe.throw(_("Valuation type charges can not be marked as Inclusive")) + + +def set_balance_in_account_currency( + gl_dict, + account_currency: str | None = None, + conversion_rate: float | None = None, + company_currency: str | None = None, +) -> None: + if (not conversion_rate) and (account_currency != company_currency): + frappe.throw( + _("Account: {0} with currency: {1} can not be selected").format(gl_dict.account, account_currency) + ) + + gl_dict["account_currency"] = account_currency + + if flt(gl_dict.debit) and not flt(gl_dict.debit_in_account_currency): + gl_dict.debit_in_account_currency = ( + gl_dict.debit if account_currency == company_currency else flt(gl_dict.debit / conversion_rate, 2) + ) + + if flt(gl_dict.credit) and not flt(gl_dict.credit_in_account_currency): + gl_dict.credit_in_account_currency = ( + gl_dict.credit + if account_currency == company_currency + else flt(gl_dict.credit / conversion_rate, 2) + ) + + +def set_child_tax_template_and_map(item, child_item, parent_doc) -> None: + ctx = frappe._dict( + { + "item_code": item.item_code, + "posting_date": parent_doc.transaction_date, + "tax_category": parent_doc.get("tax_category"), + "company": parent_doc.get("company"), + "base_net_rate": item.get("base_net_rate"), + } + ) + + item_tax_template = _get_item_tax_template(ctx, item.taxes) + + if not item_tax_template: + item_tax_template = _get_item_tax_template_from_item_group(ctx, item.item_group) + + child_item.item_tax_template = item_tax_template + child_item.item_tax_rate = get_item_tax_map( + doc=parent_doc, + tax_template=child_item.item_tax_template, + as_json=True, + ) + + +def add_taxes_from_tax_template(child_item, parent_doc, db_insert: bool = True) -> None: + add_taxes_from_item_tax_template = frappe.get_single_value( + "Accounts Settings", "add_taxes_from_item_tax_template" + ) + + if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template: + tax_map = json.loads(child_item.get("item_tax_rate")) + for tax_type, tax_rate in tax_map.items(): + if tax_rate == NOT_APPLICABLE_TAX: + continue + + tax_rate = flt(tax_rate) + taxes = parent_doc.get("taxes") or [] + found = any(tax.account_head == tax_type for tax in taxes) + if not found: + tax_row = parent_doc.append("taxes", {}) + tax_row.update( + { + "description": str(tax_type).split(" - ")[0], + "charge_type": "On Net Total", + "account_head": tax_type, + "rate": tax_rate, + "set_by_item_tax_template": 1, + } + ) + if parent_doc.doctype == "Purchase Order": + tax_row.update({"category": "Total", "add_deduct_tax": "Add"}) + if db_insert: + tax_row.db_insert() + + +def merge_taxes(source_doc, target_doc) -> None: + tax_map = {} + for tax in source_doc.get("taxes") or []: + found = False + for t in target_doc.get("taxes") or []: + if t.account_head == tax.account_head and t.cost_center == tax.cost_center: + t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount) + t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount) + tax_map[tax.name] = t + found = True + + if not found: + tax.charge_type = "Actual" + tax.included_in_print_rate = 0 + tax.dont_recompute_tax = 1 + tax.row_id = None + tax.idx = None + tax.tax_amount = tax.tax_amount_after_discount_amount + tax.base_tax_amount = tax.base_tax_amount_after_discount_amount + tax_map[tax.name] = target_doc.append("taxes", tax) + + item_map = {d._old_name: d for d in target_doc.get("items") if d.get("_old_name")} + + item_tax_details = target_doc.get("_item_wise_tax_details") or [] + for row in source_doc.get("item_wise_tax_details"): + item = item_map.get(row.item_row) + tax = tax_map.get(row.tax_row) + if not (item and tax): + continue + + item_tax_details.append( + frappe._dict( + item=item, + tax=tax, + amount=row.amount, + rate=row.rate, + taxable_amount=row.taxable_amount, + ) + ) + + target_doc._item_wise_tax_details = item_tax_details From ae01c21614bcd24cdb7ef92388f84b0093a1bdfd Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 31 Aug 2026 16:56:39 +0530 Subject: [PATCH 38/56] chore: resolve conflicts --- erpnext/accounts/services/taxes.py | 451 --------------------- erpnext/controllers/accounts_controller.py | 5 + 2 files changed, 5 insertions(+), 451 deletions(-) delete mode 100644 erpnext/accounts/services/taxes.py diff --git a/erpnext/accounts/services/taxes.py b/erpnext/accounts/services/taxes.py deleted file mode 100644 index ebccf91f0a3..00000000000 --- a/erpnext/accounts/services/taxes.py +++ /dev/null @@ -1,451 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Tax helpers: TaxService class for doc-mutating operations, free functions for stateless utilities.""" - -import json - -import frappe -from frappe import _, throw -from frappe.utils import cint, flt, parse_json - -import erpnext -from erpnext.stock.get_item_details import ( - NOT_APPLICABLE_TAX, - _get_item_tax_template, - _get_item_tax_template_from_item_group, - get_item_tax_map, -) - - -class TaxService: - def __init__(self, doc): - self.doc = doc - - def set_taxes(self) -> None: - doc = self.doc - if not doc.meta.get_field("taxes"): - return - - tax_master_doctype = doc.meta.get_field("taxes_and_charges").options - - if (doc.is_new() or self.is_pos_profile_changed()) and not doc.get("taxes"): - if doc.company and not doc.get("taxes_and_charges"): - doc.taxes_and_charges = frappe.db.get_value( - tax_master_doctype, {"is_default": 1, "company": doc.company} - ) - self.append_taxes_from_master(tax_master_doctype) - - def is_pos_profile_changed(self) -> bool: - doc = self.doc - if ( - doc.doctype == "Sales Invoice" - and doc.is_pos - and doc.pos_profile != frappe.db.get_value("Sales Invoice", doc.name, "pos_profile") - ): - return True - - def set_taxes_and_charges(self) -> None: - doc = self.doc - if doc.doctype == "Material Request": - return - - if doc.get("taxes") or doc.get("is_pos"): - return - - # set by the Opening Invoice Creation Tool, where the outstanding amount - # entered against a party is already inclusive of tax - if doc.flags.dont_auto_add_taxes: - return - - if frappe.get_single_value( - "Accounts Settings", "add_taxes_from_taxes_and_charges_template" - ) and hasattr(doc, "taxes_and_charges"): - if tax_master_doctype := doc.meta.get_field("taxes_and_charges").options: - self.append_taxes_from_master(tax_master_doctype) - - if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): - self.append_taxes_from_item_tax_template() - - def append_taxes_from_master(self, tax_master_doctype=None) -> None: - doc = self.doc - if doc.get("taxes_and_charges"): - if not tax_master_doctype: - tax_master_doctype = doc.meta.get_field("taxes_and_charges").options - doc.extend("taxes", get_taxes_and_charges(tax_master_doctype, doc.get("taxes_and_charges"))) - - def append_taxes_from_item_tax_template(self) -> None: - doc = self.doc - if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"): - return - - for row in doc.items: - item_tax_rate = row.get("item_tax_rate") - if not item_tax_rate: - continue - - if isinstance(item_tax_rate, str): - item_tax_rate = parse_json(item_tax_rate) - - for account_head, _rate in item_tax_rate.items(): - if not self.get_tax_row(account_head): - doc.append( - "taxes", - { - "charge_type": "On Net Total", - "account_head": account_head, - "rate": 0, - "description": account_head, - "set_by_item_tax_template": 1, - "category": "Total", - "add_deduct_tax": "Add", - }, - ) - - def get_tax_row(self, account_head): - for row in self.doc.taxes: - if row.account_head == account_head: - return row - - def set_other_charges(self) -> None: - self.doc.set("taxes", []) - self.set_taxes() - - def validate_enabled_taxes_and_charges(self) -> None: - doc = self.doc - taxes_and_charges_doctype = doc.meta.get_options("taxes_and_charges") - if doc.taxes_and_charges and frappe.get_cached_value( - taxes_and_charges_doctype, doc.taxes_and_charges, "disabled" - ): - frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, doc.taxes_and_charges)) - - def validate_tax_account_company(self) -> None: - doc = self.doc - for d in doc.get("taxes"): - if d.account_head: - tax_account_company = frappe.get_cached_value("Account", d.account_head, "company") - if tax_account_company != doc.company: - frappe.throw( - _("Row #{0}: Account {1} does not belong to company {2}").format( - d.idx, d.account_head, doc.company - ) - ) - - def get_tax_map(self) -> dict: - tax_map = {} - for tax in self.doc.get("taxes"): - tax_map.setdefault(tax.account_head, 0.0) - tax_map[tax.account_head] += tax.tax_amount - return tax_map - - def get_amount_and_base_amount(self, item, enable_discount_accounting): - doc = self.doc - amount = item.net_amount - base_amount = item.base_net_amount - - if ( - enable_discount_accounting - and doc.get("discount_amount") - and doc.get("additional_discount_account") - ): - if not hasattr(doc, "__has_distributed_discount_set"): - doc.__has_distributed_discount_set = any( - i.distributed_discount_amount for i in doc.get("items") - ) - - if not doc.__has_distributed_discount_set: - return item.amount, item.base_amount - - amount += item.distributed_discount_amount - base_amount += flt( - item.distributed_discount_amount * doc.get("conversion_rate"), - item.precision("distributed_discount_amount"), - ) - - return amount, base_amount - - def get_tax_amounts(self, tax, enable_discount_accounting): - doc = self.doc - amount = tax.tax_amount_after_discount_amount - base_amount = tax.base_tax_amount_after_discount_amount - - if ( - enable_discount_accounting - and doc.get("discount_amount") - and doc.get("additional_discount_account") - and doc.get("apply_discount_on") == "Grand Total" - ): - amount = tax.tax_amount - base_amount = tax.base_tax_amount - - return amount, base_amount - - -@frappe.whitelist() -def get_tax_rate(account_head: str) -> dict: - return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True) - - -@frappe.whitelist() -def get_default_taxes_and_charges( - master_doctype: str, tax_template: str | None = None, company: str | None = None -) -> dict | None: - if not company: - return {} - - if tax_template and company: - tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company") - if tax_template_company == company: - return - - default_tax = frappe.db.get_value(master_doctype, {"is_default": 1, "company": company}) - - return { - "taxes_and_charges": default_tax, - "taxes": get_taxes_and_charges(master_doctype, default_tax), - } - - -@frappe.whitelist() -def get_taxes_and_charges(master_doctype: str, master_name: str | None = None) -> list | None: - if not master_name: - return - from frappe.model import child_table_fields, default_fields - - tax_master = frappe.get_doc(master_doctype, master_name) - - taxes_and_charges = [] - for _i, tax in enumerate(tax_master.get("taxes")): - tax = tax.as_dict() - - for fieldname in default_fields + child_table_fields: - if fieldname in tax: - del tax[fieldname] - - taxes_and_charges.append(tax) - - return taxes_and_charges - - -def validate_conversion_rate( - currency: str, conversion_rate: float, conversion_rate_label: str, company: str -) -> None: - """Throw a validation error if conversion_rate is falsy.""" - company_currency = frappe.get_cached_value("Company", company, "default_currency") - - if not conversion_rate: - throw( - _("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format( - conversion_rate_label, currency, company_currency - ) - ) - - -def validate_taxes_and_charges(tax) -> None: - if tax.charge_type in ["Actual", "On Net Total", "On Paid Amount"] and tax.row_id: - frappe.throw( - _("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'") - ) - elif tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]: - if cint(tax.idx) == 1: - frappe.throw( - _( - "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" - ) - ) - elif not tax.row_id: - frappe.throw( - _("Please specify a valid Row ID for row {0} in table {1}").format(tax.idx, _(tax.doctype)) - ) - elif tax.row_id and cint(tax.row_id) >= cint(tax.idx): - frappe.throw( - _("Cannot refer row number greater than or equal to current row number for this Charge type") - ) - - if tax.charge_type == "Actual": - tax.rate = None - - -def validate_account_head(idx: int, account: str, company: str, context: str | None = None) -> None: - """Throw a ValidationError if the account belongs to a different company or is a group account.""" - if company != frappe.get_cached_value("Account", account, "company"): - frappe.throw( - _("Row {0}: The {3} Account {1} does not belong to the company {2}").format( - idx, frappe.bold(account), frappe.bold(company), context or "" - ), - title=_("Invalid Account"), - ) - - if frappe.get_cached_value("Account", account, "is_group"): - frappe.throw( - _( - "You selected the account group {1} as {2} Account in row {0}. Please select a single account." - ).format(idx, frappe.bold(account), context or ""), - title=_("Invalid Account"), - ) - - -def validate_cost_center(tax, doc) -> None: - if not tax.cost_center: - return - - company = frappe.get_cached_value("Cost Center", tax.cost_center, "company") - - if company != doc.company: - frappe.throw( - _("Row {0}: Cost Center {1} does not belong to Company {2}").format( - tax.idx, frappe.bold(tax.cost_center), frappe.bold(doc.company) - ), - title=_("Invalid Cost Center"), - ) - - -def validate_inclusive_tax(tax, doc) -> None: - def _on_previous_row_error(row_range): - throw( - _("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format( - tax.idx, row_range - ) - ) - - if cint(getattr(tax, "included_in_print_rate", None)): - if tax.charge_type == "Actual": - throw( - _("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format( - tax.idx - ) - ) - elif tax.charge_type == "On Previous Row Amount" and not cint( - doc.get("taxes")[cint(tax.row_id) - 1].included_in_print_rate - ): - _on_previous_row_error(tax.row_id) - elif tax.charge_type == "On Previous Row Total" and not all( - [cint(t.included_in_print_rate) for t in doc.get("taxes")[: cint(tax.row_id) - 1]] - ): - _on_previous_row_error("1 - %d" % (tax.row_id,)) - elif tax.get("category") == "Valuation": - frappe.throw(_("Valuation type charges can not be marked as Inclusive")) - - -def set_balance_in_account_currency( - gl_dict, - account_currency: str | None = None, - conversion_rate: float | None = None, - company_currency: str | None = None, -) -> None: - if (not conversion_rate) and (account_currency != company_currency): - frappe.throw( - _("Account: {0} with currency: {1} can not be selected").format(gl_dict.account, account_currency) - ) - - gl_dict["account_currency"] = account_currency - - if flt(gl_dict.debit) and not flt(gl_dict.debit_in_account_currency): - gl_dict.debit_in_account_currency = ( - gl_dict.debit if account_currency == company_currency else flt(gl_dict.debit / conversion_rate, 2) - ) - - if flt(gl_dict.credit) and not flt(gl_dict.credit_in_account_currency): - gl_dict.credit_in_account_currency = ( - gl_dict.credit - if account_currency == company_currency - else flt(gl_dict.credit / conversion_rate, 2) - ) - - -def set_child_tax_template_and_map(item, child_item, parent_doc) -> None: - ctx = frappe._dict( - { - "item_code": item.item_code, - "posting_date": parent_doc.transaction_date, - "tax_category": parent_doc.get("tax_category"), - "company": parent_doc.get("company"), - "base_net_rate": item.get("base_net_rate"), - } - ) - - item_tax_template = _get_item_tax_template(ctx, item.taxes) - - if not item_tax_template: - item_tax_template = _get_item_tax_template_from_item_group(ctx, item.item_group) - - child_item.item_tax_template = item_tax_template - child_item.item_tax_rate = get_item_tax_map( - doc=parent_doc, - tax_template=child_item.item_tax_template, - as_json=True, - ) - - -def add_taxes_from_tax_template(child_item, parent_doc, db_insert: bool = True) -> None: - add_taxes_from_item_tax_template = frappe.get_single_value( - "Accounts Settings", "add_taxes_from_item_tax_template" - ) - - if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template: - tax_map = json.loads(child_item.get("item_tax_rate")) - for tax_type, tax_rate in tax_map.items(): - if tax_rate == NOT_APPLICABLE_TAX: - continue - - tax_rate = flt(tax_rate) - taxes = parent_doc.get("taxes") or [] - found = any(tax.account_head == tax_type for tax in taxes) - if not found: - tax_row = parent_doc.append("taxes", {}) - tax_row.update( - { - "description": str(tax_type).split(" - ")[0], - "charge_type": "On Net Total", - "account_head": tax_type, - "rate": tax_rate, - "set_by_item_tax_template": 1, - } - ) - if parent_doc.doctype == "Purchase Order": - tax_row.update({"category": "Total", "add_deduct_tax": "Add"}) - if db_insert: - tax_row.db_insert() - - -def merge_taxes(source_doc, target_doc) -> None: - tax_map = {} - for tax in source_doc.get("taxes") or []: - found = False - for t in target_doc.get("taxes") or []: - if t.account_head == tax.account_head and t.cost_center == tax.cost_center: - t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount) - t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount) - tax_map[tax.name] = t - found = True - - if not found: - tax.charge_type = "Actual" - tax.included_in_print_rate = 0 - tax.dont_recompute_tax = 1 - tax.row_id = None - tax.idx = None - tax.tax_amount = tax.tax_amount_after_discount_amount - tax.base_tax_amount = tax.base_tax_amount_after_discount_amount - tax_map[tax.name] = target_doc.append("taxes", tax) - - item_map = {d._old_name: d for d in target_doc.get("items") if d.get("_old_name")} - - item_tax_details = target_doc.get("_item_wise_tax_details") or [] - for row in source_doc.get("item_wise_tax_details"): - item = item_map.get(row.item_row) - tax = tax_map.get(row.tax_row) - if not (item and tax): - continue - - item_tax_details.append( - frappe._dict( - item=item, - tax=tax, - amount=row.amount, - rate=row.rate, - taxable_amount=row.taxable_amount, - ) - ) - - target_doc._item_wise_tax_details = item_tax_details diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 74325b7c0bc..ba4786cddaa 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1267,6 +1267,11 @@ class AccountsController(TransactionBase): if self.get("taxes") or self.get("is_pos"): return + # set by the Opening Invoice Creation Tool, where the outstanding amount + # entered against a party is already inclusive of tax + if self.flags.dont_auto_add_taxes: + return + if frappe.get_single_value( "Accounts Settings", "add_taxes_from_taxes_and_charges_template" ) and hasattr(self, "taxes_and_charges"): From eed5969c28c33af0091038a48037a38efa164c60 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 31 Aug 2026 17:08:48 +0530 Subject: [PATCH 39/56] chore: resolve conflicts --- .../test_opening_invoice_creation_tool.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 6fca0ce6d70..fedc705d193 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -5,14 +5,11 @@ import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, today -<<<<<<< HEAD +from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( create_dimension, disable_dimension, ) -======= -from erpnext.accounts.doctype.account.test_account import create_account ->>>>>>> e08a166 (fix(taxes): skip tax addition for invoice created from opening invoice tool) from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( get_temporary_opening_account, ) From 4ad0180dffac9f008c8eadf93ecc684bbfcf05e8 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 31 Aug 2026 17:20:46 +0530 Subject: [PATCH 40/56] chore: resolve conflicts --- .../test_opening_invoice_creation_tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index fedc705d193..21735270281 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -2,7 +2,7 @@ # See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today from erpnext.accounts.doctype.account.test_account import create_account @@ -142,7 +142,7 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase): for invoice in invoices: self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC") - @ERPNextTestSuite.change_settings( + @change_settings( "Accounts Settings", {"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0}, ) From 28175a5c9db50565e16a0e9eec1cee45c824844a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:00:31 +0200 Subject: [PATCH 41/56] fix: do not map the same row twice in "Get Items From" (backport #58617) (#58618) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- .../purchase_invoice/purchase_invoice.py | 23 +++-- .../doctype/purchase_order/purchase_order.py | 29 ++++-- .../supplier_quotation/supplier_quotation.py | 6 +- erpnext/controllers/mapper.py | 25 ++++++ erpnext/controllers/tests/test_mapper.py | 90 +++++++++++++++++++ .../selling/doctype/quotation/quotation.py | 9 +- .../doctype/sales_order/sales_order.py | 21 +++-- .../doctype/delivery_note/delivery_note.py | 5 +- .../delivery_note/test_delivery_note.py | 19 ++++ .../material_request/material_request.py | 5 +- .../purchase_receipt/purchase_receipt.py | 5 +- 11 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 erpnext/controllers/mapper.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index e4cc191ffb0..26c77701140 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -40,6 +40,7 @@ from erpnext.assets.doctype.asset_category.asset_category import get_asset_categ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( get_item_account_wise_additional_cost, @@ -2201,6 +2202,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item") + + def received_and_mapped_qty(obj): + return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0)) + def post_parent_process(source_parent, target_parent): remove_items_with_zero_qty(target_parent) set_missing_values(source_parent, target_parent) @@ -2215,13 +2221,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): target_parent.run_method("calculate_taxes_and_totals") def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) - flt(obj.received_qty) - target.received_qty = flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) + pending_qty = flt(obj.qty) - received_and_mapped_qty(obj) + + target.qty = pending_qty + target.received_qty = pending_qty + target.stock_qty = pending_qty * flt(obj.conversion_factor) + target.amount = pending_qty * flt(obj.rate) + target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate) def select_item(d): filtered_items = args.get("filtered_children", []) @@ -2251,7 +2257,8 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): "wip_composite_asset": "wip_composite_asset", }, "postprocess": update_item, - "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc), + "condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty) + and select_item(doc), }, "Purchase Taxes and Charges": { "doctype": "Purchase Taxes and Charges", diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 651c781f4f6..171a89e826c 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -21,6 +21,7 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category from erpnext.accounts.party import get_party_account, get_party_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, ) @@ -737,13 +738,16 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 + mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_order_item") + def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) + received_qty = flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0)) + pending_qty = flt(obj.qty) - received_qty + + target.qty = flt(obj.qty) if is_unit_price_row(obj) else pending_qty + target.stock_qty = pending_qty * flt(obj.conversion_factor) + target.amount = pending_qty * flt(obj.rate) + target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate) def select_item(d): filtered_items = args.get("filtered_children", []) @@ -775,7 +779,9 @@ def make_purchase_receipt(source_name, target_doc=None, args=None): }, "postprocess": update_item, "condition": lambda doc: ( - True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) + doc.name not in mapped_qty_by_item + if is_unit_price_row(doc) + else abs(doc.received_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty) ) and doc.delivered_by_supplier != 1 and select_item(doc), @@ -837,9 +843,13 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions ) return query.run(pluck="qty")[0] or 0 + mapped_qty_by_item = get_qty_already_mapped(target_doc, "po_detail") + + def get_billed_and_mapped_qty(po_item_name): + return flt(get_billed_qty(po_item_name)) + flt(mapped_qty_by_item.get(po_item_name, 0)) + def update_item(obj, target, source_parent): - billed_qty = flt(get_billed_qty(obj.name)) - target.qty = flt(obj.qty) - billed_qty + target.qty = flt(obj.qty) - get_billed_and_mapped_qty(obj.name) item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) @@ -882,6 +892,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions or abs(doc.billed_amt) < abs(doc.amount) or doc.qty > flt(get_billed_qty(doc.name)) ) + and (doc.name not in mapped_qty_by_item or doc.qty > get_billed_and_mapped_qty(doc.name)) and select_item(doc), }, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 790e89f8c0e..c5282f3866a 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -11,6 +11,7 @@ from frappe.utils import flt, getdate, nowdate from erpnext.buying.utils import validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -243,6 +244,8 @@ def make_purchase_order(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + mapped_items = get_qty_already_mapped(target_doc, "supplier_quotation_item") + def set_missing_values(source, target): target.run_method("set_missing_values") target.run_method("get_schedule_dates") @@ -277,7 +280,8 @@ def make_purchase_order(source_name, target_doc=None, args=None): ["sales_order", "sales_order"], ], "postprocess": update_item, - "condition": select_item, + # no qty tracking between the two, so dedupe on the row reference alone + "condition": lambda d: d.name not in mapped_items and select_item(d), }, "Purchase Taxes and Charges": { "doctype": "Purchase Taxes and Charges", diff --git a/erpnext/controllers/mapper.py b/erpnext/controllers/mapper.py new file mode 100644 index 00000000000..4b27c406f90 --- /dev/null +++ b/erpnext/controllers/mapper.py @@ -0,0 +1,25 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe.utils import flt + + +def get_qty_already_mapped(target_doc, ref_field: str, qty_field: str = "qty") -> frappe._dict: + """Return a map: {source row name: qty} of rows already mapped into the target document. + + "Get Items From" passes the in-progress (unsaved) document back as `target_doc`. Its rows + are invisible to the pending-qty queries in the mappers, which only count submitted + documents -- so without this, selecting the same source document twice maps every row + again. Rows are keyed by `ref_field` (dn_detail, so_detail, ...), and a row is present in + the map even when its qty is 0, so mappers without qty tracking can dedupe on presence. + """ + if isinstance(target_doc, str): + target_doc = frappe.parse_json(target_doc) + + qty_map = frappe._dict() + for row in (target_doc and target_doc.get("items")) or []: + if ref := row.get(ref_field): + qty_map[ref] = qty_map.get(ref, 0) + flt(row.get(qty_field)) + + return qty_map diff --git a/erpnext/controllers/tests/test_mapper.py b/erpnext/controllers/tests/test_mapper.py index 9dbcf6cafd2..dbc35da26a7 100644 --- a/erpnext/controllers/tests/test_mapper.py +++ b/erpnext/controllers/tests/test_mapper.py @@ -28,6 +28,96 @@ class TestMapper(unittest.TestCase): src_items = item_list_1 + item_list_2 + item_list_3 self.assertEqual(set(d for d in src_items), set(d.item_code for d in updated_so.items)) + def test_get_items_from_is_idempotent(self): + """Selecting the same source document twice must not duplicate rows in the target. + + "Get Items From" hands the in-progress document back to the mapper as `target_doc`. + Its rows are unsaved, so the mappers' pending-qty queries (submitted documents only) + cannot see them -- every mapper has to discount them explicitly. + """ + for label, make_source, method in self.idempotency_cases(): + with self.subTest(label): + source = make_source() + target = frappe.get_attr(method)(source.name) + mapped_rows = len(target.items) + self.assertTrue(mapped_rows, f"{label}: nothing was mapped") + + target = frappe.get_attr(method)(source.name, target) + self.assertEqual(len(target.items), mapped_rows, f"{label}: rows were duplicated") + + def idempotency_cases(self): + """(label, source factory, mapper method) for every "Get Items From" button. + + Quotation -> Sales Invoice is absent: Sales Invoice Item keeps no reference to the + Quotation row, so there is nothing to deduplicate on. + """ + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.buying.doctype.supplier_quotation.test_supplier_quotation import ( + test_records as supplier_quotation_records, + ) + from erpnext.selling.doctype.quotation.test_quotation import make_quotation + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.material_request.test_material_request import make_material_request + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + def make_supplier_quotation(): + return frappe.copy_doc(supplier_quotation_records[0]).submit() + + return [ + ( + "Quotation -> Sales Order", + lambda: make_quotation(), + "erpnext.selling.doctype.quotation.quotation.make_sales_order", + ), + ( + "Sales Order -> Sales Invoice", + lambda: make_sales_order(), + "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice", + ), + ( + "Sales Order -> Delivery Note", + lambda: make_sales_order(), + "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", + ), + ( + "Delivery Note -> Sales Invoice", + lambda: create_delivery_note(), + "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", + ), + ( + "Material Request -> Purchase Order", + lambda: make_material_request(), + "erpnext.stock.doctype.material_request.material_request.make_purchase_order", + ), + ( + "Supplier Quotation -> Purchase Order", + make_supplier_quotation, + "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order", + ), + ( + "Purchase Order -> Purchase Receipt", + lambda: create_purchase_order(), + "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt", + ), + ( + "Purchase Order -> Purchase Invoice", + lambda: create_purchase_order(), + "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice", + ), + ( + "Purchase Receipt -> Purchase Invoice", + lambda: make_purchase_receipt(), + "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice", + ), + ( + "Purchase Invoice -> Purchase Receipt", + lambda: make_purchase_invoice(), + "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt", + ), + ] + def make_quotation(self, item_list, customer): qtn = frappe.get_doc( { diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index e25ba017c00..856ba33a486 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -9,6 +9,7 @@ from frappe import _ from frappe.model.mapper import get_mapped_doc from frappe.utils import cint, flt, getdate, nowdate +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -391,6 +392,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar customer = _make_customer(source_name, ignore_permissions) ordered_items = get_ordered_items(source_name) + mapped_items = get_qty_already_mapped(target_doc, "quotation_item", "stock_qty") + for name, stock_qty in mapped_items.items(): + ordered_items[name] = flt(ordered_items.get(name)) + stock_qty selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] @@ -444,7 +448,10 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty 3. If no selections: Simple row: Map if adequate qty """ - if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)): + if not ( + (item.stock_qty > ordered_items.get(item.name, 0.0)) + or (is_unit_price_row(item) and item.name not in mapped_items) + ): return False if not selected_rows: diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 1b7b5dce5fb..e664f69e14b 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -22,6 +22,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( validate_inter_company_party, ) from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, @@ -977,6 +978,8 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): if kwargs.for_reserved_stock: sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) + mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail") + mapper = { "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True}, @@ -1031,15 +1034,17 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): return False return ( - (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc) + (abs(doc.delivered_qty) + abs(mapped_qty_by_item.get(doc.name, 0)) < abs(doc.qty)) + or (is_unit_price_row(doc) and doc.name not in mapped_qty_by_item) ) and doc.delivered_by_supplier != 1 + def remaining_qty(source): + return flt(source.qty) - flt(source.delivered_qty) - flt(mapped_qty_by_item.get(source.name, 0)) + def update_item(source, target, source_parent): - target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) - target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate) - target.qty = ( - flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty) - ) + target.base_amount = remaining_qty(source) * flt(source.base_rate) + target.amount = remaining_qty(source) * flt(source.rate) + target.qty = flt(source.qty) if is_unit_price_row(source) else remaining_qty(source) item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) @@ -1137,6 +1142,7 @@ def make_sales_invoice( has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") billed_qty_by_item = None pending_qty_by_item = {} + mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail") def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 @@ -1165,6 +1171,7 @@ def make_sales_invoice( if source.qty and source.billed_amt: billable_qty -= get_billed_qty_by_item().get(source.name, 0) + billable_qty -= mapped_qty_by_item.get(source.name, 0) pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0) return pending_qty_by_item[source.name] @@ -1244,7 +1251,7 @@ def make_sales_invoice( "postprocess": update_item, "condition": lambda doc: select_item(doc) and ( - True + doc.name not in mapped_qty_by_item if is_unit_price_row(doc) else ( doc.qty diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 49aa06ddd79..7ac24e95825 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -18,6 +18,7 @@ from frappe.utils import cint, flt from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.controllers.selling_controller import SellingController from erpnext.stock.stock_ledger import validate_reserved_stock @@ -936,6 +937,8 @@ def make_sales_invoice( to_make_invoice_qty_map = {} returned_qty_map = get_returned_qty_map(source_name) invoiced_qty_map = get_invoiced_qty_map(source_name) + for ref, qty in get_qty_already_mapped(target_doc, "dn_detail").items(): + invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty def set_missing_values(source, target): target.run_method("set_missing_values") @@ -1008,7 +1011,7 @@ def make_sales_invoice( "postprocess": update_item, "filter": lambda d: get_pending_qty(d) <= 0 if not doc.get("is_return") - else get_pending_qty(d) > 0, + else get_pending_qty(d) >= 0, "condition": select_item, }, "Sales Taxes and Charges": { diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 25c86fee7c9..56622562d7a 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1091,6 +1091,25 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn2.per_billed, 100) self.assertEqual(dn2.status, "Completed") + def test_mapping_same_dn_twice_is_idempotent(self): + # "Get Items From > Delivery Note" passes the in-progress invoice back as target_doc. + # Selecting the same DN again must not append a second row for the same dn_detail. + dn = create_delivery_note(qty=5) + + si = make_sales_invoice(dn.name) + self.assertEqual(len(si.items), 1) + self.assertEqual(si.items[0].qty, 5) + + si = make_sales_invoice(dn.name, target_doc=si) + self.assertEqual(len(si.items), 1) + self.assertEqual(si.items[0].qty, 5) + + # a partly reduced draft row still tops up to the delivered qty + si.items[0].qty = 2 + si = make_sales_invoice(dn.name, target_doc=si) + self.assertEqual(len(si.items), 2) + self.assertEqual([d.qty for d in si.items], [2, 3]) + @change_settings("Accounts Settings", {"delete_linked_ledger_entries": True}) def test_sales_invoice_qty_after_return(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 52c1ec2cffd..4bf88564df7 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -17,6 +17,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_se from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.manufacturing.doctype.work_order.work_order import get_item_details from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.get_item_details import get_price_list_rate_for @@ -499,6 +500,8 @@ def make_purchase_order(source_name, target_doc=None, args=None): if isinstance(args, str): args = json.loads(args) + mapped_qty_by_item = get_qty_already_mapped(target_doc, "material_request_item", "stock_qty") + def postprocess(source, target_doc): if frappe.flags.args and frappe.flags.args.default_supplier: # items only for given default supplier @@ -515,7 +518,7 @@ def make_purchase_order(source_name, target_doc=None, args=None): filtered_items = args.get("filtered_children", []) child_filter = d.name in filtered_items if filtered_items else True - qty = d.ordered_qty or d.received_qty + qty = (d.ordered_qty or d.received_qty) + flt(mapped_qty_by_item.get(d.name, 0)) return qty < d.stock_qty and child_filter diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index f7df8ecd9f3..00145ffa47c 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -18,6 +18,7 @@ from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accoun from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import merge_taxes from erpnext.controllers.buying_controller import BuyingController +from erpnext.controllers.mapper import get_qty_already_mapped from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction from erpnext.stock.serial_batch_bundle import ( SerialBatchCreation, @@ -1350,6 +1351,8 @@ def make_purchase_invoice(source_name, target_doc=None, args=None): doc = frappe.get_doc("Purchase Receipt", source_name) returned_qty_map = get_returned_qty_map(source_name) invoiced_qty_map = get_invoiced_qty_map(source_name) + for ref, qty in get_qty_already_mapped(target_doc, "pr_detail").items(): + invoiced_qty_map[ref] = invoiced_qty_map.get(ref, 0) + qty def set_missing_values(source, target): if len(target.get("items")) == 0: @@ -1434,7 +1437,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None): }, "postprocess": update_item, "filter": lambda d: ( - get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0 + get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] >= 0 ), "condition": select_item, }, From c5bc5f9c31af5d2e90ad43ad6e13c5964efce0f7 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Tue, 1 Sep 2026 00:09:27 +0530 Subject: [PATCH 42/56] fix: validate POS Settings invoice and search fields on the server (backport #58611) (#58612) --- .../pos_search_fields/pos_search_fields.json | 12 +- .../doctype/pos_settings/pos_settings.js | 96 ++++--------- .../doctype/pos_settings/pos_settings.py | 134 +++++++++++++++++- .../doctype/pos_settings/test_pos_settings.py | 115 ++++++++++++++- erpnext/patches.txt | 1 + .../append_fieldname_to_pos_search_fields.py | 20 +++ 6 files changed, 302 insertions(+), 76 deletions(-) create mode 100644 erpnext/patches/v16_0/append_fieldname_to_pos_search_fields.py diff --git a/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json b/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json index a627f5b5be4..7299039cd26 100644 --- a/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json +++ b/erpnext/accounts/doctype/pos_search_fields/pos_search_fields.json @@ -12,20 +12,22 @@ { "fieldname": "fieldname", "fieldtype": "Data", - "hidden": 1, - "label": "Fieldname" + "in_list_view": 1, + "label": "Fieldname", + "read_only": 1 }, { "fieldname": "field", "fieldtype": "Select", "in_list_view": 1, - "label": "Field" + "label": "Field", + "reqd": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-04-21 11:12:54.632093", + "modified": "2026-08-31 20:41:12.000000", "modified_by": "Administrator", "module": "Accounts", "name": "POS Search Fields", @@ -34,4 +36,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.js b/erpnext/accounts/doctype/pos_settings/pos_settings.js index a2e5a57e5f7..4175a09be7a 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.js +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.js @@ -1,40 +1,9 @@ // Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -let search_fields_datatypes = [ - "Data", - "Link", - "Dynamic Link", - "Long Text", - "Select", - "Small Text", - "Text", - "Text Editor", -]; -let do_not_include_fields = [ - "naming_series", - "item_code", - "item_name", - "stock_uom", - "asset_naming_series", - "default_material_request_type", - "valuation_method", - "warranty_period", - "weight_uom", - "batch_number_series", - "serial_no_series", - "purchase_uom", - "customs_tariff_number", - "sales_uom", - "deferred_revenue_account", - "deferred_expense_account", - "quality_inspection_template", - "route", - "slideshow", - "website_image_alt", - "thumbnail", - "web_long_description", -]; +function is_valid_invoice_field(df) { + return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button"; +} frappe.ui.form.on("POS Settings", { onload: function (frm) { @@ -44,57 +13,46 @@ frappe.ui.form.on("POS Settings", { get_invoice_fields: function (frm) { frappe.model.with_doctype("POS Invoice", () => { - var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) { - if ( - frappe.model.no_value_type.indexOf(d.fieldtype) === -1 || - ["Button"].includes(d.fieldtype) - ) { - return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname }; - } else { - return null; - } - }); + const fields = frappe.get_doc("DocType", "POS Invoice").fields.filter(is_valid_invoice_field); frm.fields_dict.invoice_fields.grid.update_docfield_property( "fieldname", "options", - [""].concat(fields) + [""].concat( + fields.map((df) => { + return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname }; + }) + ) ); }); }, add_search_options: function (frm) { - frappe.model.with_doctype("Item", () => { - var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) { - if ( - search_fields_datatypes.includes(d.fieldtype) && - !do_not_include_fields.includes(d.fieldname) - ) { - return [d.label]; - } else { - return null; - } - }); + frappe.call({ + method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options", + callback: ({ message }) => { + const fields = message || []; - fields.unshift(""); - frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields); + frm.searchable_item_fields = Object.fromEntries( + fields.map((df) => [df.option, df.fieldname]) + ); + + frm.fields_dict.pos_search_fields.grid.update_docfield_property( + "field", + "options", + [""].concat(fields.map((df) => df.option)) + ); + }, }); }, }); frappe.ui.form.on("POS Search Fields", { field: function (frm, doctype, name) { - var doc = frappe.get_doc(doctype, name); - var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) { - if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) { - return d; - } else { - return null; - } - })[0]; + const doc = frappe.get_doc(doctype, name); - doc.fieldname = df.fieldname; - frm.refresh_field("fields"); + doc.fieldname = frm.searchable_item_fields?.[doc.field] || ""; + frm.refresh_field("pos_search_fields"); }, }); @@ -110,6 +68,6 @@ frappe.ui.form.on("POS Field", { doc.options = df.options; doc.fieldtype = df.fieldtype; doc.default_value = df.default; - frm.refresh_field("fields"); + frm.refresh_field("invoice_fields"); }, }); diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.py b/erpnext/accounts/doctype/pos_settings/pos_settings.py index 1e298a68f4b..e970e636841 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.py +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.py @@ -1,9 +1,50 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from collections import Counter +import frappe +from frappe import _ +from frappe.model import no_value_fields from frappe.model.document import Document +SEARCH_FIELD_TYPES = ( + "Data", + "Link", + "Dynamic Link", + "Long Text", + "Select", + "Small Text", + "Text", + "Text Editor", +) + +# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by +DO_NOT_INCLUDE_FIELDS = ( + "naming_series", + "item_code", + "item_name", + "stock_uom", + "asset_naming_series", + "default_material_request_type", + "valuation_method", + "warranty_period", + "weight_uom", + "batch_number_series", + "serial_no_series", + "purchase_uom", + "customs_tariff_number", + "sales_uom", + "deferred_revenue_account", + "deferred_expense_account", + "quality_inspection_template", + "route", + "slideshow", + "website_image_alt", + "thumbnail", + "web_long_description", +) + class POSSettings(Document): # begin: auto-generated types @@ -22,4 +63,95 @@ class POSSettings(Document): # end: auto-generated types def validate(self): - pass + self.validate_duplicate_invoice_fields() + self.validate_invoice_fields() + self.validate_duplicate_pos_search_fields() + self.validate_pos_search_fields() + + def validate_duplicate_invoice_fields(self): + fieldnames = [field.fieldname for field in self.invoice_fields] + + for fieldname, count in Counter(fieldnames).items(): + if count > 1: + frappe.throw( + title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(fieldname) + ) + + def validate_invoice_fields(self): + # the POS screen only ever creates a POS Invoice + meta = frappe.get_meta("POS Invoice") + + for field in self.invoice_fields: + df = meta.get_field(field.fieldname) + + if not df or not is_valid_invoice_field(df): + frappe.throw( + title=_("Invalid POS Field"), + msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format( + field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_("POS Invoice")) + ), + ) + + # read only in the form, so keep them in sync with the invoice + field.label = df.label + field.fieldtype = df.fieldtype + field.options = df.options + + def validate_duplicate_pos_search_fields(self): + fieldnames = [field.fieldname for field in self.pos_search_fields] + + for fieldname, count in Counter(fieldnames).items(): + if count > 1: + frappe.throw( + title=_("Duplicate POS Search Fields"), + msg=_("'{0}' has been already added.").format(fieldname), + ) + + def validate_pos_search_fields(self): + searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()} + + for field in self.pos_search_fields: + df = searchable_fields.get(field.fieldname) + + if not df: + frappe.throw( + title=_("Invalid POS Search Field"), + msg=_("Row #{0}: '{1}' cannot be used to search items.").format( + field.idx, frappe.bold(field.fieldname or "") + ), + ) + + if field.field != get_search_field_option(df): + frappe.throw( + title=_("Invalid POS Search Field"), + msg=_("Row #{0}: '{1}' does not match {2}.").format( + field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname) + ), + ) + + +def is_valid_invoice_field(df): + return df.fieldtype not in no_value_fields or df.fieldtype == "Button" + + +def get_searchable_item_fields(): + return [ + df + for df in frappe.get_meta("Item").fields + if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS + ] + + +def get_search_field_option(df): + # the fieldname keeps the option unique, two Item fields can share a label + return f"{df.label} ({df.fieldname})" + + +@frappe.whitelist() +def get_pos_search_field_options(): + frappe.has_permission("POS Settings", throw=True) + + return [ + {"option": get_search_field_option(df), "fieldname": df.fieldname} + for df in get_searchable_item_fields() + ] diff --git a/erpnext/accounts/doctype/pos_settings/test_pos_settings.py b/erpnext/accounts/doctype/pos_settings/test_pos_settings.py index 630b30564ad..f58dbc3ccdc 100644 --- a/erpnext/accounts/doctype/pos_settings/test_pos_settings.py +++ b/erpnext/accounts/doctype/pos_settings/test_pos_settings.py @@ -3,6 +3,119 @@ import unittest +import frappe + +from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname + class TestPOSSettings(unittest.TestCase): - pass + def setUp(self): + self.settings = frappe.get_single("POS Settings") + self.settings.invoice_fields = [] + self.settings.pos_search_fields = [] + + def tearDown(self): + frappe.db.rollback() + + def assertInvalid(self, message): + with self.assertRaises(frappe.ValidationError) as context: + self.settings.save() + + self.assertIn(message, str(context.exception)) + + def test_duplicate_invoice_field_is_not_allowed(self): + self.settings.append("invoice_fields", {"fieldname": "customer"}) + self.settings.append("invoice_fields", {"fieldname": "customer"}) + self.assertInvalid("'customer' has been already added.") + + def test_unknown_invoice_field_is_not_allowed(self): + self.settings.append("invoice_fields", {"fieldname": "not_a_field"}) + self.assertInvalid("is not a valid field of") + + def test_layout_invoice_field_is_not_allowed(self): + self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"}) + self.assertInvalid("is not a valid field of") + + def test_invoice_field_properties_are_set_from_the_invoice(self): + self.settings.append( + "invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"} + ) + self.settings.save() + + field = self.settings.invoice_fields[0] + self.assertEqual(field.label, "Customer") + self.assertEqual(field.fieldtype, "Link") + self.assertEqual(field.options, "Customer") + + def test_searchable_item_field_is_allowed(self): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + self.settings.save() + + self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description") + + def test_excluded_search_field_is_not_allowed(self): + self.settings.append( + "pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"} + ) + self.assertInvalid("cannot be used to search items") + + def test_search_field_of_unsearchable_type_is_not_allowed(self): + # maintain stock is a Check field + self.settings.append( + "pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"} + ) + self.assertInvalid("cannot be used to search items") + + def test_unknown_search_field_is_not_allowed(self): + self.settings.append( + "pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"} + ) + self.assertInvalid("cannot be used to search items") + + def test_search_field_without_a_fieldname_is_not_allowed(self): + # the form fills the fieldname in, it cannot be picked on its own + self.settings.append("pos_search_fields", {"field": "Description (description)"}) + self.assertInvalid("cannot be used to search items") + + def test_search_field_option_must_match_its_fieldname(self): + self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"}) + self.assertInvalid("does not match") + + def test_bare_label_is_not_accepted_as_a_search_field(self): + # the stored option carries the fieldname, the patch backfills older rows + self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"}) + self.assertInvalid("does not match") + + def test_duplicate_search_fields_are_not_allowed(self): + for _ in range(2): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + + self.assertInvalid("has been already added") + + def test_patch_appends_the_fieldname_to_a_legacy_search_field(self): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + self.settings.save() + + row = self.settings.pos_search_fields[0].name + frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False) + + append_fieldname() + + self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)") + + def test_patch_leaves_an_already_migrated_search_field_alone(self): + self.settings.append( + "pos_search_fields", {"field": "Description (description)", "fieldname": "description"} + ) + self.settings.save() + + append_fieldname() + + row = self.settings.pos_search_fields[0].name + self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)") diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5535458d3ed..9971b2e7bde 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -449,3 +449,4 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.repair_work_order_material_transfer erpnext.patches.v16_0.remove_frappe_crm_custom_fields +erpnext.patches.v16_0.append_fieldname_to_pos_search_fields diff --git a/erpnext/patches/v16_0/append_fieldname_to_pos_search_fields.py b/erpnext/patches/v16_0/append_fieldname_to_pos_search_fields.py new file mode 100644 index 00000000000..686d94dbb11 --- /dev/null +++ b/erpnext/patches/v16_0/append_fieldname_to_pos_search_fields.py @@ -0,0 +1,20 @@ +import frappe + + +def execute(): + rows = frappe.get_all( + "POS Search Fields", filters={"parent": "POS Settings"}, fields=["name", "field", "fieldname"] + ) + + for row in rows: + # the row used to hold the label alone, it now holds "Label (fieldname)" + if not (row.field and row.fieldname) or row.field.endswith(f"({row.fieldname})"): + continue + + frappe.db.set_value( + "POS Search Fields", + row.name, + "field", + f"{row.field} ({row.fieldname})", + update_modified=False, + ) From fa903f40fd62e01a244733c29b4234f82d4598cb Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:39:12 +0000 Subject: [PATCH 43/56] fix(bank_statement_import): add missing permission check on multiple whitelisted methods (backport #58221) (#58223) Co-authored-by: Diptanil Saha --- .../bank_statement_import.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index db68b6d894d..2cd076c2286 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -105,25 +105,30 @@ class BankStatementImport(DataImport): @frappe.whitelist() def get_preview_from_template(data_import, import_file=None, google_sheets_url=None): - return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template( - import_file, google_sheets_url - ) + bsi = frappe.get_doc("Bank Statement Import", data_import) + bsi.check_permission() + return bsi.get_preview_from_template(import_file, google_sheets_url) @frappe.whitelist() def form_start_import(data_import): - return frappe.get_doc("Bank Statement Import", data_import).start_import() + bsi = frappe.get_doc("Bank Statement Import", data_import) + bsi.check_permission("write") + return bsi.start_import() @frappe.whitelist() def download_errored_template(data_import_name): data_import = frappe.get_doc("Bank Statement Import", data_import_name) + data_import.check_permission() data_import.export_errored_rows() @frappe.whitelist() def download_import_log(data_import_name): - return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log() + bsi = frappe.get_doc("Bank Statement Import", data_import_name) + bsi.check_permission() + return bsi.download_import_log() def parse_data_from_template(raw_data): From d1b615b8511bc894f45001498d1a15175e210033 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:49:07 +0000 Subject: [PATCH 44/56] fix(crm): add missing permission validation on `get_contract_template` (backport #58621) (#58622) Co-authored-by: Diptanil Saha --- erpnext/crm/doctype/contract_template/contract_template.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/crm/doctype/contract_template/contract_template.py b/erpnext/crm/doctype/contract_template/contract_template.py index d0bf738d79d..587e2ce4bcd 100644 --- a/erpnext/crm/doctype/contract_template/contract_template.py +++ b/erpnext/crm/doctype/contract_template/contract_template.py @@ -39,6 +39,7 @@ def get_contract_template(template_name, doc): doc = json.loads(doc) contract_template = frappe.get_doc("Contract Template", template_name) + contract_template.check_permission() contract_terms = None if contract_template.contract_terms: From 307ffcd5c3722c7b4c5035f7d34282180a4d2c33 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:54:08 +0200 Subject: [PATCH 45/56] fix: translate label in party validation (backport #58624) (#58625) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/public/js/utils/party.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 1652a849850..66fd0eeec8e 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -81,7 +81,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) { if ( !erpnext.utils.validate_mandatory( frm, - "Posting / Transaction Date", + __("Posting / Transaction Date"), args.posting_date, args.party_type == "Customer" ? "customer" : "supplier" ) @@ -92,7 +92,7 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) { if ( !erpnext.utils.validate_mandatory( frm, - "Company", + __("Company"), frm.doc.company, args.party_type == "Customer" ? "customer" : "supplier" ) @@ -177,7 +177,7 @@ erpnext.utils.set_taxes_from_address = function ( if ( !erpnext.utils.validate_mandatory( frm, - "Lead / Customer / Supplier", + __("Lead / Customer / Supplier"), frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name, triggered_from_field ) @@ -188,7 +188,7 @@ erpnext.utils.set_taxes_from_address = function ( if ( !erpnext.utils.validate_mandatory( frm, - "Posting / Transaction Date", + __("Posting / Transaction Date"), frm.doc.posting_date || frm.doc.transaction_date, triggered_from_field ) @@ -220,14 +220,14 @@ erpnext.utils.set_taxes_from_address = function ( erpnext.utils.set_taxes = function (frm, triggered_from_field) { if (frappe.meta.get_docfield(frm.doc.doctype, "taxes")) { - if (!erpnext.utils.validate_mandatory(frm, "Company", frm.doc.company, triggered_from_field)) { + if (!erpnext.utils.validate_mandatory(frm, __("Company"), frm.doc.company, triggered_from_field)) { return; } if ( !erpnext.utils.validate_mandatory( frm, - "Lead / Customer / Supplier", + __("Lead / Customer / Supplier"), frm.doc.customer || frm.doc.supplier || frm.doc.lead || frm.doc.party_name, triggered_from_field ) @@ -238,7 +238,7 @@ erpnext.utils.set_taxes = function (frm, triggered_from_field) { if ( !erpnext.utils.validate_mandatory( frm, - "Posting / Transaction Date", + __("Posting / Transaction Date"), frm.doc.posting_date || frm.doc.transaction_date, triggered_from_field ) From 0cef90d079fe6e2d7febf4c8ee53f055219ed67e Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Mon, 31 Aug 2026 11:09:09 +0530 Subject: [PATCH 46/56] fix(sms_settings): add patch to pre-fill roles into SMS Settings Roles Table (cherry picked from commit 3501beb2bd40a56e50d12649697f7cafc6ffc46f) # Conflicts: # erpnext/patches.txt --- erpnext/patches.txt | 5 +++ .../add_transaction_roles_to_sms_settings.py | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 erpnext/patches/v16_0/add_transaction_roles_to_sms_settings.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9971b2e7bde..6161957523f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -449,4 +449,9 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.repair_work_order_material_transfer erpnext.patches.v16_0.remove_frappe_crm_custom_fields +<<<<<<< HEAD erpnext.patches.v16_0.append_fieldname_to_pos_search_fields +======= +erpnext.patches.v16_0.add_batch_split_stock_entry_type +erpnext.patches.v16_0.add_transaction_roles_to_sms_settings +>>>>>>> 3501beb (fix(sms_settings): add patch to pre-fill roles into SMS Settings Roles Table) diff --git a/erpnext/patches/v16_0/add_transaction_roles_to_sms_settings.py b/erpnext/patches/v16_0/add_transaction_roles_to_sms_settings.py new file mode 100644 index 00000000000..c287c838091 --- /dev/null +++ b/erpnext/patches/v16_0/add_transaction_roles_to_sms_settings.py @@ -0,0 +1,39 @@ +import frappe +from frappe import _ + +STANDARD_TRANSACTION_ROLES = [ + "Sales User", + "Sales Manager", + "Purchase User", + "Purchase Manager", + "Stock User", + "Stock Manager", + "Accounts User", + "Accounts Manager", +] + + +def execute(): + """Seed SMS Settings.allowed_roles with ERPNext's standard transaction roles.""" + frappe.reload_doctype("SMS Settings") + + if not frappe.get_meta("SMS Settings").has_field("allowed_roles"): + frappe.throw( + _( + "SMS Settings.allowed_roles not found. Update the Frappe Framework app to a " + "version that includes this field, then re-run bench migrate." + ) + ) + + sms_settings = frappe.get_single("SMS Settings") + existing_roles = {d.role for d in sms_settings.get("allowed_roles")} + + added = False + for role in STANDARD_TRANSACTION_ROLES: + if role not in existing_roles and frappe.db.exists("Role", role): + sms_settings.append("allowed_roles", {"role": role}) + added = True + + if added: + sms_settings.flags.ignore_mandatory = True + sms_settings.save() From 2f2a9301d8fe4392f2883b9a84abfba7af27972c Mon Sep 17 00:00:00 2001 From: AarDG10 Date: Tue, 1 Sep 2026 12:55:27 +0530 Subject: [PATCH 47/56] chore: resolve conflicts --- erpnext/patches.txt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6161957523f..d5a7ff156d3 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -449,9 +449,5 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.repair_work_order_material_transfer erpnext.patches.v16_0.remove_frappe_crm_custom_fields -<<<<<<< HEAD erpnext.patches.v16_0.append_fieldname_to_pos_search_fields -======= -erpnext.patches.v16_0.add_batch_split_stock_entry_type -erpnext.patches.v16_0.add_transaction_roles_to_sms_settings ->>>>>>> 3501beb (fix(sms_settings): add patch to pre-fill roles into SMS Settings Roles Table) +erpnext.patches.v16_0.add_transaction_roles_to_sms_settings \ No newline at end of file From 52899820596a0e588d10b9c35949c599f8946b4e Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Tue, 1 Sep 2026 15:17:57 +0530 Subject: [PATCH 48/56] fix(accounts)!: add permission checks on payment entry whitelisted methods (#58643) --- erpnext/accounts/doctype/dunning/dunning.js | 6 ++++-- erpnext/accounts/doctype/journal_entry/journal_entry.py | 5 +++++ erpnext/accounts/doctype/payment_entry/payment_entry.py | 5 ++++- erpnext/public/js/controllers/transaction.js | 6 ++++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/dunning.js b/erpnext/accounts/doctype/dunning/dunning.js index c9955c7e359..10d6043605b 100644 --- a/erpnext/accounts/doctype/dunning/dunning.js +++ b/erpnext/accounts/doctype/dunning/dunning.js @@ -234,8 +234,10 @@ frappe.ui.form.on("Dunning", { dn: frm.doc.name, }, callback: function (r) { - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", doc[0].doctype, doc[0].name); + if (!r.exc) { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", doc[0].doctype, doc[0].name); + } }, }); }, diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index bccf718f4a6..2069593992d 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1526,6 +1526,7 @@ def get_payment_entry_against_order( dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None ): ref_doc = frappe.get_doc(dt, dn) + ref_doc.check_permission() if flt(ref_doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) @@ -1571,6 +1572,8 @@ def get_payment_entry_against_invoice( dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None ): ref_doc = frappe.get_doc(dt, dn) + ref_doc.check_permission() + if dt == "Sales Invoice": party_type = "Customer" party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to @@ -1606,6 +1609,8 @@ def get_payment_entry_against_invoice( def get_payment_entry(ref_doc, args): + frappe.has_permission("Journal Entry", ptype="create", throw=True) + cost_center = ref_doc.get("cost_center") or frappe.get_cached_value( "Company", ref_doc.company, "cost_center" ) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 2746de2b99b..a16e6d01639 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2894,10 +2894,13 @@ def get_payment_entry( party_type=None, payment_type=None, reference_date=None, - ignore_permissions=False, created_from_payment_request=False, ): + frappe.has_permission("Payment Entry", ptype="create", throw=True) + doc = frappe.get_doc(dt, dn) + doc.check_permission() + over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance): frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt))) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 7a133acaf9e..54c13bd0dff 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2367,8 +2367,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe method: me.get_method_for_payment(), args: args, callback: function(r) { - var doclist = frappe.model.sync(r.message); - frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + if (!r.exc) { + var doclist = frappe.model.sync(r.message); + frappe.set_route("Form", doclist[0].doctype, doclist[0].name); + } } }); } From b98d193b77fcb73cf12d856981711c79076d519e Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Mon, 31 Aug 2026 16:43:31 +0530 Subject: [PATCH 49/56] fix: round party balances to currency precision in Trial Balance for Party (cherry picked from commit b1c7657dfa7dfcf9f7db851a491b10b887971ee9) --- .../trial_balance_for_party/trial_balance_for_party.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py index 86c8463b021..9d7a2ee8b09 100644 --- a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py +++ b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py @@ -9,6 +9,7 @@ from frappe.utils import cint, flt from erpnext.accounts.report.general_ledger.general_ledger import get_accounts_with_children from erpnext.accounts.report.trial_balance.trial_balance import validate_filters +from erpnext.accounts.utils import get_currency_precision def execute(filters=None): @@ -43,6 +44,7 @@ def get_data(filters, show_party_name): account_filter = get_accounts_with_children(filters.get("account")) company_currency = frappe.get_cached_value("Company", filters.company, "default_currency") + precision = get_currency_precision() opening_balances = get_opening_balances(filters, account_filter) balances_within_period = get_balances_within_period(filters, account_filter) @@ -65,14 +67,17 @@ def get_data(filters, show_party_name): # opening opening_debit, opening_credit = opening_balances.get(party.name, [0, 0]) + opening_debit, opening_credit = flt(opening_debit, precision), flt(opening_credit, precision) row.update({"opening_debit": opening_debit, "opening_credit": opening_credit}) # within period debit, credit = balances_within_period.get(party.name, [0, 0]) + debit, credit = flt(debit, precision), flt(credit, precision) row.update({"debit": debit, "credit": credit}) # closing closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit) + closing_debit, closing_credit = flt(closing_debit, precision), flt(closing_credit, precision) row.update({"closing_debit": closing_debit, "closing_credit": closing_credit}) row.update({"currency": company_currency}) From 547d82da563380a0644919173e00d0205f986d89 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:16:38 +0000 Subject: [PATCH 50/56] fix: add missing perm checks on various whitelisted methods (backport #58652) (#58653) Co-authored-by: Diptanil Saha --- erpnext/accounts/doctype/ledger_merge/ledger_merge.py | 4 +++- erpnext/accounts/doctype/payment_order/payment_order.py | 1 + .../process_period_closing_voucher.py | 6 ++++++ .../doctype/repost_payment_ledger/repost_payment_ledger.py | 1 + erpnext/projects/doctype/project/project.py | 1 + erpnext/stock/doctype/delivery_note/delivery_note.py | 3 ++- erpnext/utilities/bulk_transaction.py | 1 + 7 files changed, 15 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py index 49993393fe6..08e16b5a206 100644 --- a/erpnext/accounts/doctype/ledger_merge/ledger_merge.py +++ b/erpnext/accounts/doctype/ledger_merge/ledger_merge.py @@ -56,7 +56,9 @@ class LedgerMerge(Document): @frappe.whitelist() def form_start_merge(docname): - return frappe.get_doc("Ledger Merge", docname).start_merge() + lm_doc = frappe.get_doc("Ledger Merge", docname) + lm_doc.check_permission("write") + return lm_doc.start_merge() def start_merge(docname): diff --git a/erpnext/accounts/doctype/payment_order/payment_order.py b/erpnext/accounts/doctype/payment_order/payment_order.py index a4c596249b1..05af90fd9fb 100644 --- a/erpnext/accounts/doctype/payment_order/payment_order.py +++ b/erpnext/accounts/doctype/payment_order/payment_order.py @@ -83,6 +83,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters): @frappe.whitelist() def make_payment_records(name, supplier, mode_of_payment=None): doc = frappe.get_doc("Payment Order", name) + doc.check_permission() make_journal_entry(doc, supplier, mode_of_payment) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index ecedc8dc089..9fe9da0aa4c 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -139,6 +139,8 @@ def start_pcv_processing(docname: str): @frappe.whitelist() def pause_pcv_processing(docname: str): + frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True) + ppcv = qb.DocType("Process Period Closing Voucher") qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run() @@ -154,6 +156,8 @@ def pause_pcv_processing(docname: str): @frappe.whitelist() def cancel_pcv_processing(docname: str): + frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True) + ppcv = qb.DocType("Process Period Closing Voucher") qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run() @@ -168,6 +172,8 @@ def cancel_pcv_processing(docname: str): @frappe.whitelist() def resume_pcv_processing(docname: str): + frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True) + ppcv = qb.DocType("Process Period Closing Voucher") qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run() diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py index 6fd1b0f2bf2..be896b6f143 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py @@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname=None): """ if docname: repost_doc = frappe.get_doc("Repost Payment Ledger", docname) + repost_doc.check_permission("submit") if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]: try: for entry in repost_doc.repost_vouchers: diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 0e9e9d11dae..34b8e15f32f 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -794,5 +794,6 @@ def calculate_total_purchase_cost(project: str | None = None): @frappe.whitelist() def update_costing_and_billing(project: str | None = None): project = frappe.get_doc("Project", project) + project.check_permission("write") project.update_costing() project.db_update() diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 7ac24e95825..fe132d589d4 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -1266,7 +1266,8 @@ def make_sales_return(source_name, target_doc=None): @frappe.whitelist() def update_delivery_note_status(docname, status): - dn = frappe.get_doc("Delivery Note", docname) + dn = frappe.get_lazy_doc("Delivery Note", docname) + dn.check_permission("submit") dn.update_status(status) diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py index 1dc45b426b7..38399857d69 100644 --- a/erpnext/utilities/bulk_transaction.py +++ b/erpnext/utilities/bulk_transaction.py @@ -50,6 +50,7 @@ def transaction_processing(data, from_doctype, to_doctype): @frappe.whitelist() def retry(date: str | None = None): + frappe.only_for("System Manager") if not date: date = today() From c6fedf4acafcf1f524167d5a65bf6d6557bb5b1e Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Wed, 2 Sep 2026 11:44:38 +0530 Subject: [PATCH 51/56] fix(stock): preserve exchange gain loss journals in lcv (#58505) (cherry picked from commit 2b54a581f8a047564b6a9f01f402c7af2c0a90a7) --- .../stock/doctype/landed_cost_voucher/landed_cost_voucher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 60a26b91665..512687a7784 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -290,7 +290,7 @@ class LandedCostVoucher(Document): # update stock & gl entries for cancelled state of PR doc.docstatus = 2 doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True) - doc.make_gl_entries_on_cancel() + doc.make_gl_entries_on_cancel(from_repost=True) # update stock & gl entries for submit state of PR doc.docstatus = 1 From 08ec31174c85d02af5d1e47c44a3d26776c24b55 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 28 Aug 2026 16:08:51 +0530 Subject: [PATCH 52/56] fix: filter fully ordered items when creating RFQ from Material Request (cherry picked from commit 9b1d907b6a59101070810b670be45ab6ae0aa242) --- .../doctype/material_request/material_request.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 4bf88564df7..8b56cd8e28a 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -555,6 +555,15 @@ def make_purchase_order(source_name, target_doc=None, args=None): @frappe.whitelist() def make_request_for_quotation(source_name, target_doc=None): + def update_item(obj, target, source_parent): + qty = obj.ordered_qty or obj.received_qty + target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor + target.stock_qty = target.qty * target.conversion_factor + + def select_item(d): + qty = d.ordered_qty or d.received_qty + return qty < d.stock_qty + doclist = get_mapped_doc( "Material Request", source_name, @@ -570,6 +579,8 @@ def make_request_for_quotation(source_name, target_doc=None): ["parent", "material_request"], ["project", "project_name"], ], + "postprocess": update_item, + "condition": select_item, }, }, target_doc, From 3d563a2e0889cd351b0b14b5855856ba882d47fb Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 28 Aug 2026 16:32:53 +0530 Subject: [PATCH 53/56] test: RFQ from Material Request skips fully ordered items (cherry picked from commit 5adcb28b3ce12e327c902644f12bf1e30c698ff3) # Conflicts: # erpnext/stock/doctype/material_request/test_material_request.py --- .../material_request/test_material_request.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 4c753c867a2..1e6c3c10db2 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -15,6 +15,7 @@ from erpnext.stock.doctype.material_request.material_request import ( create_pick_list, make_in_transit_stock_entry, make_purchase_order, + make_request_for_quotation, make_stock_entry, make_supplier_quotation, raise_work_orders, @@ -47,6 +48,76 @@ class TestMaterialRequest(FrappeTestCase): self.assertEqual(po.doctype, "Purchase Order") self.assertEqual(len(po.get("items")), len(mr.get("items"))) +<<<<<<< HEAD +======= + def test_make_request_for_quotation_skips_ordered_items(self): + mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0]).insert() + mr = frappe.get_doc("Material Request", mr.name) + mr.submit() + + # fully order the first item, leave the second pending + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.items = [po.items[0]] + po.insert() + po.submit() + + rfq = make_request_for_quotation(mr.name) + + self.assertEqual(len(rfq.get("items")), 1) + self.assertEqual(rfq.items[0].material_request_item, mr.items[1].name) + self.assertEqual(rfq.items[0].qty, mr.items[1].qty) + + def test_make_subcontracted_purchase_order(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.stock.doctype.item.test_item import create_item, make_item + from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import ( + create_subcontracting_bom, + ) + + mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0]).insert() + mr.material_request_type = "Subcontracting" + mr.submit() + + frappe.db.set_value("Item", mr.items[0].item_code, "is_sub_contracted_item", 1) + + raw_materials = ["Raw Material Item 1", "Raw Material Item 2"] + for item in raw_materials: + create_item(item) + + frappe.new_doc("UOM").update({"uom_name": "Test UOM"}).save() + service_item = make_item( + properties={"is_stock_item": 0}, uoms=[{"uom": "Test UOM", "conversion_factor": 3}] + ) + + mr.items[0].default_bom = make_bom(item=mr.items[0].item_code, raw_materials=raw_materials) + mr.reload() + + create_subcontracting_bom( + finished_good=mr.items[0].item_code, + service_item=service_item.name, + finished_good_qty=2, + service_item_qty=1, + service_item_uom="Test UOM", + ) + + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.items[0].schedule_date = today() + po.items.pop(1) + + # Test 1 - Test if items stock qty, qty and finished good qty are calculated correctly based on provided UOMs + self.assertEqual(po.items[0].stock_qty, 81) + self.assertEqual(po.items[0].qty, 27) + self.assertEqual(po.items[0].fg_item_qty, 54) + + po.submit() + mr.reload() + + # Test 2 - MR items ordered qty should be updated based on PO items qty when submitted + self.assertEqual(mr.items[0].ordered_qty, 54) + +>>>>>>> 5adcb28 (test: RFQ from Material Request skips fully ordered items) def test_make_supplier_quotation(self): mr = frappe.copy_doc(test_records[0]).insert() From 567730e38e82a0f7b721dd69a715c791fe1526ed Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 28 Aug 2026 20:02:59 +0530 Subject: [PATCH 54/56] fix: add type hints to make_request_for_quotation (cherry picked from commit 4435f166ea982add6cab56f5b6e2a9424f708d78) --- erpnext/stock/doctype/material_request/material_request.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 8b56cd8e28a..e5d8dc0d474 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -10,6 +10,7 @@ import json import frappe import frappe.defaults from frappe import _, msgprint +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Order from frappe.query_builder.functions import Sum @@ -554,7 +555,7 @@ def make_purchase_order(source_name, target_doc=None, args=None): @frappe.whitelist() -def make_request_for_quotation(source_name, target_doc=None): +def make_request_for_quotation(source_name: str, target_doc: str | dict | Document | None = None): def update_item(obj, target, source_parent): qty = obj.ordered_qty or obj.received_qty target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor From b439deda888dc03bd7bb05207824c9c5a4be5db8 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 28 Aug 2026 20:02:59 +0530 Subject: [PATCH 55/56] test: set schedule date on PO before insert (cherry picked from commit 5af60cf2e399657781bf4e27e8976da4aa74c7fd) --- erpnext/stock/doctype/material_request/test_material_request.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 1e6c3c10db2..14916a6a4fd 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -58,7 +58,9 @@ class TestMaterialRequest(FrappeTestCase): # fully order the first item, leave the second pending po = make_purchase_order(mr.name) po.supplier = "_Test Supplier" + po.schedule_date = today() po.items = [po.items[0]] + po.items[0].schedule_date = today() po.insert() po.submit() From 4a4e1908e3893fb64f732453d57295384797f3b5 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Wed, 2 Sep 2026 11:50:21 +0530 Subject: [PATCH 56/56] chore: resolve backport conflict --- .../material_request/test_material_request.py | 54 +------------------ 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 14916a6a4fd..9a8a87d2fd5 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -48,10 +48,8 @@ class TestMaterialRequest(FrappeTestCase): self.assertEqual(po.doctype, "Purchase Order") self.assertEqual(len(po.get("items")), len(mr.get("items"))) -<<<<<<< HEAD -======= def test_make_request_for_quotation_skips_ordered_items(self): - mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0]).insert() + mr = frappe.copy_doc(test_records[0]).insert() mr = frappe.get_doc("Material Request", mr.name) mr.submit() @@ -70,56 +68,6 @@ class TestMaterialRequest(FrappeTestCase): self.assertEqual(rfq.items[0].material_request_item, mr.items[1].name) self.assertEqual(rfq.items[0].qty, mr.items[1].qty) - def test_make_subcontracted_purchase_order(self): - from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom - from erpnext.stock.doctype.item.test_item import create_item, make_item - from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import ( - create_subcontracting_bom, - ) - - mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0]).insert() - mr.material_request_type = "Subcontracting" - mr.submit() - - frappe.db.set_value("Item", mr.items[0].item_code, "is_sub_contracted_item", 1) - - raw_materials = ["Raw Material Item 1", "Raw Material Item 2"] - for item in raw_materials: - create_item(item) - - frappe.new_doc("UOM").update({"uom_name": "Test UOM"}).save() - service_item = make_item( - properties={"is_stock_item": 0}, uoms=[{"uom": "Test UOM", "conversion_factor": 3}] - ) - - mr.items[0].default_bom = make_bom(item=mr.items[0].item_code, raw_materials=raw_materials) - mr.reload() - - create_subcontracting_bom( - finished_good=mr.items[0].item_code, - service_item=service_item.name, - finished_good_qty=2, - service_item_qty=1, - service_item_uom="Test UOM", - ) - - po = make_purchase_order(mr.name) - po.supplier = "_Test Supplier" - po.items[0].schedule_date = today() - po.items.pop(1) - - # Test 1 - Test if items stock qty, qty and finished good qty are calculated correctly based on provided UOMs - self.assertEqual(po.items[0].stock_qty, 81) - self.assertEqual(po.items[0].qty, 27) - self.assertEqual(po.items[0].fg_item_qty, 54) - - po.submit() - mr.reload() - - # Test 2 - MR items ordered qty should be updated based on PO items qty when submitted - self.assertEqual(mr.items[0].ordered_qty, 54) - ->>>>>>> 5adcb28 (test: RFQ from Material Request skips fully ordered items) def test_make_supplier_quotation(self): mr = frappe.copy_doc(test_records[0]).insert()