From d68a04ad169cd08f34219bbdc31fee765a3e3f76 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 22 Jan 2026 23:56:06 +0530 Subject: [PATCH] fix: negative stock for purchae return --- erpnext/stock/deprecated_serial_batch.py | 3 - .../purchase_receipt/test_purchase_receipt.py | 39 ++++++ .../serial_and_batch_bundle.py | 120 ++++++++++++++++-- erpnext/stock/serial_batch_bundle.py | 51 -------- 4 files changed, 148 insertions(+), 65 deletions(-) diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 63ff38649ab..3ff57b69493 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -97,7 +97,6 @@ class DeprecatedBatchNoValuation: for ledger in entries: self.stock_value_differece[ledger.batch_no] += flt(ledger.batch_value) self.available_qty[ledger.batch_no] += flt(ledger.batch_qty) - self.total_qty[ledger.batch_no] += flt(ledger.batch_qty) @deprecated( "erpnext.stock.serial_batch_bundle.BatchNoValuation.get_sle_for_batches", @@ -269,7 +268,6 @@ class DeprecatedBatchNoValuation: batch_data = query.run(as_dict=True) for d in batch_data: self.available_qty[d.batch_no] += flt(d.batch_qty) - self.total_qty[d.batch_no] += flt(d.batch_qty) for d in batch_data: if self.available_qty.get(d.batch_no): @@ -381,7 +379,6 @@ class DeprecatedBatchNoValuation: batch_data = query.run(as_dict=True) for d in batch_data: self.available_qty[d.batch_no] += flt(d.batch_qty) - self.total_qty[d.batch_no] += flt(d.batch_qty) if not self.last_sle: return diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 4a1b61c76eb..b1f15e00736 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4994,6 +4994,45 @@ class TestPurchaseReceipt(IntegrationTestCase): self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.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 + + item_code = make_item( + "Test Negative Stock for Purchase Return Item", + {"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TNSFPRI.#####"}, + ).name + + pr = make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -3), + qty=10, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) + + make_purchase_receipt( + item_code=item_code, + posting_date=add_days(today(), -4), + qty=10, + rate=100, + warehouse="_Test Warehouse - _TC", + ) + + make_stock_entry( + item_code=item_code, + qty=10, + source="_Test Warehouse - _TC", + target="_Test Warehouse 1 - _TC", + batch_no=batch_no, + use_serial_batch_fields=1, + ) + + return_pr = make_return_doc("Purchase Receipt", pr.name) + self.assertRaises(frappe.ValidationError, return_pr.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 d6065dc0c3b..2a9bcd4edd6 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 @@ -575,14 +575,12 @@ class SerialandBatchBundle(Document): d.incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(d.batch_no))) precision = d.precision("qty") - for field in ["available_qty", "total_qty"]: - value = getattr(sn_obj, field) - available_qty = flt(value.get(d.batch_no), precision) - if self.docstatus == 1: - available_qty += flt(d.qty, precision) + available_qty = flt(sn_obj.available_qty.get(d.batch_no), precision) + if self.docstatus == 1: + available_qty += flt(d.qty, precision) - if not allow_negative_stock: - self.validate_negative_batch(d.batch_no, available_qty, field) + if not allow_negative_stock: + self.validate_negative_batch(d.batch_no, available_qty) d.stock_value_difference = flt(d.qty) * flt(d.incoming_rate) @@ -595,8 +593,8 @@ class SerialandBatchBundle(Document): } ) - def validate_negative_batch(self, batch_no, available_qty, field=None): - if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty, field=field): + def validate_negative_batch(self, batch_no, available_qty): + if available_qty < 0 and not self.is_stock_reco_for_valuation_adjustment(available_qty): msg = f"""Batch No {bold(batch_no)} of an Item {bold(self.item_code)} has negative stock of quantity {bold(available_qty)} in the @@ -604,7 +602,7 @@ class SerialandBatchBundle(Document): frappe.throw(_(msg), BatchNegativeStockError) - def is_stock_reco_for_valuation_adjustment(self, available_qty, field=None): + def is_stock_reco_for_valuation_adjustment(self, available_qty): if ( self.voucher_type == "Stock Reconciliation" and self.type_of_transaction == "Outward" @@ -612,7 +610,6 @@ class SerialandBatchBundle(Document): and ( abs(frappe.db.get_value("Stock Reconciliation Item", self.voucher_detail_no, "qty")) == abs(available_qty) - or field == "total_qty" ) ): return True @@ -1343,6 +1340,7 @@ class SerialandBatchBundle(Document): def on_submit(self): self.validate_docstatus() self.validate_serial_nos_inventory() + self.validate_batch_quantity() def validate_docstatus(self): for row in self.entries: @@ -1436,6 +1434,106 @@ class SerialandBatchBundle(Document): def on_cancel(self): self.validate_voucher_no_docstatus() + self.validate_batch_quantity() + + def validate_batch_quantity(self): + if not self.has_batch_no: + return + + if self.type_of_transaction != "Outward" or ( + self.voucher_type == "Stock Reconciliation" and self.type_of_transaction == "Outward" + ): + return + + batch_wise_available_qty = self.get_batchwise_available_qty() + precision = frappe.get_precision("Serial and Batch Entry", "qty") + + 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"), + ) + + 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() + + if not available_qty_from_ledger: + return available_qty + + for batch_no, qty in available_qty_from_ledger.items(): + if batch_no in available_qty: + available_qty[batch_no] += qty + else: + available_qty[batch_no] = qty + + return available_qty + + def get_available_qty_from_stock_ledger(self): + batches = [d.batch_no for d in self.entries if d.batch_no] + + sle = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(sle) + .select( + sle.batch_no, + Sum(sle.actual_qty).as_("available_qty"), + ) + .where( + (sle.item_code == self.item_code) + & (sle.warehouse == self.warehouse) + & (sle.is_cancelled == 0) + & (sle.batch_no.isin(batches)) + & (sle.docstatus == 1) + & (sle.serial_and_batch_bundle.isnull()) + & (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() + + def get_available_qty_from_sabb(self): + batches = [d.batch_no for d in self.entries if d.batch_no] + + child = frappe.qb.DocType("Serial and Batch Entry") + + query = ( + frappe.qb.from_(child) + .select( + child.batch_no, + Sum(child.qty).as_("available_qty"), + ) + .where( + (child.item_code == self.item_code) + & (child.warehouse == self.warehouse) + & (child.is_cancelled == 0) + & (child.batch_no.isin(batches)) + & (child.docstatus == 1) + & (child.type_of_transaction.isin(["Inward", "Outward"])) + ) + .for_update() + .groupby(child.batch_no) + ) + query = query.where(child.voucher_type != "Pick List") + + res = query.run(as_list=True) + + return frappe._dict(res) if res else frappe._dict() def validate_voucher_no_docstatus(self): if self.voucher_type == "POS Invoice": diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 6a63a73158c..2430d0e445a 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -808,62 +808,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation): for ledger in entries: self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate) self.available_qty[ledger.batch_no] += flt(ledger.qty) - self.total_qty[ledger.batch_no] += flt(ledger.qty) - - entries = self.get_batch_stock_after_date() - for row in entries: - self.total_qty[row.batch_no] += flt(row.total_qty) self.calculate_avg_rate_from_deprecarated_ledgers() self.calculate_avg_rate_for_non_batchwise_valuation() self.set_stock_value_difference() - def get_batch_stock_after_date(self) -> list[dict]: - # Get total qty of each batch no from Serial and Batch Bundle without checking time condition - if not self.batchwise_valuation_batches: - return [] - - child = frappe.qb.DocType("Serial and Batch Entry") - - timestamp_condition = "" - if self.sle.posting_datetime: - timestamp_condition = child.posting_datetime > self.sle.posting_datetime - - if self.sle.creation: - timestamp_condition |= (child.posting_datetime == self.sle.posting_datetime) & ( - child.creation > self.sle.creation - ) - - query = ( - frappe.qb.from_(child) - .select( - child.batch_no, - Sum(child.qty).as_("total_qty"), - ) - .where( - (child.item_code == self.sle.item_code) - & (child.warehouse == self.sle.warehouse) - & (child.batch_no.isin(self.batchwise_valuation_batches)) - & (child.docstatus == 1) - & (child.type_of_transaction.isin(["Inward", "Outward"])) - ) - .for_update() - .groupby(child.batch_no) - ) - - # Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference - if self.sle.voucher_detail_no: - query = query.where(child.voucher_detail_no != self.sle.voucher_detail_no) - elif self.sle.voucher_no: - query = query.where(child.voucher_no != self.sle.voucher_no) - - query = query.where(child.voucher_type != "Pick List") - - if timestamp_condition: - query = query.where(timestamp_condition) - - return query.run(as_dict=True) - def get_batch_stock_before_date(self) -> list[dict]: # Get batch wise stock value difference from Serial and Batch Bundle considering time condition if not self.batchwise_valuation_batches: