From b314f3839bfd19f81eac6d712a343d837fd4cb0f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 16:12:01 +0530 Subject: [PATCH] fix: Negative stock validation against inventory dimension (backport #43834) (#43846) fix: Negative stock validation against inventory dimension (#43834) (cherry picked from commit c330a292d2c473e5f0213ce5d5b15ed9928e65aa) Co-authored-by: Nabin Hait --- .../test_inventory_dimension.py | 21 ++++-- .../stock_ledger_entry/stock_ledger_entry.py | 70 +++++++++---------- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 8ce954d55e6..918399a7f66 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -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 diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 319303dbbb0..5aeabeeec56 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -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()