From edf68e25be555851a5e9b7145908de3f2c3fac68 Mon Sep 17 00:00:00 2001 From: Jatin3128 Date: Fri, 31 Jul 2026 12:18:47 +0530 Subject: [PATCH] fix: read the locked rate from the persisted source row get_rate_locked_source_row returned the mutable target row, so an unsaved rate or discount edit on a mapped row was preserved on re-fetch instead of the source pricing, and the document still failed maintain-same-rate on save. Read the pricing straight from the linked source row in the database, and cover the edit-then-refresh case with a test. --- erpnext/stock/get_item_details.py | 43 +++++++++----- erpnext/stock/tests/test_get_item_details.py | 60 ++++++++++++++++---- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 49008d27bab..b178abf5558 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -40,17 +40,28 @@ purchase_doctypes = [ NOT_APPLICABLE_TAX = "N/A" -# Item rows mapped from these source documents must keep the source rate when -# "maintain same rate" is enabled, so a newer Item Price is never fetched onto them. +# For each transaction, the child-row link field(s) that point to the source +# document item, mapped to that source item doctype. When "maintain same rate" is +# on, a mapped row keeps the persisted source pricing (read straight from that row), +# so an unsaved edit on the target row can never lock in a non-source rate. maintain_same_rate_source_fields = { - "Purchase Order": ("supplier_quotation_item",), - "Purchase Receipt": ("purchase_order_item",), - "Purchase Invoice": ("po_detail", "pr_detail"), - "Sales Order": ("quotation_item", "prevdoc_docname"), - "Delivery Note": ("so_detail", "si_detail"), - "Sales Invoice": ("so_detail", "dn_detail"), + "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", +] + def _preprocess_ctx(ctx): if not ctx.price_list: @@ -204,11 +215,11 @@ def remove_standard_fields(out: frappe._dict): def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None: - """Return the current item row when its rate is locked to a source document. + """Return the persisted source-document row a mapped target row is locked to. - With "maintain same rate" enabled, a row mapped from a source (e.g. a Purchase - Order) can only be saved at the source rate. Fetching a newer Item Price here - would set a rate the document can never be saved with, so keep the source rate. + The rate is read from the linked source row in the database (not the mutable + target row), so a re-fetch always restores the source pricing the maintain-same- + rate validator checks against, even after an unsaved edit on the target row. """ if isinstance(doc, str): doc = json.loads(doc) @@ -218,8 +229,12 @@ def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None: return None row = next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None) - if row and any(row.get(field) for field in source_fields): - return row + if not row: + return None + + for link_field, source_doctype in source_fields.items(): + if source_name := row.get(link_field): + return frappe.db.get_value(source_doctype, source_name, LOCKED_RATE_FIELDS, as_dict=True) return None diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index 5116c4efe21..12159082dec 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -315,8 +315,8 @@ class TestGetItemDetail(ERPNextTestSuite): frappe.clear_cache(doctype="Buying Settings") def test_maintain_same_rate_keeps_source_discount_on_refetch(self): - """A mapped row with a manual discount has rate != price_list_rate. Re-fetch must - keep the source's rate and discount, not just the pre-discount price, or the + """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 @@ -331,21 +331,22 @@ class TestGetItemDetail(ERPNextTestSuite): frappe.clear_cache(doctype="Buying Settings") try: - po = create_purchase_order(item_code=item_code, rate=90, qty=1) + # 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" - # price_list_rate 100 with a 10% discount gives the effective rate 90. pr_doc = { "doctype": "Purchase Receipt", "items": [ - { - "name": row_name, - "item_code": item_code, - "purchase_order_item": po.items[0].name, - "price_list_rate": 100, - "rate": 90, - "discount_percentage": 10, - } + {"name": row_name, "item_code": item_code, "purchase_order_item": po.items[0].name} ], } ctx = frappe._dict( @@ -375,3 +376,38 @@ class TestGetItemDetail(ERPNextTestSuite): 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_refetch_restores_source_rate_after_target_edit(self): + """Editing a mapped row's rate then re-fetching must restore the persisted source + rate (read from the linked row), not lock in the edit, so the document still saves. + """ + from frappe.utils import flt + + 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 + + item = "_Test Item" + original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate") + original_action = frappe.db.get_single_value("Buying Settings", "maintain_same_rate_action") + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) + frappe.db.set_single_value("Buying Settings", "maintain_same_rate_action", "Stop") + frappe.clear_cache(doctype="Buying Settings") + + try: + po = create_purchase_order(item_code=item, qty=1, rate=90) + pr = make_purchase_receipt(po.name) + pr.insert() + + # user edits the mapped row to a non-source rate + pr.items[0].price_list_rate = 200 + pr.items[0].rate = 200 + + # a re-fetch must restore the persisted source (PO) rate, not keep the edit + pr.process_item_selection(item_idx=pr.items[0].idx) + self.assertEqual(flt(pr.items[0].rate), 90) + pr.save() # must not raise the maintain-same-rate check + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.db.set_single_value("Buying Settings", "maintain_same_rate_action", original_action) + frappe.clear_cache(doctype="Buying Settings")