From 48f63f53abf149785a9249a1f50e7a01eecc0c03 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 6 Mar 2023 11:30:30 +0530 Subject: [PATCH] feat: configurable rounding methods --- .../system_settings/system_settings.json | 12 ++++- frappe/tests/test_utils.py | 49 ++++++++++++++++++- frappe/utils/data.py | 37 +++++++++----- pyproject.toml | 1 + 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index ddafd0e9fd..72f6d2345f 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -17,10 +17,11 @@ "date_format", "time_format", "number_format", + "first_day_of_the_week", "column_break_7", "float_precision", "currency_precision", - "first_day_of_the_week", + "rounding_method", "sec_backup_limit", "backup_limit", "encrypt_backup", @@ -520,12 +521,19 @@ "fieldname": "login_with_email_link_expiry", "fieldtype": "Int", "label": "Login with email link expiry (in minutes)" + }, + { + "default": "Bankers Rounding", + "fieldname": "rounding_method", + "fieldtype": "Select", + "label": "Rounding Method", + "options": "Bankers Rounding\nRounding half away from zero" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2022-12-20 21:45:37.651668", + "modified": "2023-03-06 11:31:19.144956", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index d2d5cdafd7..c41d28d9d1 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -6,25 +6,28 @@ import json import os import sys from datetime import date, datetime, time, timedelta -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal, localcontext from enum import Enum from io import StringIO from mimetypes import guess_type from unittest.mock import patch import pytz +from hypothesis import given +from hypothesis import strategies as st from PIL import Image import frappe from frappe.installer import parse_app_name from frappe.model.document import Document -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import ( ceil, dict_to_str, evaluate_filters, execute_in_shell, floor, + flt, format_timedelta, get_bench_path, get_file_timestamp, @@ -1001,3 +1004,45 @@ class TestTBSanitization(FrappeTestCase): self.assertIn("********", traceback) self.assertIn("password =", traceback) self.assertIn("safe_value", traceback) + + +class TestRounding(FrappeTestCase): + @change_settings("System Settings", {"rounding_method": "Rounding half away from zero"}) + def test_normal_rounding(self): + self.assertEqual(flt("what"), 0) + + self.assertEqual(flt("0.5", 0), 1) + self.assertEqual(flt("0.3"), 0.3) + + self.assertEqual(flt("1.5", 0), 2) + + # positive rounding to integers + self.assertEqual(flt(0.4, 0), 0) + self.assertEqual(flt(0.5, 0), 1) + self.assertEqual(flt(1.455, 0), 1) + self.assertEqual(flt(1.5, 0), 2) + + # negative rounding to integers + self.assertEqual(flt(-0.5, 0), -1) + self.assertEqual(flt(-1.5, 0), -2) + + # negative precision i.e. round to nearest 10th + self.assertEqual(flt(123, -1), 120) + self.assertEqual(flt(125, -1), 130) + self.assertEqual(flt(134.45, -1), 130) + self.assertEqual(flt(135, -1), 140) + + # # positive multiple digit rounding + self.assertEqual(flt(1.25, 1), 1.3) + self.assertEqual(flt(0.15, 1), 0.2) + + # # negative multiple digit rounding + self.assertEqual(flt(-1.25, 1), -1.3) + self.assertEqual(flt(-0.15, 1), -0.2) + + @change_settings("System Settings", {"rounding_method": "Rounding half away from zero"}) + @given(st.decimals(min_value=-1e8, max_value=1e8), st.integers(min_value=-2, max_value=4)) + def test_normal_rounding_property(self, number, precision): + with localcontext() as ctx: + ctx.rounding = ROUND_HALF_UP + self.assertEqual(Decimal(str(flt(float(number), precision))), round(number, precision)) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 92467b036b..dbb8f6294e 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1047,25 +1047,36 @@ def sbool(x: str) -> bool | Any: def rounded(num, precision=0): - """round method for round halfs to nearest even algorithm aka banker's rounding - compatible with python3""" + """Round according to method set in system setting, defaults to banker's rounding""" precision = cint(precision) - multiplier = 10**precision - # avoid rounding errors - num = round(num * multiplier if precision else num, 8) + rounding_method = frappe.get_system_settings("rounding_method") or "Bankers Rounding" - floor_num = math.floor(num) - decimal_part = num - floor_num + if rounding_method == "Bankers Rounding": + # avoid rounding errors + multiplier = 10**precision + num = round(num * multiplier if precision else num, 8) - if not precision and decimal_part == 0.5: - num = floor_num if (floor_num % 2 == 0) else floor_num + 1 - else: - if decimal_part == 0.5: - num = floor_num + 1 + floor_num = math.floor(num) + decimal_part = num - floor_num + + if not precision and decimal_part == 0.5: + num = floor_num if (floor_num % 2 == 0) else floor_num + 1 else: - num = round(num) + if decimal_part == 0.5: + num = floor_num + 1 + else: + num = round(num) - return (num / multiplier) if precision else num + return (num / multiplier) if precision else num + + elif rounding_method == "Rounding half away from zero": + if num == 0: + return 0.0 + # Epsilon is small correctional value added to correctly round numbers which can't be + # represented in IEEE 754 representation. + epsilon = 2.0 ** (math.log(abs(num), 2) - 52.0) + return round(num + math.copysign(epsilon, num), precision) def remainder(numerator: NumericType, denominator: NumericType, precision: int = 2) -> NumericType: diff --git a/pyproject.toml b/pyproject.toml index b0205edb22..837ea4624a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,3 +102,4 @@ Faker = "~=13.12.1" pyngrok = "~=5.0.5" unittest-xml-reporting = "~=3.0.4" watchdog = "~=2.1.9" +hypothesis = "~=6.68.2"