From d9421033bd0df14bb40126c5d1ad4974ead0c375 Mon Sep 17 00:00:00 2001 From: sergio-teruel Date: Sat, 10 Oct 2026 08:20:49 +0200 Subject: [PATCH] [FIX] sale_elaboration_margin: don't leave the temporary line pending to recompute To get the elaboration price, a temporary sale order line is created for the elaboration product and detached from the order afterwards. Detaching it flags its stored computed fields to be recomputed, and nothing ever computes them, so they remain pending in the environment. The next time one of those fields is read on a real line, the pending temporary line is computed in the same batch. As it has no order, it has neither company nor currency, so any compute method relying on the currency of the line fails with "Expected singleton: res.currency()". This happens for example when selecting a product in a sale order line and the elaboration is filled automatically. Now the pending computations of the temporary line are discarded once the price is obtained. --- sale_elaboration_margin/models/sale_order.py | 7 ++ sale_elaboration_margin/tests/__init__.py | 1 + .../tests/test_sale_elaboration_margin.py | 99 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 sale_elaboration_margin/tests/__init__.py create mode 100644 sale_elaboration_margin/tests/test_sale_elaboration_margin.py diff --git a/sale_elaboration_margin/models/sale_order.py b/sale_elaboration_margin/models/sale_order.py index 052ab057f..93d4f76c3 100644 --- a/sale_elaboration_margin/models/sale_order.py +++ b/sale_elaboration_margin/models/sale_order.py @@ -60,6 +60,13 @@ def _compute_elaboration_price(self): line.company_id, ) new_sol.order_id = False + # Detaching the temporary line flags its stored computed + # fields to recompute. Left pending, they are computed in + # batch with the real lines on a record without order, + # company nor currency. + for field in list(self.env.fields_to_compute()): + if field.model_name == new_sol._name: + self.env.remove_to_compute(field, new_sol) elaboration_cost_price += elaboration_product.standard_price line.elaboration_price = elaboration_price line.elaboration_cost_price = elaboration_cost_price diff --git a/sale_elaboration_margin/tests/__init__.py b/sale_elaboration_margin/tests/__init__.py new file mode 100644 index 000000000..339476610 --- /dev/null +++ b/sale_elaboration_margin/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_elaboration_margin diff --git a/sale_elaboration_margin/tests/test_sale_elaboration_margin.py b/sale_elaboration_margin/tests/test_sale_elaboration_margin.py new file mode 100644 index 000000000..a8579009c --- /dev/null +++ b/sale_elaboration_margin/tests/test_sale_elaboration_margin.py @@ -0,0 +1,99 @@ +# Copyright 2026 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests import Form, TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestSaleElaborationMargin(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product = cls.env["product.product"].create( + {"name": "Test product", "list_price": 100.0} + ) + cls.product_elaboration_a = cls.env["product.product"].create( + { + "name": "Product Elaboration A", + "type": "service", + "list_price": 50.0, + "standard_price": 20.0, + "is_elaboration": True, + "taxes_id": False, + } + ) + cls.product_elaboration_b = cls.env["product.product"].create( + { + "name": "Product Elaboration B", + "type": "service", + "list_price": 25.0, + "standard_price": 10.0, + "is_elaboration": True, + "taxes_id": False, + } + ) + cls.elaboration_a = cls.env["product.elaboration"].create( + { + "code": "AA", + "name": "Elaboration A", + "product_id": cls.product_elaboration_a.id, + } + ) + cls.elaboration_b = cls.env["product.elaboration"].create( + { + "code": "BB", + "name": "Elaboration B", + "product_id": cls.product_elaboration_b.id, + } + ) + cls.pricelist = cls.env["product.pricelist"].create({"name": "Test pricelist"}) + cls.partner = cls.env["res.partner"].create( + { + "name": "Test partner", + "property_product_pricelist": cls.pricelist.id, + } + ) + + def _create_sale_order(self, elaborations): + order_form = Form(self.env["sale.order"]) + order_form.partner_id = self.partner + with order_form.order_line.new() as line_form: + line_form.product_id = self.product + line_form.product_uom_qty = 10 + for elaboration in elaborations: + line_form.elaboration_ids.add(elaboration) + return order_form.save() + + def _get_pending_temporary_lines(self): + """Lines never saved that are still waiting for a recomputation.""" + lines = self.env["sale.order.line"] + for field in self.env.fields_to_compute(): + if field.model_name == lines._name: + lines |= self.env.records_to_compute(field) + return lines.filtered(lambda line: not line.id) + + def test_elaboration_price(self): + order = self._create_sale_order(self.elaboration_a) + line = order.order_line + self.assertEqual(line.elaboration_price, 50.0) + self.assertEqual(line.elaboration_cost_price, 20.0) + self.assertEqual(line.elaboration_margin, 300.0) + line.elaboration_ids = self.elaboration_a + self.elaboration_b + self.assertEqual(line.elaboration_price, 75.0) + self.assertEqual(line.elaboration_cost_price, 30.0) + line.elaboration_ids = False + self.assertEqual(line.elaboration_price, 0.0) + self.assertEqual(line.elaboration_cost_price, 0.0) + + def test_elaboration_price_temporary_line_not_pending(self): + """The line used to get the elaboration price is not recomputed later. + + That line has no order, so it has neither company nor currency. If it + stays waiting for a recomputation, it's computed together with the real + lines the next time one of those fields is read, breaking any compute + method that needs the currency of the line. + """ + order = self._create_sale_order(self.elaboration_a) + line = order.order_line + line.elaboration_ids = self.elaboration_b + self.assertEqual(line.elaboration_price, 25.0) + self.assertFalse(self._get_pending_temporary_lines())