|
| 1 | +# Copyright 2026 Camptocamp SA |
| 2 | +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). |
| 3 | +from odoo import api, fields, models |
| 4 | +from odoo.exceptions import ValidationError |
| 5 | +from odoo.tools import float_compare |
| 6 | + |
| 7 | + |
| 8 | +class SaleOrderLine(models.Model): |
| 9 | + _inherit = "sale.order.line" |
| 10 | + |
| 11 | + def _sale_multiple_applies(self) -> bool: |
| 12 | + """Check whether sale multiple UoM logic applies to this sale order line. |
| 13 | +
|
| 14 | + :return: True if the sale_multiple_uom_id is set |
| 15 | + and the lines' product UoM is the same as the product's base UoM. |
| 16 | + """ |
| 17 | + self.ensure_one() |
| 18 | + same_product_uom = ( |
| 19 | + self.product_id |
| 20 | + and self.product_uom_id |
| 21 | + and self.product_uom_id == self.product_id.uom_id |
| 22 | + ) |
| 23 | + return same_product_uom and bool(self.product_id.sale_multiple_uom_id) |
| 24 | + |
| 25 | + def _round_sale_qty_to_multiple( |
| 26 | + self, qty_to_order: float, rounding_method: str |
| 27 | + ) -> float: |
| 28 | + """Round qty in product base UoM to a multiple of the sales multiple UoM. |
| 29 | +
|
| 30 | + :param qty: quantity expressed in product.uom_id (base UoM). |
| 31 | + :param rounding_method: one of "UP", "DOWN", "HALF-UP". |
| 32 | + :return: rounded quantity expressed in product.uom_id. |
| 33 | +
|
| 34 | + Method is inspired by ``stock.warehouse.orderpoint::_get_multiple_rounded_qty`` |
| 35 | + """ |
| 36 | + self.ensure_one() |
| 37 | + # Convert base qty to sales multiple UoM |
| 38 | + packs = self.product_id.uom_id._compute_quantity( |
| 39 | + qty_to_order, self.product_id.sale_multiple_uom_id |
| 40 | + ) |
| 41 | + # Round qty to nearest multiple |
| 42 | + packs = fields.Float.round( |
| 43 | + packs, precision_digits=0, rounding_method=rounding_method |
| 44 | + ) |
| 45 | + # Convert back to base UoM |
| 46 | + qty_rounded = self.product_id.sale_multiple_uom_id._compute_quantity( |
| 47 | + packs, self.product_uom_id |
| 48 | + ) |
| 49 | + # Avoid rounding DOWN from a small positive number to 0: |
| 50 | + # i.e.: generally we want 3 to become 50 (UP), not 0. |
| 51 | + if rounding_method == "DOWN" and qty_rounded == 0.0: |
| 52 | + # Round qty to nearest multiple |
| 53 | + packs = fields.Float.round(packs, precision_digits=0, rounding_method="UP") |
| 54 | + qty_rounded = self.product_id.sale_multiple_uom_id._compute_quantity( |
| 55 | + packs, self.product_uom_id |
| 56 | + ) |
| 57 | + return qty_rounded |
| 58 | + |
| 59 | + def _get_edit_rounding_method(self, qty: float) -> str: |
| 60 | + """Get rounding method based on user edit direction. |
| 61 | +
|
| 62 | + Compare the current line quantity with the origin value: |
| 63 | + - If the user increased the quantity => round UP. |
| 64 | + - If the user decreased the quantity => round DOWN. |
| 65 | + """ |
| 66 | + self.ensure_one() |
| 67 | + prev_qty = (self._origin.product_uom_qty or 0.0) if self._origin else 0.0 |
| 68 | + return "UP" if qty >= prev_qty else "DOWN" |
| 69 | + |
| 70 | + @api.onchange("product_id", "product_uom_qty", "product_uom_id") |
| 71 | + def _onchange_sale_multiple_qty(self): |
| 72 | + """Round product_uom_qty to a multiple of the sales multiple UoM.""" |
| 73 | + for line in self: |
| 74 | + if not line._sale_multiple_applies(): |
| 75 | + continue |
| 76 | + |
| 77 | + qty = line.product_uom_qty or 0.0 |
| 78 | + if qty <= 0: |
| 79 | + continue |
| 80 | + |
| 81 | + rounding_method = line._get_edit_rounding_method(qty) |
| 82 | + new_qty = line._round_sale_qty_to_multiple(qty, rounding_method) |
| 83 | + |
| 84 | + # Avoid rounding DOWN from a small positive number to 0: |
| 85 | + # i.e.: generally we want 3 to become 50 (UP), not 0. |
| 86 | + if rounding_method == "DOWN" and new_qty == 0.0: |
| 87 | + new_qty = line._round_sale_qty_to_multiple(qty, "UP") |
| 88 | + |
| 89 | + if ( |
| 90 | + float_compare( |
| 91 | + new_qty, qty, precision_rounding=line.product_uom_id.rounding |
| 92 | + ) |
| 93 | + != 0 |
| 94 | + ): |
| 95 | + line.product_uom_qty = new_qty |
| 96 | + |
| 97 | + @api.constrains( |
| 98 | + "product_id", |
| 99 | + "product_uom_qty", |
| 100 | + "product_uom_id", |
| 101 | + ) |
| 102 | + def _check_sale_multiple_qty_constraint(self): |
| 103 | + """Ensure product_uom_qty is a multiple of the sales multiple UoM.""" |
| 104 | + for line in self: |
| 105 | + if not line._sale_multiple_applies(): |
| 106 | + continue |
| 107 | + |
| 108 | + qty = line.product_uom_qty or 0.0 |
| 109 | + if qty <= 0: |
| 110 | + continue |
| 111 | + # Get the nearest valid multiple quantity |
| 112 | + valid_multiple_qty = line._round_sale_qty_to_multiple(qty, "HALF-UP") |
| 113 | + # Entered quantity should be equal to the valid multiple quantity |
| 114 | + if ( |
| 115 | + float_compare( |
| 116 | + valid_multiple_qty, |
| 117 | + qty, |
| 118 | + precision_rounding=line.product_uom_id.rounding, |
| 119 | + ) |
| 120 | + != 0 |
| 121 | + ): |
| 122 | + raise ValidationError( |
| 123 | + self.env._( |
| 124 | + "The qty %(qty).2f is not valid for product '%(product)s'.\n" |
| 125 | + "Please enter the sales multiple UoM '%(uom)s' qty.", |
| 126 | + qty=qty, |
| 127 | + product=line.product_id.display_name, |
| 128 | + uom=line.product_id.sale_multiple_uom_id.display_name, |
| 129 | + ) |
| 130 | + ) |
0 commit comments