fix: negative stock validation

This commit is contained in:
Rohit Waghchaure
2025-11-04 13:04:32 +05:30
parent 689eee767d
commit eca71dce54
2 changed files with 37 additions and 28 deletions

View File

@@ -1026,7 +1026,14 @@ class TestDeliveryNote(IntegrationTestCase):
def test_sales_invoice_qty_after_return(self): def test_sales_invoice_qty_after_return(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
dn = create_delivery_note(qty=10) item = make_item(
"Test Sales Invoice Qty After Return",
properties={"is_stock_item": 1, "stock_uom": "Nos"},
)
make_stock_entry(item_code=item.name, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
dn = create_delivery_note(item_code=item.name, qty=10)
dnr1 = make_sales_return(dn.name) dnr1 = make_sales_return(dn.name)
dnr1.get("items")[0].qty = -3 dnr1.get("items")[0].qty = -3
@@ -1042,8 +1049,8 @@ class TestDeliveryNote(IntegrationTestCase):
self.assertEqual(si.get("items")[0].qty, 5) self.assertEqual(si.get("items")[0].qty, 5)
si.reload().cancel().delete() si.reload().cancel().delete()
dnr1.reload().cancel().delete()
dnr2.reload().cancel().delete() dnr2.reload().cancel().delete()
dnr1.reload().cancel().delete()
dn.reload().cancel().delete() dn.reload().cancel().delete()
def test_dn_billing_status_case3(self): def test_dn_billing_status_case3(self):

View File

@@ -8,6 +8,7 @@ import json
import frappe import frappe
from frappe import _, bold, scrub from frappe import _, bold, scrub
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.query_builder import Order
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Sum
from frappe.utils import ( from frappe.utils import (
add_to_date, add_to_date,
@@ -67,8 +68,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
from erpnext.controllers.stock_controller import future_sle_exists from erpnext.controllers.stock_controller import future_sle_exists
if sl_entries: if sl_entries:
cancel = sl_entries[0].get("is_cancelled") cancelled = sl_entries[0].get("is_cancelled")
if cancel: if cancelled:
validate_cancellation(sl_entries) validate_cancellation(sl_entries)
set_as_cancel(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no")) set_as_cancel(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no"))
@@ -79,7 +80,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
if sle.serial_no and not via_landed_cost_voucher: if sle.serial_no and not via_landed_cost_voucher:
validate_serial_no(sle) validate_serial_no(sle)
if cancel: if cancelled:
sle["actual_qty"] = -flt(sle.get("actual_qty")) sle["actual_qty"] = -flt(sle.get("actual_qty"))
if sle["actual_qty"] < 0 and not sle.get("outgoing_rate"): if sle["actual_qty"] < 0 and not sle.get("outgoing_rate"):
@@ -108,7 +109,9 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
if is_stock_item: if is_stock_item:
bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse")) bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
args.reserved_stock = flt(frappe.db.get_value("Bin", bin_name, "reserved_stock")) args.reserved_stock = flt(frappe.db.get_value("Bin", bin_name, "reserved_stock"))
repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher) repost_current_voucher(
args, allow_negative_stock, via_landed_cost_voucher, cancelled=cancelled
)
update_bin_qty(bin_name, args) update_bin_qty(bin_name, args)
else: else:
frappe.msgprint( frappe.msgprint(
@@ -116,7 +119,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
) )
def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_voucher=False): def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_voucher=False, cancelled=False):
if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation": if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation":
if not args.get("posting_date"): if not args.get("posting_date"):
args["posting_date"] = nowdate() args["posting_date"] = nowdate()
@@ -135,6 +138,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou
"sle_id": args.get("name"), "sle_id": args.get("name"),
"creation": args.get("creation"), "creation": args.get("creation"),
"reserved_stock": args.get("reserved_stock"), "reserved_stock": args.get("reserved_stock"),
"cancelled": cancelled,
}, },
allow_negative_stock=allow_negative_stock, allow_negative_stock=allow_negative_stock,
via_landed_cost_voucher=via_landed_cost_voucher, via_landed_cost_voucher=via_landed_cost_voucher,
@@ -667,33 +671,31 @@ class update_entries_after:
def process_sle_against_current_timestamp(self): def process_sle_against_current_timestamp(self):
sl_entries = self.get_sle_against_current_voucher() sl_entries = self.get_sle_against_current_voucher()
for sle in sl_entries: for sle in sl_entries:
sle["timestamp"] = sle.posting_datetime
self.process_sle(sle) self.process_sle(sle)
def get_sle_against_current_voucher(self): def get_sle_against_current_voucher(self):
self.args["posting_datetime"] = get_combine_datetime(self.args.posting_date, self.args.posting_time) self.args["posting_datetime"] = get_combine_datetime(self.args.posting_date, self.args.posting_time)
doctype = frappe.qb.DocType("Stock Ledger Entry")
return frappe.db.sql( query = (
""" frappe.qb.from_(doctype)
select .select("*")
*, posting_datetime as "timestamp" .where(
from (doctype.item_code == self.args.item_code)
`tabStock Ledger Entry` & (doctype.warehouse == self.args.warehouse)
where & (doctype.is_cancelled == 0)
item_code = %(item_code)s & (doctype.posting_datetime == self.args.posting_datetime)
and warehouse = %(warehouse)s )
and is_cancelled = 0 .orderby(doctype.creation, order=Order.asc)
and ( .for_update()
posting_datetime = %(posting_datetime)s
)
and creation = %(creation)s
order by
creation ASC
for update
""",
self.args,
as_dict=1,
) )
if not self.args.get("cancelled"):
query = query.where(doctype.creation == self.args.creation)
return query.run(as_dict=True)
def get_future_entries_to_fix(self): def get_future_entries_to_fix(self):
# includes current entry! # includes current entry!
args = self.data[self.args.warehouse].previous_sle or frappe._dict( args = self.data[self.args.warehouse].previous_sle or frappe._dict(
@@ -1715,7 +1717,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc
voucher_no = args.get("voucher_no") voucher_no = args.get("voucher_no")
voucher_condition = f"and voucher_no != '{voucher_no}'" voucher_condition = f"and voucher_no != '{voucher_no}'"
elif args.get("creation") and args.get("sle_id"): elif args.get("creation") and args.get("sle_id") and not args.get("cancelled"):
creation = args.get("creation") creation = args.get("creation")
operator = "<=" operator = "<="
voucher_condition = f"and creation < '{creation}'" voucher_condition = f"and creation < '{creation}'"