From 05d6cf5c9a4f0b3de830709b3dfc87038f846ae7 Mon Sep 17 00:00:00 2001 From: kavin-114 Date: Thu, 2 Apr 2026 23:27:24 +0530 Subject: [PATCH 1/4] fix(stock): update stock queue in SABE for return entries (cherry picked from commit 0af8077bcc828422593dfa51b99bcac249a8bbed) --- .../serial_and_batch_bundle.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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 4de6ebc6a00..f1457e1a10b 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 @@ -400,6 +400,25 @@ class SerialandBatchBundle(Document): def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None): if valuation_details := self.get_valuation_rate_for_return_entry(return_against): + from erpnext.stock.utils import get_valuation_method + + valuation_method = get_valuation_method(self.item_code, self.company) + + stock_queue = [] + non_batchwise_batches = [] + if not self.has_serial_no and valuation_method == "FIFO": + non_batchwise_batches = frappe.get_all( + "Batch", + filters={ + "name": ("in", [d.batch_no for d in self.entries if d.batch_no]), + "use_batchwise_valuation": 0, + }, + pluck="name", + ) + + if non_batchwise_batches and prev_sle and prev_sle.stock_queue: + stock_queue = parse_json(prev_sle.stock_queue) + for row in self.entries: if valuation_details: self.validate_returned_serial_batch_no(return_against, row, valuation_details) @@ -421,11 +440,25 @@ class SerialandBatchBundle(Document): row.incoming_rate = flt(valuation_rate) row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate) + if ( + non_batchwise_batches + and row.batch_no in non_batchwise_batches + and row.incoming_rate is not None + ): + if flt(row.qty) > 0: + stock_queue.append([row.qty, row.incoming_rate]) + elif flt(row.qty) < 0: + stock_queue = FIFOValuation(stock_queue) + stock_queue.remove_stock(qty=abs(row.qty)) + stock_queue = stock_queue.state + row.stock_queue = json.dumps(stock_queue) + if save: row.db_set( { "incoming_rate": row.incoming_rate, "stock_value_difference": row.stock_value_difference, + "stock_queue": row.get("stock_queue"), } ) From b57db06100b1abe489399a2ad984ed81700228b4 Mon Sep 17 00:00:00 2001 From: kavin-114 Date: Fri, 3 Apr 2026 00:02:42 +0530 Subject: [PATCH 2/4] test(stock): add unit test to update stock queue for return (cherry picked from commit e537896df882f81fcabd999a9aa74f1cd1aa7462) # Conflicts: # erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py --- .../test_serial_and_batch_bundle.py | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 51b939c343d..90b91bd148d 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1071,6 +1071,208 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertTrue(bundle_doc.docstatus == 0) self.assertRaises(frappe.ValidationError, bundle_doc.submit) +<<<<<<< HEAD +======= + def test_reference_voucher_on_cancel(self): + """ + When a source document is cancelled, the reference voucher field + in the respective serial or batch document should be nullified. + """ + + item_code = make_item( + "Serial Item", + properties={ + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "SERIAL.#####", + }, + ).name + + se = make_stock_entry( + item_code=item_code, + qty=1, + target="_Test Warehouse - _TC", + ) + serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0] + self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se.name) + + se.cancel() + self.assertIsNone(frappe.get_value("Serial No", serial_no, "reference_name")) + + se1 = frappe.copy_doc(se, ignore_no_copy=False) + se1.items[0].serial_no = serial_no + se1.submit() + + self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se1.name) + + def test_stock_queue_for_return_entry_with_non_batchwise_valuation(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + batch_item_code = "Old Batch Return Queue Test" + make_item( + batch_item_code, + { + "has_batch_no": 1, + "batch_number_series": "TEST-RET-Q-.#####", + "create_new_batch": 1, + "is_stock_item": 1, + "valuation_method": "FIFO", + }, + ) + + batch_id = "Old Batch Return Queue 1" + if not frappe.db.exists("Batch", batch_id): + batch_doc = frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": batch_item_code, + "use_batchwise_valuation": 0, + } + ).insert(ignore_permissions=True) + + batch_doc.db_set( + { + "use_batchwise_valuation": 0, + "batch_qty": 0, + } + ) + + # Create initial stock with FIFO queue: [[10, 100], [20, 200]] + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=100, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=20, + rate=200, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + # Purchase Receipt: inward 5 @ 300 + pr = make_purchase_receipt( + item_code=batch_item_code, + warehouse="_Test Warehouse - _TC", + qty=5, + rate=300, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": pr.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should now be [[10, 100], [20, 200], [5, 300]] + self.assertEqual(json.loads(sle.stock_queue), [[10, 100], [20, 200], [5, 300]]) + + # Purchase Return: return 5 against the PR + return_pr = make_return_doc("Purchase Receipt", pr.name) + return_pr.submit() + + return_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_pr.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should have 5 removed via FIFO from [[10, 100], [20, 200], [5, 300]] + # FIFO removes from front: [10, 100] -> [5, 100], rest unchanged + self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100], [20, 200], [5, 300]]) + + def test_stock_queue_for_return_entry_with_empty_fifo_queue(self): + """Credit note (sales return) against empty FIFO queue should still rebuild stock_queue.""" + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + batch_item_code = "Old Batch Empty Queue Test" + make_item( + batch_item_code, + { + "has_batch_no": 1, + "batch_number_series": "TEST-EQ-.#####", + "create_new_batch": 1, + "is_stock_item": 1, + "valuation_method": "FIFO", + }, + ) + + batch_id = "Old Batch Empty Queue 1" + if not frappe.db.exists("Batch", batch_id): + batch_doc = frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": batch_item_code, + "use_batchwise_valuation": 0, + } + ).insert(ignore_permissions=True) + + batch_doc.db_set( + { + "use_batchwise_valuation": 0, + "batch_qty": 0, + } + ) + + # Inward 10 @ 100, then outward all 10 to empty the queue + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=100, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + dn = create_delivery_note( + item_code=batch_item_code, + warehouse="_Test Warehouse - _TC", + qty=10, + rate=150, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + # Verify queue is empty after full outward + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": dn.name}, + ["stock_queue"], + as_dict=True, + ) + self.assertFalse(json.loads(sle.stock_queue or "[]")) + + # Sales return (credit note): 5 items come back at original rate 100 + return_dn = make_return_doc("Delivery Note", dn.name) + for row in return_dn.items: + row.qty = -5 + return_dn.save().submit() + + return_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_dn.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should have the returned stock: [[5, 100]] + self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100]]) + +>>>>>>> e537896df8 (test(stock): add unit test to update stock queue for return) def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos From f855cc89c9d77a868f1b325d28b8fdcfd4bd3d01 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 6 Apr 2026 13:12:03 +0530 Subject: [PATCH 3/4] chore: fix conflicts --- .../test_serial_and_batch_bundle.py | 114 ------------------ 1 file changed, 114 deletions(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 90b91bd148d..d05c0716648 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1071,40 +1071,6 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertTrue(bundle_doc.docstatus == 0) self.assertRaises(frappe.ValidationError, bundle_doc.submit) -<<<<<<< HEAD -======= - def test_reference_voucher_on_cancel(self): - """ - When a source document is cancelled, the reference voucher field - in the respective serial or batch document should be nullified. - """ - - item_code = make_item( - "Serial Item", - properties={ - "is_stock_item": 1, - "has_serial_no": 1, - "serial_no_series": "SERIAL.#####", - }, - ).name - - se = make_stock_entry( - item_code=item_code, - qty=1, - target="_Test Warehouse - _TC", - ) - serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0] - self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se.name) - - se.cancel() - self.assertIsNone(frappe.get_value("Serial No", serial_no, "reference_name")) - - se1 = frappe.copy_doc(se, ignore_no_copy=False) - se1.items[0].serial_no = serial_no - se1.submit() - - self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se1.name) - def test_stock_queue_for_return_entry_with_non_batchwise_valuation(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -1193,86 +1159,6 @@ class TestSerialandBatchBundle(FrappeTestCase): # FIFO removes from front: [10, 100] -> [5, 100], rest unchanged self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100], [20, 200], [5, 300]]) - def test_stock_queue_for_return_entry_with_empty_fifo_queue(self): - """Credit note (sales return) against empty FIFO queue should still rebuild stock_queue.""" - from erpnext.controllers.sales_and_purchase_return import make_return_doc - from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note - - batch_item_code = "Old Batch Empty Queue Test" - make_item( - batch_item_code, - { - "has_batch_no": 1, - "batch_number_series": "TEST-EQ-.#####", - "create_new_batch": 1, - "is_stock_item": 1, - "valuation_method": "FIFO", - }, - ) - - batch_id = "Old Batch Empty Queue 1" - if not frappe.db.exists("Batch", batch_id): - batch_doc = frappe.get_doc( - { - "doctype": "Batch", - "batch_id": batch_id, - "item": batch_item_code, - "use_batchwise_valuation": 0, - } - ).insert(ignore_permissions=True) - - batch_doc.db_set( - { - "use_batchwise_valuation": 0, - "batch_qty": 0, - } - ) - - # Inward 10 @ 100, then outward all 10 to empty the queue - make_stock_entry( - item_code=batch_item_code, - target="_Test Warehouse - _TC", - qty=10, - rate=100, - batch_no=batch_id, - use_serial_batch_fields=True, - ) - - dn = create_delivery_note( - item_code=batch_item_code, - warehouse="_Test Warehouse - _TC", - qty=10, - rate=150, - batch_no=batch_id, - use_serial_batch_fields=True, - ) - - # Verify queue is empty after full outward - sle = frappe.db.get_value( - "Stock Ledger Entry", - {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": dn.name}, - ["stock_queue"], - as_dict=True, - ) - self.assertFalse(json.loads(sle.stock_queue or "[]")) - - # Sales return (credit note): 5 items come back at original rate 100 - return_dn = make_return_doc("Delivery Note", dn.name) - for row in return_dn.items: - row.qty = -5 - return_dn.save().submit() - - return_sle = frappe.db.get_value( - "Stock Ledger Entry", - {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_dn.name}, - ["stock_queue"], - as_dict=True, - ) - - # Stock queue should have the returned stock: [[5, 100]] - self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100]]) - ->>>>>>> e537896df8 (test(stock): add unit test to update stock queue for return) def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos From c81c1ea8693519d13be249238de1c1d6a278d8bf Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 7 Apr 2026 13:11:44 +0530 Subject: [PATCH 4/4] chore: fix test case --- .../doctype/serial_and_batch_bundle/serial_and_batch_bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 49a50204f42..b0b8c221f8e 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,7 +402,7 @@ class SerialandBatchBundle(Document): if valuation_details := self.get_valuation_rate_for_return_entry(return_against): from erpnext.stock.utils import get_valuation_method - valuation_method = get_valuation_method(self.item_code, self.company) + valuation_method = get_valuation_method(self.item_code) stock_queue = [] non_batchwise_batches = []