Skip to content

Commit

Permalink
feat: validate negative stock for inventory dimension (backport #37373)…
Browse files Browse the repository at this point in the history
… (#37383)

* 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 1480aca)

# 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 <[email protected]>
  • Loading branch information
mergify[bot] and rohitwaghchaure authored Oct 16, 2023
1 parent 6e3e4c8 commit 27a1e3b
Show file tree
Hide file tree
Showing 8 changed files with 218 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def do_not_update_document(self):
"fetch_from_parent",
"type_of_transaction",
"condition",
"validate_negative_stock",
]

for field in frappe.get_meta("Inventory Dimension").fields:
Expand Down Expand Up @@ -160,6 +161,7 @@ def get_dimension_fields(self, doctype=None):
insert_after="inventory_dimension",
options=self.reference_document,
label=label,
search_index=1,
reqd=self.reqd,
mandatory_depends_on=self.mandatory_depends_on,
),
Expand Down Expand Up @@ -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"]],
Expand Down Expand Up @@ -340,6 +342,7 @@ def get_inventory_dimensions():
fields=[
"distinct target_fieldname as fieldname",
"reference_document as doctype",
"validate_negative_stock",
],
filters={"disabled": 0},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,53 @@ def test_inter_transfer_return_against_inventory_dimension(self):
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(
Expand Down Expand Up @@ -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)
Expand Down
69 changes: 67 additions & 2 deletions erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -48,6 +50,69 @@ def validate(self):
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()
Expand Down
43 changes: 41 additions & 2 deletions erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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,
)
Expand Down Expand Up @@ -50,13 +51,25 @@ def validate(self):
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()

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()
Expand Down Expand Up @@ -202,8 +215,19 @@ def _changed(item):
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 (
Expand Down Expand Up @@ -507,7 +531,13 @@ def get_sle_for_items(self, row, serial_nos=None):
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)
Expand All @@ -523,6 +553,13 @@ def get_sle_for_items(self, row, serial_nos=None):
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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 27a1e3b

Please sign in to comment.