From e2ded11e0922a78fa0eab94af776bf4ff86bbacd Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 6 Aug 2026 15:20:34 +0530 Subject: [PATCH] fix: purchase return of batchwise valuation batch valued at original receipt rate instead of batch avg rate (version-15-hotfix) (#57837) * fix: use current batch avg rate for outward returns of batchwise valuation batches * fix: honor zero batch average and avoid duplicate batch classification query --- .../purchase_receipt/test_purchase_receipt.py | 60 +++++++++++++++++++ .../serial_and_batch_bundle.py | 48 +++++++++++++++ erpnext/stock/serial_batch_bundle.py | 5 ++ 3 files changed, 113 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 471de86f0a7..40cf324010d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4996,6 +4996,66 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]]) + def test_purchase_return_valuation_for_batchwise_valuation_batch(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + item_code = make_item( + "Test Purchase Return Batchwise Valn Item", + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "BN-TPRBWV-.#####", + }, + ).name + + batch_no = "BN-TPRBWV-00001" + batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert() + self.assertEqual(batch.use_batchwise_valuation, 1) + + warehouse = "_Test Warehouse - _TC" + pr = make_purchase_receipt( + item_code=item_code, + qty=100, + rate=1000, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + make_purchase_receipt( + item_code=item_code, + qty=100, + rate=400, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + create_delivery_note( + item_code=item_code, + qty=100, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + + return_pr = make_return_doc("Purchase Receipt", pr.name) + return_pr.submit() + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": return_pr.name, "is_cancelled": 0}, + ["stock_value_difference", "qty_after_transaction", "stock_value", "serial_and_batch_bundle"], + as_dict=True, + ) + self.assertEqual(flt(sle.qty_after_transaction), 0.0) + self.assertEqual(flt(sle.stock_value_difference, 2), -70000.0) + self.assertEqual(flt(sle.stock_value, 2), 0.0) + + rate = frappe.db.get_value( + "Serial and Batch Entry", {"parent": sle.serial_and_batch_bundle}, "incoming_rate" + ) + self.assertEqual(flt(rate, 2), 700.0) + def test_negative_stock_error_for_purchase_return(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index ca18baac969..0b614be1596 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -402,6 +402,13 @@ class SerialandBatchBundle(Document): valuation_method = get_valuation_method(self.item_code) + # An outward return must go out at the batch's current average rate for a + # batchwise valuation batch. The original receipt rate is only correct while + # the batch still holds stock at that rate; once other receipts have changed + # the average, removing at the original rate strands a residue in the batch + # value (negative when returning the costlier receipt). + batchwise_avg_rates = self.get_batchwise_return_avg_rates() + stock_queue = [] non_batchwise_batches = [] if not self.has_serial_no and valuation_method == "FIFO": @@ -435,6 +442,12 @@ class SerialandBatchBundle(Document): batches = sorted(list(valuation_details["batches"].keys())) valuation_rate = valuation_details["batches"].get(batches[cint(row.idx) - 1]) + # a batch with an available balance goes out at its current average rate (a + # valid 0.0 included); the original receipt rate applies only when there is + # no balance to average + if not row.serial_no and row.batch_no in batchwise_avg_rates: + valuation_rate = batchwise_avg_rates[row.batch_no] + row.incoming_rate = flt(valuation_rate) row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate) @@ -463,6 +476,41 @@ class SerialandBatchBundle(Document): elif self.type_of_transaction == "Inward": self.set_incoming_rate_for_inward_transaction(row, save, prev_sle=prev_sle) + def get_batchwise_return_avg_rates(self): + from erpnext.stock.utils import get_valuation_method + + if self.type_of_transaction != "Outward" or self.has_serial_no: + return {} + + batch_nos = [d.batch_no for d in self.entries if d.batch_no] + if not batch_nos: + return {} + + if get_valuation_method(self.item_code) == "Moving Average" and frappe.db.get_single_value( + "Stock Settings", "do_not_use_batchwise_valuation" + ): + return {} + + batchwise_batches = frappe.get_all( + "Batch", + filters={"name": ("in", batch_nos), "use_batchwise_valuation": 1}, + pluck="name", + ) + if not batchwise_batches: + return {} + + # scoped to batchwise batches only, so BatchNoValuation's non-batchwise + # machinery never runs for them + sle = self.get_sle_for_outward_transaction() + sle.batch_nos = {batch_no: sle.batch_nos[batch_no] for batch_no in batchwise_batches} + sle.batchwise_valuation_batches = batchwise_batches + sn_obj = BatchNoValuation(sle=sle, item_code=self.item_code, warehouse=self.warehouse) + return { + batch_no: abs(flt(sn_obj.batch_avg_rate.get(batch_no))) + for batch_no in batchwise_batches + if flt(sn_obj.available_qty.get(batch_no)) + } + def validate_returned_serial_batch_no(self, return_against, row, original_inv_details): if frappe.flags.through_repost_item_valuation: return diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 4e0d69134bb..dc659cb79ab 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -910,6 +910,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation): self.batchwise_valuation_batches = [] self.non_batchwise_valuation_batches = [] + if batchwise_batches := self.sle.get("batchwise_valuation_batches"): + self.batchwise_valuation_batches = list(batchwise_batches) + self.non_batchwise_valuation_batches = list(set(self.batches) - set(batchwise_batches)) + return + if get_valuation_method(self.sle.item_code) == "Moving Average" and frappe.db.get_single_value( "Stock Settings", "do_not_use_batchwise_valuation" ):