From c30d76ae686982fa60a2f6d5ffa5b4b3e7b3ebc5 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 3 Feb 2026 14:53:30 +0530 Subject: [PATCH] fix: negative stock for purchase return --- .../purchase_receipt/test_purchase_receipt.py | 78 +++++++++++++++++++ .../serial_and_batch_bundle.py | 72 ++++++++++------- 2 files changed, 120 insertions(+), 30 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 341c38bdcf1..1685c9d98a8 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4794,6 +4794,84 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(stk_ledger.incoming_rate, 120) self.assertEqual(stk_ledger.stock_value_difference, 600) + def test_negative_stock_error_for_purchase_return_when_stock_exists_in_future_date(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 + from erpnext.stock.stock_ledger import NegativeStockError + + item_code = make_item( + "Test Negative Stock for Purchase Return with Future Stock Item", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TNSPFPRI.#####", + }, + ).name + + make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -4), + qty=100, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + pr1 = make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -3), + qty=100, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + batch1 = get_batch_from_bundle(pr1.items[0].serial_and_batch_bundle) + + pr2 = make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -2), + qty=100, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + batch2 = get_batch_from_bundle(pr2.items[0].serial_and_batch_bundle) + + make_stock_entry( + item_code=item_code, + qty=100, + posting_date=add_days(today(), -1), + source="_Test Warehouse - _TC", + target="_Test Warehouse 1 - _TC", + batch_no=batch1, + use_serial_batch_fields=1, + ) + + make_stock_entry( + item_code=item_code, + qty=100, + posting_date=add_days(today(), -1), + source="_Test Warehouse - _TC", + target="_Test Warehouse 1 - _TC", + batch_no=batch2, + use_serial_batch_fields=1, + ) + + make_stock_entry( + item_code=item_code, + qty=100, + posting_date=today(), + source="_Test Warehouse 1 - _TC", + target="_Test Warehouse - _TC", + batch_no=batch1, + use_serial_batch_fields=1, + ) + + make_purchase_entry = make_return_doc("Purchase Receipt", pr1.name) + make_purchase_entry.set_posting_time = 1 + make_purchase_entry.posting_date = pr1.posting_date + self.assertRaises(NegativeStockError, make_purchase_entry.submit) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier 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 fe10c6aeeb9..46be3cbbf8f 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 @@ -15,6 +15,7 @@ from frappe.utils import ( cint, cstr, flt, + get_datetime, get_link_to_form, getdate, now, @@ -1419,31 +1420,44 @@ class SerialandBatchBundle(Document): for d in self.entries: available_qty = batch_wise_available_qty.get(d.batch_no, 0) if flt(available_qty, precision) < 0: - frappe.throw( - _( - """ - The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry.""" - ).format( - bold(d.batch_no), - bold(self.item_code), - bold(self.warehouse), - bold(abs(flt(available_qty, precision))), - ), - title=_("Negative Stock Error"), - ) + self.throw_negative_batch(d.batch_no, available_qty, precision) + + def throw_negative_batch(self, batch_no, available_qty, precision): + from erpnext.stock.stock_ledger import NegativeStockError + + frappe.throw( + _( + """ + The Batch {0} of an item {1} has negative stock in the warehouse {2}. Please add a stock quantity of {3} to proceed with this entry.""" + ).format( + bold(batch_no), + bold(self.item_code), + bold(self.warehouse), + bold(abs(flt(available_qty, precision))), + ), + title=_("Negative Stock Error"), + exc=NegativeStockError, + ) def get_batchwise_available_qty(self): - available_qty = self.get_available_qty_from_sabb() - available_qty_from_ledger = self.get_available_qty_from_stock_ledger() + batchwise_entries = self.get_available_qty_from_sabb() + batchwise_entries.extend(self.get_available_qty_from_stock_ledger()) - if not available_qty_from_ledger: - return available_qty + available_qty = frappe._dict({}) + batchwise_entries = sorted( + batchwise_entries, + key=lambda x: (get_datetime(x.get("posting_datetime")), get_datetime(x.get("creation"))), + ) - for batch_no, qty in available_qty_from_ledger.items(): - if batch_no in available_qty: - available_qty[batch_no] += qty + precision = frappe.get_precision("Serial and Batch Entry", "qty") + for row in batchwise_entries: + if row.batch_no in available_qty: + available_qty[row.batch_no] += flt(row.qty) else: - available_qty[batch_no] = qty + available_qty[row.batch_no] = flt(row.qty) + + if flt(available_qty[row.batch_no], precision) < 0: + self.throw_negative_batch(row.batch_no, available_qty[row.batch_no], precision) return available_qty @@ -1456,7 +1470,9 @@ class SerialandBatchBundle(Document): frappe.qb.from_(sle) .select( sle.batch_no, - Sum(sle.actual_qty).as_("available_qty"), + sle.actual_qty.as_("qty"), + sle.posting_datetime, + sle.creation, ) .where( (sle.item_code == self.item_code) @@ -1468,12 +1484,9 @@ class SerialandBatchBundle(Document): & (sle.batch_no.isnotnull()) ) .for_update() - .groupby(sle.batch_no) ) - res = query.run(as_list=True) - - return frappe._dict(res) if res else frappe._dict() + return query.run(as_dict=True) def get_available_qty_from_sabb(self): batches = [d.batch_no for d in self.entries if d.batch_no] @@ -1487,7 +1500,9 @@ class SerialandBatchBundle(Document): .on(parent.name == child.parent) .select( child.batch_no, - Sum(child.qty).as_("total_qty"), + child.qty, + CombineDatetime(parent.posting_date, parent.posting_time).as_("posting_datetime"), + parent.creation, ) .where( (parent.warehouse == self.warehouse) @@ -1498,14 +1513,11 @@ class SerialandBatchBundle(Document): & (parent.type_of_transaction.isin(["Inward", "Outward"])) ) .for_update() - .groupby(child.batch_no) ) query = query.where(parent.voucher_type != "Pick List") - res = query.run(as_list=True) - - return frappe._dict(res) if res else frappe._dict() + return query.run(as_dict=True) def validate_voucher_no_docstatus(self): if self.voucher_type == "POS Invoice":