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.item.test_item import create_item
|
||||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
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_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
|
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||||
|
|
||||||
|
|
||||||
@@ -426,39 +427,49 @@ class TestInventoryDimension(FrappeTestCase):
|
|||||||
|
|
||||||
warehouse = create_warehouse("Negative Stock Warehouse")
|
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 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, do_not_submit=True)
|
||||||
doc.items[0].inv_site = "Site 1"
|
doc.items[0].inv_site = "Site 1"
|
||||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
self.assertRaises(InventoryDimensionNegativeStockError, doc.submit)
|
||||||
|
|
||||||
|
# cancel the stock entry
|
||||||
doc.reload()
|
doc.reload()
|
||||||
if doc.docstatus == 1:
|
if doc.docstatus == 1:
|
||||||
doc.cancel()
|
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 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, do_not_submit=True)
|
||||||
|
|
||||||
doc.items[0].to_inv_site = "Site 1"
|
doc.items[0].to_inv_site = "Site 1"
|
||||||
doc.submit()
|
doc.submit()
|
||||||
|
|
||||||
|
# check inventory dimension value in stock ledger entry
|
||||||
site_name = frappe.get_all(
|
site_name = frappe.get_all(
|
||||||
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
|
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
|
||||||
)[0].inv_site
|
)[0].inv_site
|
||||||
|
|
||||||
self.assertEqual(site_name, "Site 1")
|
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 = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True)
|
||||||
|
|
||||||
doc.items[0].inv_site = "Site 1"
|
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.reload()
|
||||||
inv_dimension.db_set("validate_negative_stock", 0)
|
inv_dimension.db_set("validate_negative_stock", 0)
|
||||||
frappe.local.inventory_dimensions = {}
|
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 = make_stock_entry(item_code=item_code, source=warehouse, qty=100, do_not_submit=True)
|
||||||
|
|
||||||
doc.items[0].inv_site = "Site 1"
|
doc.items[0].inv_site = "Site 1"
|
||||||
doc.submit()
|
doc.submit()
|
||||||
self.assertEqual(doc.docstatus, 1)
|
self.assertEqual(doc.docstatus, 1)
|
||||||
|
|
||||||
|
# check inventory dimension value in stock ledger entry
|
||||||
site_name = frappe.get_all(
|
site_name = frappe.get_all(
|
||||||
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
|
"Stock Ledger Entry", filters={"voucher_no": doc.name, "is_cancelled": 0}, fields=["inv_site"]
|
||||||
)[0].inv_site
|
)[0].inv_site
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import frappe
|
|||||||
from frappe import _, bold
|
from frappe import _, bold
|
||||||
from frappe.core.doctype.role.role import get_users
|
from frappe.core.doctype.role.role import get_users
|
||||||
from frappe.model.document import Document
|
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 frappe.utils import add_days, cint, flt, formatdate, get_datetime, getdate
|
||||||
|
|
||||||
from erpnext.accounts.utils import get_fiscal_year
|
from erpnext.accounts.utils import get_fiscal_year
|
||||||
@@ -25,6 +26,10 @@ class BackDatedStockTransaction(frappe.ValidationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryDimensionNegativeStockError(frappe.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
exclude_from_linked_with = True
|
exclude_from_linked_with = True
|
||||||
|
|
||||||
|
|
||||||
@@ -104,61 +109,56 @@ class StockLedgerEntry(Document):
|
|||||||
self.posting_datetime = get_combine_datetime(self.posting_date, self.posting_time)
|
self.posting_datetime = get_combine_datetime(self.posting_date, self.posting_time)
|
||||||
|
|
||||||
def validate_inventory_dimension_negative_stock(self):
|
def validate_inventory_dimension_negative_stock(self):
|
||||||
if self.is_cancelled:
|
if self.is_cancelled or self.actual_qty >= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
extra_cond = ""
|
|
||||||
kwargs = {}
|
|
||||||
|
|
||||||
dimensions = self._get_inventory_dimensions()
|
dimensions = self._get_inventory_dimensions()
|
||||||
if not dimensions:
|
if not dimensions:
|
||||||
return
|
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
|
flt_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||||
if sle:
|
for dimension, values in dimensions.items():
|
||||||
qty_after_transaction = sle.qty_after_transaction
|
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(available_qty + flt(self.actual_qty), flt_precision) # qty after current transaction
|
||||||
diff = flt(diff, flt_precision)
|
if diff < 0 and abs(diff) > 0.0001:
|
||||||
if diff < 0 and abs(diff) > 0.0001:
|
self.throw_validation_error(diff, dimension, dimension_value)
|
||||||
self.throw_validation_error(diff, dimensions)
|
|
||||||
|
|
||||||
def throw_validation_error(self, diff, dimensions):
|
def get_available_qty_after_prev_transaction(self, dimension, dimension_value):
|
||||||
dimension_msg = _(", with the inventory {0}: {1}").format(
|
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||||
"dimensions" if len(dimensions) > 1 else "dimension",
|
available_qty = (
|
||||||
", ".join(f"{bold(d.doctype)} ({d.value})" for k, d in dimensions.items()),
|
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 = _(
|
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(
|
).format(
|
||||||
abs(diff),
|
abs(diff),
|
||||||
frappe.get_desk_link("Item", self.item_code),
|
frappe.get_desk_link("Item", self.item_code),
|
||||||
frappe.get_desk_link("Warehouse", self.warehouse),
|
frappe.get_desk_link("Warehouse", self.warehouse),
|
||||||
dimension_msg,
|
frappe.bold(dimension),
|
||||||
|
frappe.bold(dimension_value),
|
||||||
self.posting_date,
|
self.posting_date,
|
||||||
self.posting_time,
|
self.posting_time,
|
||||||
frappe.get_desk_link(self.voucher_type, self.voucher_no),
|
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):
|
def _get_inventory_dimensions(self):
|
||||||
inv_dimensions = get_inventory_dimensions()
|
inv_dimensions = get_inventory_dimensions()
|
||||||
|
|||||||
Reference in New Issue
Block a user