From b186870ab82479f7914a7189d51f8cf22a60b137 Mon Sep 17 00:00:00 2001 From: maciej-wichowski Date: Mon, 24 Aug 2026 13:17:25 +0200 Subject: [PATCH] [FIX] tracking_manager: float value precision Values read from database are not rounded, while written ones go through `float_round`, which may return a value differing by one unit in the last place (0.1 -> 0.09999999999999999). Comparing them with `!=` reported a change on every write of an unchanged value, and the raw value was then displayed as is. Compare and display float and monetary fields at their own precision. --- tracking_manager/models/models.py | 53 +++++++++++++++---- tracking_manager/readme/CONTRIBUTORS.md | 1 + .../tests/test_tracking_manager.py | 31 +++++++++++ 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/tracking_manager/models/models.py b/tracking_manager/models/models.py index f3ea58d7b58..a3b61d8659d 100644 --- a/tracking_manager/models/models.py +++ b/tracking_manager/models/models.py @@ -8,6 +8,7 @@ from odoo import Command, api, models, tools from odoo.exceptions import AccessError +from odoo.tools import float_compare, float_repr, float_round from ..tools import format_m2m @@ -61,21 +62,53 @@ def _tm_notify_owner(self, mode, changes=None): def _tm_get_field_description(self, field_name): return self._fields[field_name].get_description(self.env)["string"] + def _tm_get_digits(self, field): + """Return the decimal digits of a float field, None for other fields. + + Float fields declared without precision also return None: their values + are stored and compared with the full float precision. + """ + if field.type == "monetary": + currency_field = field.get_currency_field(self) + currency = currency_field and self[currency_field] + return currency.decimal_places if currency else None + if field.type != "float": + return None + digits = field.get_digits(self.env) + return digits and digits[1] + + def _tm_has_changed(self, before, after, digits): + if digits is None: + return before != after + # `float_round`, applied to every written value, may return a value that + # differs by one unit in the last place from the one read from database + # (0.1 -> 0.09999999999999999). Both are stored identically, so such a + # difference is not a change. + return float_compare(before, after, precision_digits=digits) != 0 + + def _tm_format_values(self, field, before, after, digits): + if field.type == "many2many": + return format_m2m(before), format_m2m(after) + if field.type == "many2one": + return before.display_name, after["display_name"] + if digits is not None: + # displaying the raw value would expose the representation error of + # the rounding done on write (0.1 -> 0.09999999999999999) + return ( + float_repr(float_round(before, precision_digits=digits), digits), + float_repr(float_round(after, precision_digits=digits), digits), + ) + return before, after + def _tm_get_changes(self, values): self.ensure_one() changes = [] for field_name, before in values.items(): field = self._fields[field_name] - if before != self[field_name]: - if field.type == "many2many": - old = format_m2m(before) - new = format_m2m(self[field_name]) - elif field.type == "many2one": - old = before.display_name - new = self[field_name]["display_name"] - else: - old = before - new = self[field_name] + after = self[field_name] + digits = self._tm_get_digits(field) + if self._tm_has_changed(before, after, digits): + old, new = self._tm_format_values(field, before, after, digits) changes.append( { "name": self._tm_get_field_description(field_name), diff --git a/tracking_manager/readme/CONTRIBUTORS.md b/tracking_manager/readme/CONTRIBUTORS.md index 38de8f9244c..36de4746f02 100644 --- a/tracking_manager/readme/CONTRIBUTORS.md +++ b/tracking_manager/readme/CONTRIBUTORS.md @@ -1,2 +1,3 @@ - Kévin Roche \<\> - Sébastien BEAU \<\> +- Maciej Wichowski \<\> (https://versada.eu) diff --git a/tracking_manager/tests/test_tracking_manager.py b/tracking_manager/tests/test_tracking_manager.py index a20181d450e..6f4352a474b 100644 --- a/tracking_manager/tests/test_tracking_manager.py +++ b/tracking_manager/tests/test_tracking_manager.py @@ -71,6 +71,23 @@ def messages(self): self.flush_tracking() return self.partner.message_ids + def _create_tracked_child(self, latitude): + """Return a child partner whose (float) latitude is tracked.""" + self.env.ref("base.field_res_partner__child_ids").custom_tracking = True + self.env.ref("base.field_res_partner__partner_latitude").custom_tracking = True + child = self.env["res.partner"].create( + { + "name": "Test child", + "parent_id": self.partner.id, + "partner_latitude": latitude, + } + ) + self.flush_tracking() + self.partner.message_ids.unlink() + # values read from database are not rounded, unlike written ones + self.env.invalidate_all() + return child + def test_m2m_add_line(self): self.partner = self.env["res.partner"].browse(self.partner.id) self.partner.write( @@ -271,3 +288,17 @@ def test_o2m_update_record(self): ) child.write({"parent_id": False}) self.assertEqual(len(self.messages), 1) + + def test_o2m_update_float_without_change(self): + child = self._create_tracked_child(0.1) + child.write({"partner_latitude": 0.1}) + self.assertEqual(len(self.messages), 0) + + def test_o2m_update_float(self): + child = self._create_tracked_child(0.1) + child.write({"partner_latitude": 1.5}) + self.assertEqual(len(self.messages), 1) + self.assertEqual(self.messages.body.count("Change :"), 1) + # values are displayed with the precision of the field (10, 7) + self.assertIn("0.1000000", self.messages.body) + self.assertIn("1.5000000", self.messages.body)