diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 29d6bf2df8b..179f7237f96 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -712,6 +712,7 @@ def create_item( item_code, is_stock_item=1, valuation_rate=0, + stock_uom="Nos", warehouse="_Test Warehouse - _TC", is_customer_provided_item=None, customer=None, @@ -727,6 +728,7 @@ def create_item( item.item_name = item_code item.description = item_code item.item_group = "All Item Groups" + item.stock_uom = stock_uom item.is_stock_item = is_stock_item item.is_fixed_asset = is_fixed_asset item.asset_category = asset_category diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 92f90a1c7d6..e0bdc9229f5 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -590,7 +590,7 @@ class StockEntry(StockController): ) + "

" + _("Available quantity is {0}, you need {1}").format( - frappe.bold(d.actual_qty), frappe.bold(d.transfer_qty) + frappe.bold(flt(d.actual_qty, d.precision("actual_qty"))), frappe.bold(d.transfer_qty) ), NegativeStockError, title=_("Insufficient Stock"), diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 9fa61098a0b..e810c7933cc 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -7,7 +7,7 @@ import frappe from frappe.core.page.permission_manager.permission_manager import reset from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import add_days, add_to_date, today +from frappe.utils import add_days, add_to_date, flt, today from erpnext.accounts.doctype.gl_entry.gl_entry import rename_gle_sle_docs from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note @@ -40,6 +40,9 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): "delete from `tabBin` where item_code in (%s)" % (", ".join(["%s"] * len(items))), items ) + def tearDown(self): + frappe.db.rollback() + def assertSLEs(self, doc, expected_sles, sle_filters=None): """Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line""" @@ -754,6 +757,93 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): ) self.assertEqual(abs(sles[0].stock_value_difference), sles[1].stock_value_difference) + @change_settings("System Settings", {"float_precision": 4}) + def test_negative_qty_with_precision(self): + "Test if system precision is respected while validating negative qty." + from erpnext.stock.doctype.item.test_item import create_item + from erpnext.stock.utils import get_stock_balance + + item_code = "ItemPrecisionTest" + warehouse = "_Test Warehouse - _TC" + create_item(item_code, is_stock_item=1, stock_uom="Kg") + + create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=559.8327, rate=100) + + make_stock_entry(item_code=item_code, source=warehouse, qty=470.84, rate=100) + self.assertEqual(get_stock_balance(item_code, warehouse), 88.9927) + + settings = frappe.get_doc("System Settings") + settings.float_precision = 3 + settings.save() + + # To deliver 100 qty we fall short of 11.0073 qty (11.007 with precision 3) + # Stock up with 11.007 (balance in db becomes 99.9997, on UI it will show as 100) + make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100) + self.assertEqual(get_stock_balance(item_code, warehouse), 99.9997) + + # See if delivery note goes through + # Negative qty error should not be raised as 99.9997 is 100 with precision 3 (system precision) + dn = create_delivery_note( + item_code=item_code, + qty=100, + rate=150, + warehouse=warehouse, + company="_Test Company", + expense_account="Cost of Goods Sold - _TC", + cost_center="Main - _TC", + do_not_submit=True, + ) + dn.submit() + + self.assertEqual(flt(get_stock_balance(item_code, warehouse), 3), 0.000) + + @change_settings("System Settings", {"float_precision": 4}) + def test_future_negative_qty_with_precision(self): + """ + Ledger: + | Voucher | Qty | Balance + ------------------- + | Reco | 559.8327| 559.8327 + | SE | -470.84 | [Backdated] (new bal: 88.9927) + | SE | 11.007 | 570.8397 (new bal: 99.9997) + | DN | -100 | 470.8397 (new bal: -0.0003) + + Check if future negative qty is asserted as per precision 3. + -0.0003 should be considered as 0.000 + """ + from erpnext.stock.doctype.item.test_item import create_item + + item_code = "ItemPrecisionTest" + warehouse = "_Test Warehouse - _TC" + create_item(item_code, is_stock_item=1, stock_uom="Kg") + + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=559.8327, + rate=100, + posting_date=add_days(today(), -2), + ) + make_stock_entry(item_code=item_code, target=warehouse, qty=11.007, rate=100) + create_delivery_note( + item_code=item_code, + qty=100, + rate=150, + warehouse=warehouse, + company="_Test Company", + expense_account="Cost of Goods Sold - _TC", + cost_center="Main - _TC", + ) + + settings = frappe.get_doc("System Settings") + settings.float_precision = 3 + settings.save() + + # Make backdated SE and make sure SE goes through as per precision (no negative qty error) + make_stock_entry( + item_code=item_code, source=warehouse, qty=470.84, rate=100, posting_date=add_days(today(), -1) + ) + def create_repack_entry(**args): args = frappe._dict(args) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7bdf2078c4a..da57ba054dc 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt import copy @@ -360,7 +360,7 @@ class update_entries_after(object): self.args["name"] = self.args.sle_id self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") - self.get_precision() + self.set_precision() self.valuation_method = get_valuation_method(self.item_code) self.new_items_found = False @@ -371,10 +371,10 @@ class update_entries_after(object): self.initialize_previous_data(self.args) self.build() - def get_precision(self): - company_base_currency = frappe.get_cached_value("Company", self.company, "default_currency") - self.precision = get_field_precision( - frappe.get_meta("Stock Ledger Entry").get_field("stock_value"), currency=company_base_currency + def set_precision(self): + self.flt_precision = cint(frappe.db.get_default("float_precision")) or 2 + self.currency_precision = get_field_precision( + frappe.get_meta("Stock Ledger Entry").get_field("stock_value") ) def initialize_previous_data(self, args): @@ -571,7 +571,7 @@ class update_entries_after(object): ) # rounding as per precision - self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision) + self.wh_data.stock_value = flt(self.wh_data.stock_value, self.currency_precision) if not self.wh_data.qty_after_transaction: self.wh_data.stock_value = 0.0 stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value @@ -595,6 +595,7 @@ class update_entries_after(object): will not consider cancelled entries """ diff = self.wh_data.qty_after_transaction + flt(sle.actual_qty) + diff = flt(diff, self.flt_precision) # respect system precision if diff < 0 and abs(diff) > 0.0001: # negative stock! @@ -1347,7 +1348,8 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): return neg_sle = get_future_sle_with_negative_qty(args) - if neg_sle: + + if is_negative_with_precision(neg_sle): message = _( "{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." ).format( @@ -1365,7 +1367,7 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): return neg_batch_sle = get_future_sle_with_negative_batch_qty(args) - if neg_batch_sle: + if is_negative_with_precision(neg_batch_sle, is_batch=True): message = _( "{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." ).format( @@ -1379,6 +1381,22 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch")) +def is_negative_with_precision(neg_sle, is_batch=False): + """ + Returns whether system precision rounded qty is insufficient. + E.g: -0.0003 in precision 3 (0.000) is sufficient for the user. + """ + + if not neg_sle: + return False + + field = "cumulative_total" if is_batch else "qty_after_transaction" + precision = cint(frappe.db.get_default("float_precision")) or 2 + qty_deficit = flt(neg_sle[0][field], precision) + + return qty_deficit < 0 and abs(qty_deficit) > 0.0001 + + def get_future_sle_with_negative_qty(args): return frappe.db.sql( """