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.
This commit is contained in:
Jatin3128
2026-07-31 12:18:47 +05:30
parent 5bf2d71893
commit edf68e25be
2 changed files with 77 additions and 26 deletions

View File

@@ -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

View File

@@ -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")