From 98dd40884cb8356c1f2634d509e10dbe4539a353 Mon Sep 17 00:00:00 2001 From: times-odoo Date: Tue, 26 May 2026 18:08:44 +0530 Subject: [PATCH 1/3] [FIX] sale_management: update global discount when order lines change When a global discount line is applied to a sale order and an order line is removed, the discount amount was not recalculated, leaving an incorrect discount value on the order. Added an onchange on order_line that recomputes the discount line amount based on the updated subtotal of remaining product lines. If no product lines remain, the discount line is removed entirely. task-6245598 --- sale_order_discount/__init__.py | 1 + sale_order_discount/__manifest__.py | 10 ++++++++ sale_order_discount/models/__init__.py | 1 + sale_order_discount/models/sale_order.py | 32 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 sale_order_discount/__init__.py create mode 100644 sale_order_discount/__manifest__.py create mode 100644 sale_order_discount/models/__init__.py create mode 100644 sale_order_discount/models/sale_order.py diff --git a/sale_order_discount/__init__.py b/sale_order_discount/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/sale_order_discount/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_order_discount/__manifest__.py b/sale_order_discount/__manifest__.py new file mode 100644 index 00000000000..74822945542 --- /dev/null +++ b/sale_order_discount/__manifest__.py @@ -0,0 +1,10 @@ +{ + 'name': "Sale Order Discount", + 'version': "1.0", + 'category': "Sales", + 'description': "Updates global discount when order lines are changed", + 'depends': ['sale_management'], + 'installable': True, + 'author': "times", + 'license': "LGPL-3", +} diff --git a/sale_order_discount/models/__init__.py b/sale_order_discount/models/__init__.py new file mode 100644 index 00000000000..6aacb753131 --- /dev/null +++ b/sale_order_discount/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order diff --git a/sale_order_discount/models/sale_order.py b/sale_order_discount/models/sale_order.py new file mode 100644 index 00000000000..0e84e1e0732 --- /dev/null +++ b/sale_order_discount/models/sale_order.py @@ -0,0 +1,32 @@ +import re +from odoo import api, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + @api.onchange('order_line') + def _onchange_update_global_discount(self): + discount_lines = self.env['sale.order.line'] + product_lines = self.env['sale.order.line'] + + for line in self.order_line: + if line._is_global_discount(): + discount_lines += line + if not line._is_global_discount(): + product_lines += line + + if not discount_lines: + return + + if not product_lines: + self.order_line -= discount_lines + return + + subtotal = sum(product_lines.mapped('price_subtotal')) + + for discount_line in discount_lines: + match = re.search(r"(\d+(?:\.\d+)?)%", discount_line.name) + if match: + percent = float(match.group(1)) + discount_line.price_unit = -(subtotal * (percent / 100)) From 8e516260bdf636ac68b399e09a59cdeaa90e7ef1 Mon Sep 17 00:00:00 2001 From: times-odoo Date: Mon, 1 Jun 2026 11:19:30 +0530 Subject: [PATCH 2/3] [IMP] sale_management: synchronize global discount field with line text updates Introduced a tracking field for global discount percentages and added logic to dynamically update it based on modifications made directly to the discount line description. Rationale: Users need a synchronized flow when modifying global discount values. By introducing 'global_discount_percentage', the system preserves the raw numerical float value for other computing methods while allowing changes made inside the line item description text to drive the value update. Additionally, extracting the decimal precision dynamically ensures that line renaming remains visually clean, localized, and compliant with system-configured currency rounding standards. Technical choices: - Added the 'global_discount_percentage' Float field to 'sale.order'. - Updated '_onchange_update_global_discount' to extract and validate descriptions, raising a UserError if a proper float structure is missing. - Implemented state-change detection to synchronize the field value only when 'discount_from_name' shifts. - Leveraged 'decimal.precision' and 'float_repr' to construct dynamically formatted, localized description strings using Odoo's 'self.env._' method. --- sale_order_discount/__init__.py | 1 + sale_order_discount/__manifest__.py | 2 +- sale_order_discount/models/sale_order.py | 27 +++++++++++++++---- sale_order_discount/wizard/__init__.py | 1 + .../wizard/sale_order_discount.py | 10 +++++++ 5 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 sale_order_discount/wizard/__init__.py create mode 100644 sale_order_discount/wizard/sale_order_discount.py diff --git a/sale_order_discount/__init__.py b/sale_order_discount/__init__.py index 0650744f6bc..9b4296142f4 100644 --- a/sale_order_discount/__init__.py +++ b/sale_order_discount/__init__.py @@ -1 +1,2 @@ from . import models +from . import wizard diff --git a/sale_order_discount/__manifest__.py b/sale_order_discount/__manifest__.py index 74822945542..bf51253ac64 100644 --- a/sale_order_discount/__manifest__.py +++ b/sale_order_discount/__manifest__.py @@ -4,7 +4,7 @@ 'category': "Sales", 'description': "Updates global discount when order lines are changed", 'depends': ['sale_management'], - 'installable': True, + 'auto_install': True, 'author': "times", 'license': "LGPL-3", } diff --git a/sale_order_discount/models/sale_order.py b/sale_order_discount/models/sale_order.py index 0e84e1e0732..3bd2694af04 100644 --- a/sale_order_discount/models/sale_order.py +++ b/sale_order_discount/models/sale_order.py @@ -1,10 +1,14 @@ import re -from odoo import api, models +from odoo import _, api, fields, models +from odoo.tools import float_repr +from odoo.exceptions import UserError class SaleOrder(models.Model): _inherit = 'sale.order' + global_discount_percentage = fields.Float() + @api.onchange('order_line') def _onchange_update_global_discount(self): discount_lines = self.env['sale.order.line'] @@ -26,7 +30,20 @@ def _onchange_update_global_discount(self): subtotal = sum(product_lines.mapped('price_subtotal')) for discount_line in discount_lines: - match = re.search(r"(\d+(?:\.\d+)?)%", discount_line.name) - if match: - percent = float(match.group(1)) - discount_line.price_unit = -(subtotal * (percent / 100)) + match = re.search(r"(\d+(?:\.\d+)?)", discount_line.name) + discount_from_name = float(match.group(1)) if match else False + + if not discount_from_name: + raise UserError(_("Discounts should be in Float(0.00%)")) + + if discount_from_name != self.global_discount_percentage: + self.global_discount_percentage = discount_from_name + + percent = self.global_discount_percentage + discount_dp = self.env['decimal.precision'].precision_get('Discount') + + discount_line.name = _( + "Discount %(percent)s%%", + percent=float_repr(percent, discount_dp), + ) + discount_line.price_unit = -(subtotal * percent) / 100 diff --git a/sale_order_discount/wizard/__init__.py b/sale_order_discount/wizard/__init__.py new file mode 100644 index 00000000000..d156570a8be --- /dev/null +++ b/sale_order_discount/wizard/__init__.py @@ -0,0 +1 @@ +from . import sale_order_discount diff --git a/sale_order_discount/wizard/sale_order_discount.py b/sale_order_discount/wizard/sale_order_discount.py new file mode 100644 index 00000000000..abefed791fa --- /dev/null +++ b/sale_order_discount/wizard/sale_order_discount.py @@ -0,0 +1,10 @@ +from odoo import models + + +class SaleOrderDiscount(models.TransientModel): + _inherit = 'sale.order.discount' + + def _prepare_global_discount_so_lines(self, base_lines): + res = super()._prepare_global_discount_so_lines(base_lines) + self.sale_order_id.global_discount_percentage = self.discount_percentage + return res From eda77ae8123a57b5cdcfaf81c4c80833d298fbba Mon Sep 17 00:00:00 2001 From: times-odoo Date: Mon, 1 Jun 2026 17:47:26 +0530 Subject: [PATCH 3/3] [FIX] sale_order_discount: fallback to origin data and use non-blocking notifications on invalid discount names Refactored the onchange string validation to utilize non-blocking bus notifications and safely fall back to the original database record state. Rationale: Raising a strict 'UserError' inside an '@api.onchange' method breaks the user's input flow harshly and can cause unstable UI behaviors in the web client. Replacing the error with a bus notification improves the user experience by warning them gracefully while resetting the line description back to its last saved valid state ('self.id.origin'). Additionally, fixed the wizard mapping computation to correctly store the percentage value as a whole float number (multiplied by 100) to remain consistent with backend expectations. Technical choices: - Replaced 'UserError' with 'self.env.user._bus_send' to broadcast a non-blocking UI warning snippet. - Leveraged 'self.id.origin' via a database browse to retrieve the last committed 'global_discount_percentage' value as a dependable fallback. - Restructured string evaluation inside the onchange loop to immediately return after rebuilding the description string, stopping unexpected line mutation. - Corrected the transient wizard assignment math by multiplying the scalar representation by 100 before writing it to the core sales document. --- sale_order_discount/models/sale_order.py | 25 ++++++++++++++----- .../wizard/sale_order_discount.py | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/sale_order_discount/models/sale_order.py b/sale_order_discount/models/sale_order.py index 3bd2694af04..a49a17f1a28 100644 --- a/sale_order_discount/models/sale_order.py +++ b/sale_order_discount/models/sale_order.py @@ -1,7 +1,6 @@ import re from odoo import _, api, fields, models from odoo.tools import float_repr -from odoo.exceptions import UserError class SaleOrder(models.Model): @@ -30,20 +29,34 @@ def _onchange_update_global_discount(self): subtotal = sum(product_lines.mapped('price_subtotal')) for discount_line in discount_lines: + + discount_dp = self.env['decimal.precision'].precision_get('Discount') + origin_record = self.env['sale.order'].browse(self.id.origin) + match = re.search(r"(\d+(?:\.\d+)?)", discount_line.name) discount_from_name = float(match.group(1)) if match else False if not discount_from_name: - raise UserError(_("Discounts should be in Float(0.00%)")) + self.env.user._bus_send("simple_notification", { + 'type': 'danger', + 'title': _("Error"), + 'message': _("Discounts should be in Float(0.00%)") + }) + percent = origin_record.global_discount_percentage + + discount_line.name = _( + "Discount %(percent)s%%", + percent=float_repr(percent, discount_dp), + ) + return if discount_from_name != self.global_discount_percentage: - self.global_discount_percentage = discount_from_name + origin_record.global_discount_percentage = discount_from_name - percent = self.global_discount_percentage - discount_dp = self.env['decimal.precision'].precision_get('Discount') + percent = origin_record.global_discount_percentage discount_line.name = _( "Discount %(percent)s%%", percent=float_repr(percent, discount_dp), ) - discount_line.price_unit = -(subtotal * percent) / 100 + discount_line.price_unit = -(subtotal * (percent / 100)) diff --git a/sale_order_discount/wizard/sale_order_discount.py b/sale_order_discount/wizard/sale_order_discount.py index abefed791fa..5c5582039ea 100644 --- a/sale_order_discount/wizard/sale_order_discount.py +++ b/sale_order_discount/wizard/sale_order_discount.py @@ -6,5 +6,5 @@ class SaleOrderDiscount(models.TransientModel): def _prepare_global_discount_so_lines(self, base_lines): res = super()._prepare_global_discount_so_lines(base_lines) - self.sale_order_id.global_discount_percentage = self.discount_percentage + self.sale_order_id.global_discount_percentage = self.discount_percentage * 100 return res