From c0df515e43707b274f7ab2df779944e668ebc68d Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 6 Oct 2023 17:55:32 +0530 Subject: [PATCH] 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 --- .../inventory_dimension.js | 2 +- .../inventory_dimension.json | 14 +++- .../inventory_dimension.py | 5 +- .../test_inventory_dimension.py | 67 +++++++++++++++++ .../stock_ledger_entry/stock_ledger_entry.py | 72 ++++++++++++++++++- .../stock_reconciliation.py | 49 ++++++++++++- erpnext/stock/stock_ledger.py | 39 +++++++++- erpnext/stock/utils.py | 9 ++- 8 files changed, 247 insertions(+), 10 deletions(-) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js index 0310682a2c17..35d1c02719cc 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js @@ -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)) { diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json index eb6102a436e3..0e4055251f0c 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.json @@ -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", diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 8bff4d514709..257d18fc33a2 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -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: @@ -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, ), @@ -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}, ) diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 2d273c66fa6d..33394e5a1159 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -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( @@ -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) 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 3ca4bad4e4b7..5ff5929216b7 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -5,14 +5,19 @@ 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 +<<<<<<< HEAD from erpnext.stock.serial_batch_bundle import SerialBatchBundle +======= +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +from erpnext.stock.stock_ledger import get_previous_sle +>>>>>>> 1480acabb0 (feat: validate negative stock for inventory dimension (#37373)) class StockFreezeError(frappe.ValidationError): @@ -48,6 +53,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() diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index e36d5769bd20..83d790116c67 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -11,10 +11,15 @@ import erpnext from erpnext.accounts.utils import get_company_default from erpnext.controllers.stock_controller import StockController +<<<<<<< HEAD from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_available_serial_nos, ) +======= +from erpnext.stock.doctype.batch.batch import get_batch_qty +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +>>>>>>> 1480acabb0 (feat: validate negative stock for inventory dimension (#37373)) from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.utils import get_stock_balance @@ -50,6 +55,7 @@ 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() @@ -57,6 +63,17 @@ def validate(self): 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() @@ -192,6 +209,7 @@ def remove_items_with_no_change(self): self.difference_amount = 0.0 def _changed(item): +<<<<<<< HEAD if item.current_serial_and_batch_bundle: bundle_data = frappe.get_all( "Serial and Batch Bundle", @@ -201,9 +219,21 @@ 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")) +>>>>>>> 1480acabb0 (feat: validate negative stock for inventory dimension (#37373)) 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 +537,17 @@ 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")) +<<<<<<< HEAD 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: +>>>>>>> 1480acabb0 (feat: validate negative stock for inventory dimension (#37373)) if row.current_qty: data.actual_qty = -1 * row.current_qty data.qty_after_transaction = flt(row.current_qty) @@ -523,6 +563,11 @@ 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: + 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 +956,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 +985,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: diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index d3807b0f9770..10d4aa828bda 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -24,9 +24,13 @@ import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty +<<<<<<< HEAD from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock, ) +======= +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +>>>>>>> 1480acabb0 (feat: validate negative stock for inventory dimension (#37373)) from erpnext.stock.utils import ( get_incoming_outgoing_rate_for_cancel, get_or_make_bin, @@ -711,10 +715,33 @@ def process_sle(self, sle): ): sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle) +<<<<<<< HEAD if sle.serial_and_batch_bundle: self.calculate_valuation_for_serial_batch_bundle(sle) - else: +======= + dimensions = get_inventory_dimensions() + has_dimensions = False + if dimensions: + for dimension in dimensions: + if sle.get(dimension.get("fieldname")): + has_dimensions = True + + if get_serial_nos(sle.serial_no): + self.get_serialized_values(sle) + self.wh_data.qty_after_transaction += flt(sle.actual_qty) if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: + self.wh_data.qty_after_transaction = sle.qty_after_transaction + + self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt( + self.wh_data.valuation_rate + ) + elif sle.batch_no and frappe.db.get_value( + "Batch", sle.batch_no, "use_batchwise_valuation", cache=True + ): + self.update_batched_values(sle) +>>>>>>> 1480acabb0 (feat: validate negative stock for inventory dimension (#37373)) + else: + 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 +1324,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 +1339,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 +1353,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 +1391,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" diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 02444064c171..bd0d4697c94b 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -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: