From 78b101b3c387bb79d0aea6a237865cf4135b988e Mon Sep 17 00:00:00 2001 From: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:45:02 +0530 Subject: [PATCH] fix(stock): preserve rates for unsaved mapped rows (#58852) * fix(stock): preserve rates for unsaved mapped rows * test(stock): cover mapped delivery note item rates (cherry picked from commit e0d6d797d7086b214f74bf29e2ffbd75894e7b74) # Conflicts: # erpnext/stock/tests/test_get_item_details.py --- erpnext/stock/get_item_details.py | 6 +- erpnext/stock/tests/test_get_item_details.py | 169 +++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 9e676e10696..f14ffc72b56 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -222,7 +222,11 @@ def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None: if not source_fields or not doc or ctx.get("is_return") or not maintain_same_rate_enabled(ctx): return None - row = next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None) + row = ( + next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None) + if ctx.child_docname + else ctx + ) if not row: return None diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py index d790c673e20..c9525b6fbd1 100644 --- a/erpnext/stock/tests/test_get_item_details.py +++ b/erpnext/stock/tests/test_get_item_details.py @@ -456,3 +456,172 @@ class TestGetItemDetail(ERPNextTestSuite): frappe.set_user("Administrator") frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) frappe.clear_cache(doctype="Buying Settings") +<<<<<<< HEAD +======= + + def test_rate_lock_matches_unsaved_mapped_row(self): + 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") + + try: + first_po = create_purchase_order(rate=100) + second_po = create_purchase_order(rate=200) + pr_doc = { + "doctype": "Purchase Receipt", + "items": [ + {"name": None, "purchase_order_item": first_po.items[0].name}, + {"name": None, "purchase_order_item": second_po.items[0].name}, + ], + } + ctx = frappe._dict( + doctype="Purchase Receipt", + child_docname=None, + purchase_order_item=second_po.items[0].name, + ) + + source_row = get_rate_locked_source_row(ctx, pr_doc) + self.assertEqual(source_row.rate, 200) + finally: + frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original) + frappe.clear_cache(doctype="Buying Settings") + + @ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1}) + def test_delivery_note_to_sales_invoice_keeps_item_rates(self): + from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + first_item, first_batches = self.make_batched_item_with_stock([1]) + second_item, second_batches = self.make_batched_item_with_stock([1]) + dn = create_delivery_note( + item_code=first_item, + qty=1, + rate=100, + batch_no=first_batches[0], + use_serial_batch_fields=1, + do_not_save=True, + ) + dn.append( + "items", + { + "item_code": second_item, + "warehouse": "_Test Warehouse - _TC", + "qty": 1, + "rate": 200, + "conversion_factor": 1, + "batch_no": second_batches[0], + "use_serial_batch_fields": 1, + }, + ) + dn.insert() + dn.submit() + + si = make_sales_invoice(dn.name) + self.assertEqual([item.rate for item in si.items], [100, 200]) + + def make_batched_item_with_stock(self, quantities, uoms=None, **properties): + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + ) + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + properties.update({"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "FBQ-.#####"}) + item_code = make_item(properties=properties, uoms=uoms).name + batches = [] + for qty in quantities: + se = make_stock_entry( + item_code=item_code, target="_Test Warehouse - _TC", qty=qty, basic_rate=100 + ) + batches.append(get_batch_from_bundle(se.items[0].serial_and_batch_bundle)) + + return item_code, batches + + def get_item_details_for_row(self, item_code, qty, items=None): + ctx = frappe._dict( + { + "doctype": "Delivery Note", + "item_code": item_code, + "company": "_Test Company", + "warehouse": "_Test Warehouse - _TC", + "qty": qty, + "use_serial_batch_fields": 1, + "price_list": "_Test Price List", + "currency": "INR", + "conversion_rate": 1.0, + "price_list_currency": "INR", + "plc_conversion_rate": 1.0, + "ignore_pricing_rule": 1, + } + ) + doc = {"doctype": "Delivery Note", "selling_price_list": "_Test Price List", "items": items or []} + return get_item_details(ctx, doc=doc) + + def get_picked_batch_no(self, item_code, qty, items=None): + return self.get_item_details_for_row(item_code, qty, items).get("batch_no") + + def test_batch_no_set_only_when_first_batch_covers_qty(self): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + item_code, batches = self.make_batched_item_with_stock([2, 3, 14]) + first_batch_row = [{"batch_no": batches[0], "qty": 2, "stock_qty": 2}] + + with self.change_settings( + "Stock Settings", + {"pick_serial_and_batch_based_on": "FIFO", "auto_create_serial_and_batch_bundle_for_outward": 1}, + ): + self.assertEqual(self.get_picked_batch_no(item_code, 2), batches[0]) + self.assertEqual(self.get_picked_batch_no(item_code, 3, items=first_batch_row), batches[1]) + self.assertIsNone(self.get_picked_batch_no(item_code, 5)) + self.assertIsNone(self.get_picked_batch_no(item_code, 20)) + + dn = create_delivery_note(item_code=item_code, qty=5, use_serial_batch_fields=1) + dn.reload() + entries = frappe.get_all( + "Serial and Batch Entry", {"parent": dn.items[0].serial_and_batch_bundle}, ["batch_no", "qty"] + ) + self.assertEqual({d.batch_no: d.qty for d in entries}, {batches[0]: -2, batches[1]: -3}) + + def test_serial_nos_picked_across_batches_when_no_batch_covers_qty(self): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_serial_nos_from_bundle, + ) + + item_code, batches = self.make_batched_item_with_stock( + [2, 3], has_serial_no=1, serial_no_series="FBQ-SN-.#####" + ) + + with self.change_settings( + "Stock Settings", + {"pick_serial_and_batch_based_on": "FIFO", "auto_create_serial_and_batch_bundle_for_outward": 1}, + ): + details = self.get_item_details_for_row(item_code, 5) + self.assertIsNone(details.get("batch_no")) + serial_nos = details.serial_no.split("\n") + self.assertEqual(len(serial_nos), 5) + + dn = create_delivery_note( + item_code=item_code, qty=5, use_serial_batch_fields=1, serial_no=details.serial_no + ) + dn.reload() + self.assertEqual( + get_serial_nos_from_bundle(dn.items[0].serial_and_batch_bundle), sorted(serial_nos) + ) + + def test_same_document_rows_reduce_batch_by_stock_qty(self): + item_code, batches = self.make_batched_item_with_stock( + [10], uoms=[{"uom": "Box", "conversion_factor": 5}] + ) + box_row = [{"batch_no": batches[0], "uom": "Box", "qty": 1, "stock_qty": 5}] + + with self.change_settings( + "Stock Settings", + {"pick_serial_and_batch_based_on": "FIFO", "auto_create_serial_and_batch_bundle_for_outward": 1}, + ): + self.assertEqual(self.get_picked_batch_no(item_code, 5, items=box_row), batches[0]) + self.assertIsNone(self.get_picked_batch_no(item_code, 6, items=box_row)) +>>>>>>> e0d6d79 (fix(stock): preserve rates for unsaved mapped rows (#58852))