Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions sale_product_multiple_qty/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

=========================
Sale Product Multiple Qty
=========================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:fae80e9f1799cbaea5d031c91bf0456af4741e0d2cf9edd5af4f30424377956f
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github
:target: https://github.com/OCA/sale-workflow/tree/19.0/sale_product_multiple_qty
:alt: OCA/sale-workflow
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/sale-workflow-19-0/sale-workflow-19-0-sale_product_multiple_qty
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&target_branch=19.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

Sales Product Multiple Quantity
===============================

This module adds a **Sales Multiple** unit of measure on products.

When a Sales Multiple is set, sales order line quantities are
automatically rounded **UP** to the nearest multiple of the selected
unit of measure. This is useful for products that must be sold in fixed
pack sizes (boxes, bundles, pallets, etc.).

The rounding is performed by converting the entered quantity to the
Sales Multiple UoM, rounding the number of packs **UP**, and converting
the result back to the order line UoM.

For example, with a Sales Multiple of *Pack of 100*:

- ordering 15 packs of 5 units (75 units) is rounded to 20 packs (100
units);
- ordering 55 packs of 5 units (275 units) is rounded to 60 packs (300
units).

If the Sales Multiple UoM is not divisible by the order line UoM, the
rounded quantity may be fractional. It is the user's responsibility to
configure compatible units of measure.

**Table of contents**

.. contents::
:local:

Usage
=====

Usage
=====

To use this module:

1. Create one or more packaging UoMs (for example, *Pack of 100*), using
the same reference unit as the product base UoM.
2. On the product form, set the **Sales Multiple** field to the desired
UoM.
3. When entering quantities on a sales order line, the quantity is
automatically rounded **UP** to the nearest valid multiple.

If no Sales Multiple is set on the product, no rounding is applied.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/sale-workflow/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/sale-workflow/issues/new?body=module:%20sale_product_multiple_qty%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Camptocamp SA

Contributors
------------

- `Camptocamp <https://www.camptocamp.com>`__:

- Maksym Yankin <maksym.yankin@camptocamp.com>
- Ivan Todorovich <ivan.todorovich@camptocamp.com>
- Gaëtan Vaujour <gaetan.vaujour@camptocamp.com>

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

.. |maintainer-yankinmax| image:: https://github.com/yankinmax.png?size=40px
:target: https://github.com/yankinmax
:alt: yankinmax

Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-yankinmax|

