feat: configurable rounding methods

This commit is contained in:
Ankush Menat 2023-03-06 11:30:30 +05:30
parent 640a543dae
commit 48f63f53ab
4 changed files with 82 additions and 17 deletions

View file

@ -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",

View file

@ -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))

View file

@ -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:

View file

@ -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"