Skip to content

Commit 8f5dcd9

Browse files
committed
[ADD] sale_product_multiple_qty
1 parent 1c4cb48 commit 8f5dcd9

File tree

14 files changed

+987
-0
lines changed

14 files changed

+987
-0
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
5+
=========================
6+
Sale Product Multiple Qty
7+
=========================
8+
9+
..
10+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
11+
!! This file is generated by oca-gen-addon-readme !!
12+
!! changes will be overwritten. !!
13+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14+
!! source digest: sha256:fae80e9f1799cbaea5d031c91bf0456af4741e0d2cf9edd5af4f30424377956f
15+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16+
17+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
18+
:target: https://odoo-community.org/page/development-status
19+
:alt: Beta
20+
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
21+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
22+
:alt: License: AGPL-3
23+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github
24+
:target: https://github.com/OCA/sale-workflow/tree/19.0/sale_product_multiple_qty
25+
:alt: OCA/sale-workflow
26+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
27+
:target: https://translation.odoo-community.org/projects/sale-workflow-19-0/sale-workflow-19-0-sale_product_multiple_qty
28+
:alt: Translate me on Weblate
29+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
30+
:target: https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&target_branch=19.0
31+
:alt: Try me on Runboat
32+
33+
|badge1| |badge2| |badge3| |badge4| |badge5|
34+
35+
Sales Product Multiple Quantity
36+
===============================
37+
38+
This module adds a **Sales Multiple** unit of measure on products.
39+
40+
When a Sales Multiple is set, sales order line quantities are
41+
automatically rounded **UP** to the nearest multiple of the selected
42+
unit of measure. This is useful for products that must be sold in fixed
43+
pack sizes (boxes, bundles, pallets, etc.).
44+
45+
The rounding is performed by converting the entered quantity to the
46+
Sales Multiple UoM, rounding the number of packs **UP** to the nearest
47+
integer, and converting the result back to the order line UoM.
48+
49+
For example, with a Sales Multiple of *Pack of 100*:
50+
51+
- ordering 15 packs of 5 units (75 units) is rounded to 20 packs (100
52+
units);
53+
- ordering 55 packs of 5 units (275 units) is rounded to 60 packs (300
54+
units).
55+
56+
If the Sales Multiple UoM is not divisible by the order line UoM, the
57+
rounded quantity may be fractional. In such cases, the module displays a
58+
warning and prevents saving invalid quantities. It is the user's
59+
responsibility to configure compatible units of measure.
60+
61+
**Table of contents**
62+
63+
.. contents::
64+
:local:
65+
66+
Usage
67+
=====
68+
69+
Usage
70+
=====
71+
72+
To use this module:
73+
74+
1. Create one or more packaging UoMs (for example, *Pack of 100*), using
75+
the same reference unit as the product base UoM.
76+
2. On the product form, set the **Sales Multiple** field to the desired
77+
UoM.
78+
3. When entering quantities on a sales order line, the quantity is
79+
automatically rounded **UP** to the nearest valid multiple.
80+
81+
If the selected UoM is not compatible with the Sales Multiple, a warning
82+
is displayed and invalid quantities cannot be saved.
83+
84+
If no Sales Multiple is set on the product, no rounding is applied.
85+
86+
Bug Tracker
87+
===========
88+
89+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/sale-workflow/issues>`_.
90+
In case of trouble, please check there if your issue has already been reported.
91+
If you spotted it first, help us to smash it by providing a detailed and welcomed
92+
`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**>`_.
93+
94+
Do not contact contributors directly about support or help with technical issues.
95+
96+
Credits
97+
=======
98+
99+
Authors
100+
-------
101+
102+
* Camptocamp SA
103+
104+
Contributors
105+
------------
106+
107+
- `Camptocamp <https://www.camptocamp.com>`__:
108+
109+
- Maksym Yankin <maksym.yankin@camptocamp.com>
110+
- Ivan Todorovich <ivan.todorovich@camptocamp.com>
111+
- Gaëtan Vaujour <gaetan.vaujour@camptocamp.com>
112+
113+
Maintainers
114+
-----------
115+
116+
This module is maintained by the OCA.
117+
118+
.. image:: https://odoo-community.org/logo.png
119+
:alt: Odoo Community Association
120+
:target: https://odoo-community.org
121+
122+
OCA, or the Odoo Community Association, is a nonprofit organization whose
123+
mission is to support the collaborative development of Odoo features and
124+
promote its widespread use.
125+
126+
.. |maintainer-yankinmax| image:: https://github.com/yankinmax.png?size=40px
127+
:target: https://github.com/yankinmax
128+
:alt: yankinmax
129+
130+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
131+
132+
|maintainer-yankinmax|
133+
134+
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.
135+
136+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
{
4+
"name": "Sale Product Multiple Qty",
5+
"summary": "Allows defining a sales quantity multiple on products.",
6+
"version": "19.0.1.0.0",
7+
"category": "Sales",
8+
"website": "https://github.com/OCA/sale-workflow",
9+
"author": "Camptocamp SA, Odoo Community Association (OCA)",
10+
"license": "AGPL-3",
11+
"installable": True,
12+
"depends": ["sale"],
13+
"maintainers": ["yankinmax"],
14+
"data": [
15+
# Views
16+
"views/product_view.xml",
17+
],
18+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import product_product
2+
from . import sale_order_line
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
from odoo import fields, models
4+
5+
6+
class ProductProduct(models.Model):
7+
_inherit = "product.product"
8+
9+
sale_multiple_uom_id = fields.Many2one(
10+
comodel_name="uom.uom",
11+
string="Sales Multiple",
12+
help="When set, sale order quantities are rounded up to an "
13+
"multiple number of this unit.",
14+
)
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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+
6+
7+
class SaleOrderLine(models.Model):
8+
_inherit = "sale.order.line"
9+
10+
def _round_sale_qty_to_multiple(self, qty_to_order: float) -> float:
11+
"""Round the order line quantity to a multiple of the sales multiple UoM.
12+
13+
:param qty_to_order: quantity expressed in the order line UoM (product_uom_id).
14+
:return: rounded quantity expressed in the order line UoM (product_uom_id).
15+
16+
This method is inspired by
17+
``stock.warehouse.orderpoint::_get_multiple_rounded_qty``.
18+
Round using "UP" strategy to the nearest multiple of
19+
product_id.sale_multiple_uom_id:
20+
21+
- Convert qty_to_order from the product_uom_id to the
22+
product_id.sale_multiple_uom_id
23+
- Round the quantity using "UP" strategy
24+
- Convert back to the product_uom_id quantity
25+
26+
Being said, if sales multiple UoM is divisible by the order line UoM,
27+
the result will be an integer.
28+
Otherwise, we expect rounding issues.
29+
It's up to the user to choose compatible UoMs.
30+
31+
For example (compatible UoMs: 100 is divisible by 5):
32+
- order line UoM: Pack of 5 (5 units)
33+
- sales multiple UoM: Pack of 100 (100 units)
34+
- qty_to_order = 15 packs is rounded to 20 packs (100 units):
35+
15 packs of 5 = 75 units -> 1 pack of 100 = 100 units -> 20 packs of 5
36+
- qty_to_order = 55 packs is rounded to 60 packs (300 units):
37+
55 packs of 5 = 275 units -> 3 packs of 100 = 300 units -> 60 packs of 5
38+
39+
For example (fractional result: 100 is not divisible by 6):
40+
- order line UoM: Pack of 6 (6 units)
41+
- sales multiple UoM: Pack of 100 (100 units)
42+
- qty_to_order = 13 packs is rounded to 17 packs (100.02 units):
43+
13 packs of 6 = 78 units -> 1.0 pack of 100 = 100 units
44+
-> 16.6667 packs of 6
45+
"""
46+
self.ensure_one()
47+
packs = self.product_uom_id._compute_quantity(
48+
qty_to_order, self.product_id.sale_multiple_uom_id
49+
)
50+
packs = fields.Float.round(packs, precision_digits=0, rounding_method="UP")
51+
qty_rounded = self.product_id.sale_multiple_uom_id._compute_quantity(
52+
packs, self.product_uom_id
53+
)
54+
return qty_rounded
55+
56+
def _nearest_valid_integer_qty(self, entered_qty: float) -> float:
57+
"""Return the nearest valid integer qty bigger than entered (UP).
58+
59+
The idea is simply to try integer quantities bigger than the entered
60+
quantity until we find one that, when rounded to the sales multiple UoM,
61+
gives an integer result.
62+
"""
63+
self.ensure_one()
64+
qty_to_check = int(entered_qty) + 1
65+
while True:
66+
rounded_qty = self._round_sale_qty_to_multiple(qty_to_check)
67+
if rounded_qty.is_integer():
68+
return rounded_qty
69+
qty_to_check += 1
70+
71+
def _prepare_incompatible_uom_warning_msg(
72+
self,
73+
entered_qty: float,
74+
rounded_qty: float,
75+
) -> str:
76+
"""Prepare the warning message for incompatible UoMs.
77+
78+
:param entered_qty: quantity entered by the user.
79+
:param rounded_qty: rounded quantity to the sales multiple UoM.
80+
:return: warning message string.
81+
"""
82+
self.ensure_one()
83+
multiple_uom = self.product_id.sale_multiple_uom_id
84+
valid_qty = self._nearest_valid_integer_qty(entered_qty)
85+
return self.env._(
86+
"Incompatible UoMs.\n"
87+
"The entered qty %(entered_qty).2f is rounded to "
88+
"%(rounded_qty).2f which is not valid "
89+
"for product '%(product)s'.\n"
90+
"It should be a multiple of %(multiple)s.\n"
91+
"The nearest valid qty is %(valid_qty).2f.",
92+
entered_qty=entered_qty,
93+
rounded_qty=rounded_qty,
94+
product=self.product_id.display_name,
95+
multiple=multiple_uom.display_name,
96+
valid_qty=valid_qty,
97+
)
98+
99+
@api.onchange("product_id", "product_uom_qty", "product_uom_id")
100+
def _onchange_product_uom_qty(self):
101+
"""Round product_uom_qty to a multiple of the sales multiple UoM.
102+
103+
If sales multiple UoM is set on the product, this onchange rounds
104+
the order line quantity to the nearest multiple of that UoM using
105+
the "_round_sale_qty_to_multiple" method.
106+
107+
If the rounded quantity is a floating point number, it means that the
108+
selected UoM is not compatible with the sales multiple UoM.
109+
See an example in the "_round_sale_qty_to_multiple" method.
110+
111+
For such cases, a warning is displayed to inform the user
112+
about the incompatibility of UoMs.
113+
"""
114+
for line in self:
115+
multiple_uom = line.product_id.sale_multiple_uom_id
116+
if not multiple_uom:
117+
continue
118+
119+
qty_to_order = line.product_uom_qty or 0.0
120+
if qty_to_order <= 0:
121+
continue
122+
123+
rounded_qty = line._round_sale_qty_to_multiple(qty_to_order)
124+
if not rounded_qty.is_integer():
125+
msg = line._prepare_incompatible_uom_warning_msg(
126+
entered_qty=qty_to_order,
127+
rounded_qty=rounded_qty,
128+
)
129+
return {"warning": {"title": self.env._("Warning"), "message": msg}}
130+
equal_qty = line.product_uom_id.compare(rounded_qty, qty_to_order) == 0
131+
if not equal_qty:
132+
line.product_uom_qty = rounded_qty
133+
134+
@api.constrains(
135+
"product_id",
136+
"product_uom_qty",
137+
"product_uom_id",
138+
)
139+
def _check_rounded_sale_multiple_qty(self):
140+
"""Ensure product_uom_qty is a multiple of the sales multiple UoM."""
141+
for line in self:
142+
multiple_uom = line.product_id.sale_multiple_uom_id
143+
if not multiple_uom:
144+
continue
145+
146+
qty_to_order = line.product_uom_qty or 0.0
147+
if qty_to_order <= 0:
148+
continue
149+
150+
rounded_qty = line._round_sale_qty_to_multiple(qty_to_order)
151+
if not rounded_qty.is_integer():
152+
msg = line._prepare_incompatible_uom_warning_msg(
153+
entered_qty=qty_to_order,
154+
rounded_qty=rounded_qty,
155+
)
156+
raise ValidationError(msg)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["whool"]
3+
build-backend = "whool.buildapi"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- [Camptocamp](https://www.camptocamp.com):
2+
- Maksym Yankin \<<maksym.yankin@camptocamp.com>\>
3+
- Ivan Todorovich \<<ivan.todorovich@camptocamp.com>\>
4+
- Gaëtan Vaujour \<<gaetan.vaujour@camptocamp.com>\>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
Sales Product Multiple Quantity
2+
===============================
3+
4+
This module adds a **Sales Multiple** unit of measure on products.
5+
6+
When a Sales Multiple is set, sales order line quantities are automatically
7+
rounded **UP** to the nearest multiple of the selected unit of measure.
8+
This is useful for products that must be sold in fixed pack sizes
9+
(boxes, bundles, pallets, etc.).
10+
11+
The rounding is performed by converting the entered quantity to the Sales
12+
Multiple UoM, rounding the number of packs **UP** to the nearest integer,
13+
and converting the result back to the order line UoM.
14+
15+
For example, with a Sales Multiple of *Pack of 100*:
16+
- ordering 15 packs of 5 units (75 units) is rounded to 20 packs (100 units);
17+
- ordering 55 packs of 5 units (275 units) is rounded to 60 packs (300 units).
18+
19+
If the Sales Multiple UoM is not divisible by the order line UoM, the rounded
20+
quantity may be fractional. In such cases, the module displays a warning and
21+
prevents saving invalid quantities. It is the user's responsibility to
22+
configure compatible units of measure.
23+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Usage
2+
=====
3+
4+
To use this module:
5+
6+
1. Create one or more packaging UoMs (for example, *Pack of 100*), using the
7+
same reference unit as the product base UoM.
8+
2. On the product form, set the **Sales Multiple** field to the desired UoM.
9+
3. When entering quantities on a sales order line, the quantity is
10+
automatically rounded **UP** to the nearest valid multiple.
11+
12+
If the selected UoM is not compatible with the Sales Multiple, a warning is
13+
displayed and invalid quantities cannot be saved.
14+
15+
If no Sales Multiple is set on the product, no rounding is applied.

0 commit comments

Comments
 (0)