This module is part of the `OCA/sale-workflow <https://github.com/OCA/sale-workflow/tree/19.0/sale_product_multiple_qty>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions sale_product_multiple_qty/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
19 changes: 19 additions & 0 deletions sale_product_multiple_qty/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Sale Product Multiple Qty",
"summary": "Allows defining a sales quantity multiple on products.",
"version": "19.0.1.0.0",
"category": "Sales",
"website": "https://github.com/OCA/sale-workflow",
"author": "Camptocamp SA, Odoo Community Association (OCA)",
"license": "AGPL-3",
"installable": True,
"depends": ["sale"],
"maintainers": ["yankinmax"],
"data": [
# Views
"views/product_product_view.xml",
"views/product_template_view.xml",
],
}
3 changes: 3 additions & 0 deletions sale_product_multiple_qty/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import product_product
from . import product_template
from . import sale_order_line
14 changes: 14 additions & 0 deletions sale_product_multiple_qty/models/product_product.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models


class ProductProduct(models.Model):
_inherit = "product.product"

sale_multiple_uom_id = fields.Many2one(
comodel_name="uom.uom",
string="Sales Multiple",
help="When set, sale order quantities are rounded up to an "
"multiple number of this unit.",
Comment on lines +12 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
help="When set, sale order quantities are rounded up to an "
"multiple number of this unit.",
help="When set, sale order quantities are rounded up to a "
"multiple number of this unit.",

)
34 changes: 34 additions & 0 deletions sale_product_multiple_qty/models/product_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models


class ProductTemplate(models.Model):
_inherit = "product.template"

sale_multiple_uom_id = fields.Many2one(
comodel_name="uom.uom",
string="Sales Multiple",
compute="_compute_sale_multiple_uom_id",
inverse="_inverse_sale_multiple_uom_id",
store=True,
help="When set, sale order quantities are rounded up to an "
"multiple number of this unit.",
Comment on lines +15 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
help="When set, sale order quantities are rounded up to an "
"multiple number of this unit.",
help="When set, sale order quantities are rounded up to a "
"multiple number of this unit.",

)

@api.depends("product_variant_ids", "product_variant_ids.sale_multiple_uom_id")
def _compute_sale_multiple_uom_id(self):
self.sale_multiple_uom_id = self.env["uom.uom"]
for template in self.filtered(
lambda template: len(template.product_variant_ids) == 1
):
template.sale_multiple_uom_id = (
template.product_variant_ids.sale_multiple_uom_id
)

def _inverse_sale_multiple_uom_id(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.sale_multiple_uom_id = (
template.sale_multiple_uom_id
)
152 changes: 152 additions & 0 deletions sale_product_multiple_qty/models/sale_order_line.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import math

from odoo import api, fields, models


class SaleOrderLine(models.Model):
_inherit = "sale.order.line"

def _sale_multiple_uom_has_unit_reference(self):
"""Return whether both UoMs share ``Units`` as common reference.

This is used to distinguish quantity-based UoMs from dimensional UoMs.

When both the order line UoM and the sales multiple UoM share the
``uom.product_uom_unit`` reference, the sellable quantity in the order
line UoM should remain an integer count of items or packs.

Examples:
- Pack of 5 and Pack of 100 -> True
- Pack of 6 and Pack of 100 -> True
- 400 g and 1 kg -> False
"""
self.ensure_one()
unit_uom = self.env.ref("uom.product_uom_unit", raise_if_not_found=False)
if not unit_uom:
return False
multiple_uom = self.product_id.sale_multiple_uom_id
return self.product_uom_id._has_common_reference(
unit_uom
) and multiple_uom._has_common_reference(unit_uom)

def _get_sale_multiple_step_qty(self) -> float:
"""Return the effective quantity step in the order line UoM.

The sales multiple is stored as a UoM on the product. This method
expresses one unit of that sales multiple UoM in the order line UoM.

For UoMs sharing ``Units`` as common reference, the result is ceiled to
keep an integer count of items or packs.

Examples:
- sales multiple UoM: Pack of 100
order line UoM: Pack of 5
step quantity: 20

- sales multiple UoM: Pack of 100
order line UoM: Pack of 6
raw step: 16.666...
effective step quantity: 17

- sales multiple UoM: 1 kg
order line UoM: 400 g
step quantity: 2.5
"""
self.ensure_one()
multiple_uom = self.product_id.sale_multiple_uom_id
step_qty = multiple_uom._compute_quantity(1.0, self.product_uom_id)
if self._sale_multiple_uom_has_unit_reference():
step_qty = math.ceil(step_qty)
return step_qty

def _round_sale_qty_to_multiple(self, qty_to_order: float) -> float:
"""Round the order line quantity to a multiple of the sales multiple UoM.

:param qty_to_order: quantity expressed in the order line UoM (product_uom_id).
:return: rounded quantity expressed in the order line UoM (product_uom_id).

This method is inspired by
``stock.warehouse.orderpoint::_get_multiple_rounded_qty``.
Round using "UP" strategy to the nearest multiple of
product_id.sale_multiple_uom_id:

- Convert qty_to_order from the product_uom_id to the
product_id.sale_multiple_uom_id
- Round the quantity using "UP" strategy
- Convert back to the product_uom_id quantity

Being said, for the UoMs which share common reference unit (e.g. Units)
the sales multiple UoM is divisible by the order line UoM
and the division result is usually an integer.
But for dimensional UoMs like (e.g. Kg(s), Meter(s)) the rounding result
can be a floating point number which is perfectly acceptable.

For example (compatible UoMs: 100 is divisible by 5):
- order line UoM: Pack of 5 (5 units)
- sales multiple UoM: Pack of 100 (100 units)
- qty_to_order = 15 packs are rounded to 20 packs (100 units):
15 packs of 5 units = 75 units
-> 1 pack of 100 = 100 units -> 20 packs of 5 units
- qty_to_order = 55 packs are rounded to 60 packs (300 units):
55 packs of 5 units = 275 units
-> 3 packs of 100 = 300 units -> 60 packs of 5 units

For example (compatible UoMs: 100 is not divisible by 6):
- order line UoM: Pack of 6 (6 units)
- sales multiple UoM: Pack of 100 (100 units)
- qty_to_order = 13 packs are rounded to 16.67 packs (100.02 units):
13 packs of 6 units = 78 units -> 1.0 pack of 100 = 100 units
-> 16.6667 packs of 6 units => ceiled to 17

For example (dimensional UoMs: 1 kg is not divisible by 400g):
- order line UoM: 400g (0.4 kg)
- sales multiple UoM: 1 kg
- qty_to_order = 2 packs are rounded to 2.5 packs (1 kg):
2 packs of 400g = 0.8 kg -> 1.0 pack of 1 kg = 1 kg
-> 2.5 packs of 400g = 1 kg (fractional, must NOT be ceiled to 3)
"""
self.ensure_one()
multiple_uom = self.product_id.sale_multiple_uom_id
# For UoMs sharing ``Units`` as common reference, keep quantities that
# are already multiples of the effective step unchanged.
if self._sale_multiple_uom_has_unit_reference():
step_qty = self._get_sale_multiple_step_qty()
multiple = qty_to_order / step_qty
rounded_multiple = fields.Float.round(
multiple, precision_digits=0, rounding_method="UP"
)
if multiple_uom.compare(rounded_multiple, multiple) == 0:
return qty_to_order

packs = self.product_uom_id._compute_quantity(qty_to_order, multiple_uom)
packs = fields.Float.round(packs, precision_digits=0, rounding_method="UP")
qty_rounded = multiple_uom._compute_quantity(packs, self.product_uom_id)
# For UoMs sharing ``Units`` as common reference,
# we ceil the result to keep an integer count of items or packs.
if self._sale_multiple_uom_has_unit_reference():
qty_rounded = math.ceil(qty_rounded)
return qty_rounded

@api.onchange("product_id", "product_uom_qty", "product_uom_id")
def _onchange_product_uom_qty_round_multiple(self):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, as product_uom_qty is a computed field and onchanges should be avoided, this should be changed to compute_product_uom_qty()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ivantodorovich what do you think?

Copy link
Copy Markdown
Contributor

@ivantodorovich ivantodorovich Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR we used onchange to not have this UX rounding play out for programatic assignation, and keep it only for forms.

In case of programatic assignation, no autofix is done and the constraint plays out -- which is better to detect possible errors

Copy link
Copy Markdown
Contributor Author

@yankinmax yankinmax Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our goal is to propose the rounded value to a user. That's it.
AFAIR, the input value still can be used and there is no real impact anywhere except the forms.
Moreover, we dropped an idea to have any constraints here to allow rounding with decimals, because only the UoM's that share "Unit" as a common reference and have an integer as a sales multiple.

We probably might add a tiny improvement for the rounding, so the UoM's that share "Unit" as a common reference are rounded to the nearest multiple integer.

So, this example:

order line UoM: Pack of 6 (6 units)
sales multiple UoM: Pack of 100 (100 units)
ordering 13 packs of 6 units (78 units) is rounded to 16.67 packs (100.02 units);

Will have the rounded value 17.

"""Round product_uom_qty to a multiple of the sales multiple UoM.

If sales multiple UoM is set on the product, this onchange rounds
the order line quantity to the nearest multiple of that UoM.
"""
for line in self:
multiple_uom = line.product_id.sale_multiple_uom_id
if not multiple_uom:
continue

qty_to_order = line.product_uom_qty or 0.0
if qty_to_order <= 0:
continue
rounded_qty = line._round_sale_qty_to_multiple(qty_to_order)
# ``uom.uom.compare`` returns -1, 0 or 1 depending on whether
# ``rounded_qty`` is lower than, equal to, or greater than
# ``qty_to_order`` (within UoM rounding precision).
if line.product_uom_id.compare(rounded_qty, qty_to_order) != 0:
line.product_uom_qty = rounded_qty
3 changes: 3 additions & 0 deletions sale_product_multiple_qty/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
4 changes: 4 additions & 0 deletions sale_product_multiple_qty/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- [Camptocamp](https://www.camptocamp.com):
- Maksym Yankin \<<maksym.yankin@camptocamp.com>\>
- Ivan Todorovich \<<ivan.todorovich@camptocamp.com>\>
- Gaëtan Vaujour \<<gaetan.vaujour@camptocamp.com>\>
21 changes: 21 additions & 0 deletions sale_product_multiple_qty/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Sales Product Multiple Quantity
===============================

This module adds a **Sales Multiple** unit of measure on products.

When a Sales Multiple is set, sales order line quantities are automatically
rounded **UP** to the nearest multiple of the selected unit of measure.
This is useful for products that must be sold in fixed pack sizes
(boxes, bundles, pallets, etc.).

The rounding is performed by converting the entered quantity to the Sales
Multiple UoM, rounding the number of packs **UP**, and converting the result back to the order line UoM.

For example, with a Sales Multiple of *Pack of 100*:
- ordering 15 packs of 5 units (75 units) is rounded to 20 packs (100 units);
- ordering 55 packs of 5 units (275 units) is rounded to 60 packs (300 units).

If the Sales Multiple UoM is not divisible by the order line UoM, the rounded
quantity may be fractional. It is the user's responsibility to
configure compatible units of measure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

Loading
Loading