fix: Negative stock validation against inventory dimension (backport #43834) (#43846)

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:
mergify[bot]
2024-11-11 16:12:01 +05:30
committed by GitHub
parent 363f15124e
commit b314f3839b
2 changed files with 51 additions and 40 deletions

View File

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

View File

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