fix: Negative stock validation against inventory dimension (#43834)
(cherry picked from commit c330a292d2)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
This commit is contained in:
@@ -16,6 +16,7 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import InventoryDimensionNegativeStockError
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
|
||||
@@ -426,39 +427,49 @@ class TestInventoryDimension(FrappeTestCase):
|
||||
|
||||
warehouse = create_warehouse("Negative Stock Warehouse")
|
||||
|
||||
# Try issuing 10 qty, more than available stock against inventory dimension
|
||||
doc = make_stock_entry(item_code=item_code, source=warehouse, qty=10, do_not_submit=True)
|
||||
doc.items[0].inv_site = "Site 1"
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
self.assertRaises(InventoryDimensionNegativeStockError, doc.submit)
|
||||
|
||||
# cancel the stock entry
|
||||
doc.reload()
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
|
||||
# Receive 10 qty against inventory dimension
|
||||
doc = make_stock_entry(item_code=item_code, target=warehouse, qty=10, do_not_submit=True)
|
||||
|
||||
doc.items[0].to_inv_site = "Site 1"
|
||||
doc.submit()
|
||||
|
||||
# check inventory dimension value in stock ledger entry
|
||||
site_name = frappe.get_all(
|
||||
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
|
||||
)[0].inv_site
|
||||
|
||||
self.assertEqual(site_name, "Site 1")
|
||||
|
||||
# Receive another 100 qty without inventory dimension
|
||||
doc = make_stock_entry(item_code=item_code, target=warehouse, qty=100)
|
||||
|
||||
# Try issuing 100 qty, more than available stock against inventory dimension
|
||||
# Note: total available qty for the item is 110, but against inventory dimension, only 10 qty is available
|
||||
doc = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True)
|
||||
|
||||
doc.items[0].inv_site = "Site 1"
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
self.assertRaises(InventoryDimensionNegativeStockError, doc.submit)
|
||||
|
||||
# disable validate_negative_stock for inventory dimension
|
||||
inv_dimension.reload()
|
||||
inv_dimension.db_set("validate_negative_stock", 0)
|
||||
frappe.local.inventory_dimensions = {}
|
||||
|
||||
# Try issuing 100 qty, more than available stock against inventory dimension
|
||||
doc = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True)
|
||||
|
||||
doc.items[0].inv_site = "Site 1"
|
||||
doc.submit()
|
||||
self.assertEqual(doc.docstatus, 1)
|
||||
|
||||
# check inventory dimension value in stock ledger entry
|
||||
site_name = frappe.get_all(
|
||||
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
|
||||
)[0].inv_site
|
||||
|
||||
@@ -8,6 +8,7 @@ import frappe
|
||||
from frappe import _, bold
|
||||
from frappe.core.doctype.role.role import get_users
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
|
||||
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
@@ -25,6 +26,10 @@ class BackDatedStockTransaction(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class InventoryDimensionNegativeStockError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
exclude_from_linked_with = True
|
||||
|
||||
|
||||
@@ -104,61 +109,56 @@ class StockLedgerEntry(Document):
|
||||
self.posting_datetime = get_combine_datetime(self.posting_date, self.posting_time)
|
||||
|
||||
def validate_inventory_dimension_negative_stock(self):
|
||||
if self.is_cancelled:
|
||||
if self.is_cancelled or self.actual_qty >= 0:
|
||||
return
|
||||
|
||||
extra_cond = ""
|
||||
kwargs = {}
|
||||
|
||||
dimensions = self._get_inventory_dimensions()
|
||||
if not dimensions:
|
||||
return
|
||||
|
||||
for dimension, values in dimensions.items():
|
||||
kwargs[dimension] = values.get("value")
|
||||
extra_cond += f" and {dimension} = %({dimension})s"
|
||||
|
||||
kwargs.update(
|
||||
{
|
||||
"item_code": self.item_code,
|
||||
"warehouse": self.warehouse,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
"company": self.company,
|
||||
"sle": self.name,
|
||||
}
|
||||
)
|
||||
|
||||
sle = get_previous_sle(kwargs, extra_cond=extra_cond)
|
||||
qty_after_transaction = 0.0
|
||||
flt_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
if sle:
|
||||
qty_after_transaction = sle.qty_after_transaction
|
||||
for dimension, values in dimensions.items():
|
||||
dimension_value = values.get("value")
|
||||
available_qty = self.get_available_qty_after_prev_transaction(dimension, dimension_value)
|
||||
|
||||
diff = qty_after_transaction + flt(self.actual_qty)
|
||||
diff = flt(diff, flt_precision)
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
self.throw_validation_error(diff, dimensions)
|
||||
diff = flt(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction
|
||||
if diff < 0 and abs(diff) > 0.0001:
|
||||
self.throw_validation_error(diff, dimension, dimension_value)
|
||||
|
||||
def throw_validation_error(self, diff, dimensions):
|
||||
dimension_msg = _(", with the inventory {0}: {1}").format(
|
||||
"dimensions" if len(dimensions) > 1 else "dimension",
|
||||
", ".join(f"{bold(d.doctype)} ({d.value})" for k, d in dimensions.items()),
|
||||
)
|
||||
def get_available_qty_after_prev_transaction(self, dimension, dimension_value):
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
available_qty = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.actual_qty))
|
||||
.where(
|
||||
(sle.item_code == self.item_code)
|
||||
& (sle.warehouse == self.warehouse)
|
||||
& (sle.posting_datetime < self.posting_datetime)
|
||||
& (sle.company == self.company)
|
||||
& (sle.is_cancelled == 0)
|
||||
& (sle[dimension] == dimension_value)
|
||||
)
|
||||
).run()
|
||||
|
||||
return available_qty[0][0] or 0
|
||||
|
||||
def throw_validation_error(self, diff, dimension, dimension_value):
|
||||
msg = _(
|
||||
"{0} units of {1} are required in {2}{3}, on {4} {5} for {6} to complete the transaction."
|
||||
"{0} units of {1} are required in {2} with the inventory dimension: {3} ({4}) on {5} {6} for {7} to complete the transaction."
|
||||
).format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Item", self.item_code),
|
||||
frappe.get_desk_link("Warehouse", self.warehouse),
|
||||
dimension_msg,
|
||||
frappe.bold(dimension),
|
||||
frappe.bold(dimension_value),
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
frappe.get_desk_link(self.voucher_type, self.voucher_no),
|
||||
)
|
||||
|
||||
frappe.throw(msg, title=_("Inventory Dimension Negative Stock"))
|
||||
frappe.throw(
|
||||
msg, title=_("Inventory Dimension Negative Stock"), exc=InventoryDimensionNegativeStockError
|
||||
)
|
||||
|
||||
def _get_inventory_dimensions(self):
|
||||
inv_dimensions = get_inventory_dimensions()
|
||||
|
||||
Reference in New Issue
Block a user