fix: negative stock for purchae return

This commit is contained in:
Rohit Waghchaure
2026-01-22 23:56:06 +05:30
parent 4b3000b071
commit d68a04ad16
4 changed files with 148 additions and 65 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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":

View File

@@ -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: