* feat: validate negative stock for inventory dimension (#37373) * feat: validate negative stock for inventory dimension * test: test case for validate negative stock for inv dimension (cherry picked from commit 1480acabb0faeae61c7c055bb7d1e81877b87cfb) # Conflicts: # erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py # erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py # erpnext/stock/stock_ledger.py * chore: fix conflicts * chore: fix conflicts * chore: fix conflicts * chore: fix linter issue * chore: fix linter issue * chore: fix linter issue * chore: fix linter issue * chore: fix linter issue --------- Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
This commit is contained in:
parent
6e3e4c8ade
commit
27a1e3bf83
@ -37,7 +37,7 @@ frappe.ui.form.on('Inventory Dimension', {
|
||||
if (frm.doc.__onload && frm.doc.__onload.has_stock_ledger
|
||||
&& frm.doc.__onload.has_stock_ledger.length) {
|
||||
let allow_to_edit_fields = ['disabled', 'fetch_from_parent',
|
||||
'type_of_transaction', 'condition', 'mandatory_depends_on'];
|
||||
'type_of_transaction', 'condition', 'mandatory_depends_on', 'validate_negative_stock'];
|
||||
|
||||
frm.fields.forEach((field) => {
|
||||
if (!in_list(allow_to_edit_fields, field.df.fieldname)) {
|
||||
|
@ -17,6 +17,8 @@
|
||||
"target_fieldname",
|
||||
"applicable_for_documents_tab",
|
||||
"apply_to_all_doctypes",
|
||||
"column_break_niy2u",
|
||||
"validate_negative_stock",
|
||||
"column_break_13",
|
||||
"document_type",
|
||||
"type_of_transaction",
|
||||
@ -173,11 +175,21 @@
|
||||
"fieldname": "reqd",
|
||||
"fieldtype": "Check",
|
||||
"label": "Mandatory"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_niy2u",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "validate_negative_stock",
|
||||
"fieldtype": "Check",
|
||||
"label": "Validate Negative Stock"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2023-01-31 13:44:38.507698",
|
||||
"modified": "2023-10-05 12:52:18.705431",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Inventory Dimension",
|
||||
|
@ -60,6 +60,7 @@ class InventoryDimension(Document):
|
||||
"fetch_from_parent",
|
||||
"type_of_transaction",
|
||||
"condition",
|
||||
"validate_negative_stock",
|
||||
]
|
||||
|
||||
for field in frappe.get_meta("Inventory Dimension").fields:
|
||||
@ -160,6 +161,7 @@ class InventoryDimension(Document):
|
||||
insert_after="inventory_dimension",
|
||||
options=self.reference_document,
|
||||
label=label,
|
||||
search_index=1,
|
||||
reqd=self.reqd,
|
||||
mandatory_depends_on=self.mandatory_depends_on,
|
||||
),
|
||||
@ -255,7 +257,7 @@ def field_exists(doctype, fieldname) -> str or None:
|
||||
def get_inventory_documents(
|
||||
doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None
|
||||
):
|
||||
and_filters = [["DocField", "parent", "not in", ["Batch", "Serial No"]]]
|
||||
and_filters = [["DocField", "parent", "not in", ["Batch", "Serial No", "Item Price"]]]
|
||||
or_filters = [
|
||||
["DocField", "options", "in", ["Batch", "Serial No"]],
|
||||
["DocField", "parent", "in", ["Putaway Rule"]],
|
||||
@ -340,6 +342,7 @@ def get_inventory_dimensions():
|
||||
fields=[
|
||||
"distinct target_fieldname as fieldname",
|
||||
"reference_document as doctype",
|
||||
"validate_negative_stock",
|
||||
],
|
||||
filters={"disabled": 0},
|
||||
)
|
||||
|
@ -414,6 +414,53 @@ class TestInventoryDimension(FrappeTestCase):
|
||||
else:
|
||||
self.assertEqual(d.store, "Inter Transfer Store 2")
|
||||
|
||||
def test_validate_negative_stock_for_inventory_dimension(self):
|
||||
frappe.local.inventory_dimensions = {}
|
||||
item_code = "Test Negative Inventory Dimension Item"
|
||||
frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1)
|
||||
create_item(item_code)
|
||||
|
||||
inv_dimension = create_inventory_dimension(
|
||||
apply_to_all_doctypes=1,
|
||||
dimension_name="Inv Site",
|
||||
reference_document="Inv Site",
|
||||
document_type="Inv Site",
|
||||
validate_negative_stock=1,
|
||||
)
|
||||
|
||||
warehouse = create_warehouse("Negative Stock Warehouse")
|
||||
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()
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
inv_dimension.reload()
|
||||
inv_dimension.db_set("validate_negative_stock", 0)
|
||||
frappe.local.inventory_dimensions = {}
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def get_voucher_sl_entries(voucher_no, fields):
|
||||
return frappe.get_all(
|
||||
@ -504,6 +551,26 @@ def prepare_test_data():
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
if not frappe.db.exists("DocType", "Inv Site"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "DocType",
|
||||
"name": "Inv Site",
|
||||
"module": "Stock",
|
||||
"custom": 1,
|
||||
"naming_rule": "By fieldname",
|
||||
"autoname": "field:site_name",
|
||||
"fields": [{"label": "Site Name", "fieldname": "site_name", "fieldtype": "Data"}],
|
||||
"permissions": [
|
||||
{"role": "System Manager", "permlevel": 0, "read": 1, "write": 1, "create": 1, "delete": 1}
|
||||
],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
for site in ["Site 1", "Site 2"]:
|
||||
if not frappe.db.exists("Inv Site", site):
|
||||
frappe.get_doc({"doctype": "Inv Site", "site_name": site}).insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def create_inventory_dimension(**args):
|
||||
args = frappe._dict(args)
|
||||
|
@ -5,14 +5,16 @@
|
||||
from datetime import date
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe import _, bold
|
||||
from frappe.core.doctype.role.role import get_users
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, cint, 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.controllers.item_variant import ItemTemplateCannotHaveStock
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.serial_batch_bundle import SerialBatchBundle
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
|
||||
class StockFreezeError(frappe.ValidationError):
|
||||
@ -48,6 +50,69 @@ class StockLedgerEntry(Document):
|
||||
self.validate_and_set_fiscal_year()
|
||||
self.block_transactions_against_group_warehouse()
|
||||
self.validate_with_last_transaction_posting_time()
|
||||
self.validate_inventory_dimension_negative_stock()
|
||||
|
||||
def validate_inventory_dimension_negative_stock(self):
|
||||
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 = get_previous_sle(kwargs, extra_cond=extra_cond)
|
||||
if sle:
|
||||
flt_precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
diff = sle.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)
|
||||
|
||||
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()),
|
||||
)
|
||||
|
||||
msg = _(
|
||||
"{0} units of {1} are required in {2}{3}, on {4} {5} for {6} to complete the transaction."
|
||||
).format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Item", self.item_code),
|
||||
frappe.get_desk_link("Warehouse", self.warehouse),
|
||||
dimension_msg,
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
frappe.get_desk_link(self.voucher_type, self.voucher_no),
|
||||
)
|
||||
|
||||
frappe.throw(msg, title=_("Inventory Dimension Negative Stock"))
|
||||
|
||||
def _get_inventory_dimensions(self):
|
||||
inv_dimensions = get_inventory_dimensions()
|
||||
inv_dimension_dict = {}
|
||||
for dimension in inv_dimensions:
|
||||
if not dimension.get("validate_negative_stock") or not self.get(dimension.fieldname):
|
||||
continue
|
||||
|
||||
dimension["value"] = self.get(dimension.fieldname)
|
||||
inv_dimension_dict.setdefault(dimension.fieldname, dimension)
|
||||
|
||||
return inv_dimension_dict
|
||||
|
||||
def on_submit(self):
|
||||
self.check_stock_frozen_date()
|
||||
|
@ -12,6 +12,7 @@ import erpnext
|
||||
from erpnext.accounts.utils import get_company_default
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_available_serial_nos,
|
||||
)
|
||||
@ -50,6 +51,7 @@ class StockReconciliation(StockController):
|
||||
self.clean_serial_nos()
|
||||
self.set_total_qty_and_amount()
|
||||
self.validate_putaway_capacity()
|
||||
self.validate_inventory_dimension()
|
||||
|
||||
if self._action == "submit":
|
||||
self.validate_reserved_stock()
|
||||
@ -57,6 +59,17 @@ class StockReconciliation(StockController):
|
||||
def on_update(self):
|
||||
self.set_serial_and_batch_bundle(ignore_validate=True)
|
||||
|
||||
def validate_inventory_dimension(self):
|
||||
dimensions = get_inventory_dimensions()
|
||||
for dimension in dimensions:
|
||||
for row in self.items:
|
||||
if not row.batch_no and row.current_qty and row.get(dimension.get("fieldname")):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: You cannot use the inventory dimension '{1}' in Stock Reconciliation to modify the quantity or valuation rate. Stock reconciliation with inventory dimensions is intended solely for performing opening entries."
|
||||
).format(row.idx, bold(dimension.get("doctype")))
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
self.update_stock_ledger()
|
||||
self.make_gl_entries()
|
||||
@ -202,8 +215,19 @@ class StockReconciliation(StockController):
|
||||
self.calculate_difference_amount(item, bundle_data)
|
||||
return True
|
||||
|
||||
inventory_dimensions_dict = {}
|
||||
if not item.batch_no and not item.serial_no:
|
||||
for dimension in get_inventory_dimensions():
|
||||
if item.get(dimension.get("fieldname")):
|
||||
inventory_dimensions_dict[dimension.get("fieldname")] = item.get(dimension.get("fieldname"))
|
||||
|
||||
item_dict = get_stock_balance_for(
|
||||
item.item_code, item.warehouse, self.posting_date, self.posting_time, batch_no=item.batch_no
|
||||
item.item_code,
|
||||
item.warehouse,
|
||||
self.posting_date,
|
||||
self.posting_time,
|
||||
batch_no=item.batch_no,
|
||||
inventory_dimensions_dict=inventory_dimensions_dict,
|
||||
)
|
||||
|
||||
if (item.qty is None or item.qty == item_dict.get("qty")) and (
|
||||
@ -507,7 +531,13 @@ class StockReconciliation(StockController):
|
||||
if not row.batch_no:
|
||||
data.qty_after_transaction = flt(row.qty, row.precision("qty"))
|
||||
|
||||
if self.docstatus == 2:
|
||||
dimensions = get_inventory_dimensions()
|
||||
has_dimensions = False
|
||||
for dimension in dimensions:
|
||||
if row.get(dimension.get("fieldname")):
|
||||
has_dimensions = True
|
||||
|
||||
if self.docstatus == 2 and (not row.batch_no or not row.serial_and_batch_bundle):
|
||||
if row.current_qty:
|
||||
data.actual_qty = -1 * row.current_qty
|
||||
data.qty_after_transaction = flt(row.current_qty)
|
||||
@ -523,6 +553,13 @@ class StockReconciliation(StockController):
|
||||
data.valuation_rate = flt(row.valuation_rate)
|
||||
data.stock_value_difference = -1 * flt(row.amount_difference)
|
||||
|
||||
elif (
|
||||
self.docstatus == 1 and has_dimensions and (not row.batch_no or not row.serial_and_batch_bundle)
|
||||
):
|
||||
data.actual_qty = row.qty
|
||||
data.qty_after_transaction = 0.0
|
||||
data.incoming_rate = flt(row.valuation_rate)
|
||||
|
||||
self.update_inventory_dimensions(row, data)
|
||||
|
||||
return data
|
||||
@ -911,6 +948,7 @@ def get_stock_balance_for(
|
||||
posting_time,
|
||||
batch_no: Optional[str] = None,
|
||||
with_valuation_rate: bool = True,
|
||||
inventory_dimensions_dict=None,
|
||||
):
|
||||
frappe.has_permission("Stock Reconciliation", "write", throw=True)
|
||||
|
||||
@ -939,6 +977,7 @@ def get_stock_balance_for(
|
||||
posting_time,
|
||||
with_valuation_rate=with_valuation_rate,
|
||||
with_serial_no=has_serial_no,
|
||||
inventory_dimensions_dict=inventory_dimensions_dict,
|
||||
)
|
||||
|
||||
if has_serial_no:
|
||||
|
@ -24,6 +24,7 @@ from frappe.utils import (
|
||||
|
||||
import erpnext
|
||||
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock,
|
||||
)
|
||||
@ -711,10 +712,17 @@ class update_entries_after(object):
|
||||
):
|
||||
sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle)
|
||||
|
||||
dimensions = get_inventory_dimensions()
|
||||
has_dimensions = False
|
||||
if dimensions:
|
||||
for dimension in dimensions:
|
||||
if sle.get(dimension.get("fieldname")):
|
||||
has_dimensions = True
|
||||
|
||||
if sle.serial_and_batch_bundle:
|
||||
self.calculate_valuation_for_serial_batch_bundle(sle)
|
||||
else:
|
||||
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
|
||||
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and not has_dimensions:
|
||||
# assert
|
||||
self.wh_data.valuation_rate = sle.valuation_rate
|
||||
self.wh_data.qty_after_transaction = sle.qty_after_transaction
|
||||
@ -1297,7 +1305,7 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc
|
||||
return sle[0] if sle else frappe._dict()
|
||||
|
||||
|
||||
def get_previous_sle(args, for_update=False):
|
||||
def get_previous_sle(args, for_update=False, extra_cond=None):
|
||||
"""
|
||||
get the last sle on or before the current time-bucket,
|
||||
to get actual qty before transaction, this function
|
||||
@ -1312,7 +1320,9 @@ def get_previous_sle(args, for_update=False):
|
||||
}
|
||||
"""
|
||||
args["name"] = args.get("sle", None) or ""
|
||||
sle = get_stock_ledger_entries(args, "<=", "desc", "limit 1", for_update=for_update)
|
||||
sle = get_stock_ledger_entries(
|
||||
args, "<=", "desc", "limit 1", for_update=for_update, extra_cond=extra_cond
|
||||
)
|
||||
return sle and sle[0] or {}
|
||||
|
||||
|
||||
@ -1324,6 +1334,7 @@ def get_stock_ledger_entries(
|
||||
for_update=False,
|
||||
debug=False,
|
||||
check_serial_no=True,
|
||||
extra_cond=None,
|
||||
):
|
||||
"""get stock ledger entries filtered by specific posting datetime conditions"""
|
||||
conditions = " and timestamp(posting_date, posting_time) {0} timestamp(%(posting_date)s, %(posting_time)s)".format(
|
||||
@ -1361,6 +1372,9 @@ def get_stock_ledger_entries(
|
||||
if operator in (">", "<=") and previous_sle.get("name"):
|
||||
conditions += " and name!=%(name)s"
|
||||
|
||||
if extra_cond:
|
||||
conditions += f"{extra_cond}"
|
||||
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select *, timestamp(posting_date, posting_time) as "timestamp"
|
||||
|
@ -95,6 +95,7 @@ def get_stock_balance(
|
||||
posting_time=None,
|
||||
with_valuation_rate=False,
|
||||
with_serial_no=False,
|
||||
inventory_dimensions_dict=None,
|
||||
):
|
||||
"""Returns stock balance quantity at given warehouse on given posting date or current date.
|
||||
|
||||
@ -114,7 +115,13 @@ def get_stock_balance(
|
||||
"posting_time": posting_time,
|
||||
}
|
||||
|
||||
last_entry = get_previous_sle(args)
|
||||
extra_cond = ""
|
||||
if inventory_dimensions_dict:
|
||||
for field, value in inventory_dimensions_dict.items():
|
||||
args[field] = value
|
||||
extra_cond += f" and {field} = %({field})s"
|
||||
|
||||
last_entry = get_previous_sle(args, extra_cond=extra_cond)
|
||||
|
||||
if with_valuation_rate:
|
||||
if with_serial_no:
|
||||
|
Loading…
Reference in New Issue
Block a user