fix: Respect system precision for user facing balance qty values (#30837)
* fix: Respect system precision for user facing balance qty values
- `get_precision` -> `set_precision`
- Use system wide currency precision for `stock_value`
- Round of qty defiiciency as per user defined precision (system flt precision), so that it is WYSIWYG for users
* fix: Consider system precision when validating future negative qty
* test: Immediate Negative Qty precision test
- Test for Immediate Negative Qty precision
- Stock Entry Negative Qty message: Format available qty in system precision
- Pass `stock_uom` as confugrable option in `make_item`
* test: Future Negative Qty validation with precision
* fix: Use `get_field_precision` for currency precision as it used to
- `get_field_precision` defaults to number format for precision (maintain old behaviour)
- Don't pass `currency` to `get_field_precision` as its not used anymore
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index aa0a549..d5074e7 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -800,6 +800,7 @@
item_code,
is_stock_item=1,
valuation_rate=0,
+ stock_uom="Nos",
warehouse="_Test Warehouse - _TC",
is_customer_provided_item=None,
customer=None,
@@ -815,6 +816,7 @@
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 a9176a9..e902d1e 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -590,7 +590,7 @@
)
+ "<br><br>"
+ _("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 55a213c..f669e90 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
@@ -42,6 +42,9 @@
"delete from `tabBin` where item_code in (%s)" % (", ".join(["%s"] * len(items))), items
)
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_item_cost_reposting(self):
company = "_Test Company"
@@ -1230,6 +1233,93 @@
)
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 4789b52..ba2d3c1 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
@@ -370,7 +370,7 @@
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
@@ -381,10 +381,10 @@
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):
@@ -581,7 +581,7 @@
self.update_queue_values(sle)
# 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
@@ -605,6 +605,7 @@
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!
@@ -1405,7 +1406,8 @@
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(
@@ -1423,7 +1425,7 @@
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(
@@ -1437,6 +1439,22 @@
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(
"""