Skip to content

Commit 0b79786

Browse files
committed
[ADD] sale_product_multiple_qty
1 parent 1c4cb48 commit 0b79786

File tree

14 files changed

+858
-0
lines changed

14 files changed

+858
-0
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
This module allows to add a configurable Sales Multiple on products.
36+
When set, sales quantities entered in the product base UoM are
37+
automatically rounded to a multiple of the selected packaging unit. For
38+
example, if set to 'Box of 100', the product can only be sold in
39+
quantities equivalent to whole boxes (100, 200, 300 units, etc.).
40+
Rounding direction depends on user edit: increase → round UP; decrease →
41+
round DOWN; Rounding is applied only when the sale order line UoM equals
42+
the product base UoM.
43+
44+
**Table of contents**
45+
46+
.. contents::
47+
:local:
48+
49+
Usage
50+
=====
51+
52+
To use this module, you need to:
53+
54+
- create a packaging UoM (e.g. Pack of 50) and set Reference Unit equals
55+
your base UoM, Contains (e.g. 50)
56+
- on the Product form set base UoM (e.g. Unit(s)) and under Sales tab,
57+
set Sales Multiple (e.g. Pack of 50)
58+
59+
Bug Tracker
60+
===========
61+
62+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/sale-workflow/issues>`_.
63+
In case of trouble, please check there if your issue has already been reported.
64+
If you spotted it first, help us to smash it by providing a detailed and welcomed
65+
`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**>`_.
66+
67+
Do not contact contributors directly about support or help with technical issues.
68+
69+
Credits
70+
=======
71+
72+
Authors
73+
-------
74+
75+
* Camptocamp SA
76+
77+
Contributors
78+
------------
79+
80+
- `Camptocamp <https://www.camptocamp.com>`__:
81+
82+
- Maksym Yankin <maksym.yankin@camptocamp.com>
83+
84+
Maintainers
85+
-----------
86+
87+
This module is maintained by the OCA.
88+
89+
.. image:: https://odoo-community.org/logo.png
90+
:alt: Odoo Community Association
91+
:target: https://odoo-community.org
92+
93+
OCA, or the Odoo Community Association, is a nonprofit organization whose
94+
mission is to support the collaborative development of Odoo features and
95+
promote its widespread use.
96+
97+
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.
98+
99+
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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": "This module adds a Sales Multiple Unit of Measure on products.",
6+
"version": "19.0.1.0.1",
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+
"data": [
14+
# Views
15+
"views/product_view.xml",
16+
],
17+
}
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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.fields import Domain
5+
6+
7+
class ProductProduct(models.Model):
8+
_inherit = "product.product"
9+
10+
allowed_sale_multiple_uom_ids = fields.Many2many(
11+
comodel_name="uom.uom",
12+
compute="_compute_allowed_sale_multiple_uom_ids",
13+
)
14+
sale_multiple_uom_id = fields.Many2one(
15+
comodel_name="uom.uom",
16+
string="Sales Multiple",
17+
domain="[('id', 'in', allowed_sale_multiple_uom_ids)]",
18+
help="UoM used to define sales quantities as multiples of this unit. "
19+
"Sales quantities will be rounded to a multiple of this unit/packaging.",
20+
)
21+
22+
@api.depends("uom_id")
23+
def _compute_allowed_sale_multiple_uom_ids(self):
24+
"""Compute the allowed UoMs that can be selected as a sales multiple.
25+
26+
The packaging reference unit (``relative_uom_id``) is the product base UoM.
27+
"""
28+
uom = self.env["uom.uom"]
29+
for product in self:
30+
if not product.uom_id:
31+
product.allowed_sale_multiple_uom_ids = False
32+
continue
33+
product.allowed_sale_multiple_uom_ids = uom.search(
34+
Domain("relative_uom_id", "=", product.uom_id.id),
35+
)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
)
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- [Camptocamp](https://www.camptocamp.com):
2+
- Maksym Yankin \<<maksym.yankin@camptocamp.com>\>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
This module allows to add a configurable Sales Multiple on products.
2+
When set, sales quantities entered in the product base UoM are automatically rounded to a multiple of the selected packaging unit.
3+
For example, if set to 'Box of 100', the product can only be sold in quantities equivalent to whole boxes (100, 200, 300 units, etc.).
4+
Rounding direction depends on user edit:
5+
increase → round UP;
6+
decrease → round DOWN;
7+
Rounding is applied only when the sale order line UoM equals the product base UoM.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
To use this module, you need to:
2+
3+
- create a packaging UoM (e.g. Pack of 50) and set Reference Unit equals your base UoM, Contains (e.g. 50)
4+
- on the Product form set base UoM (e.g. Unit(s)) and under Sales tab, set Sales Multiple (e.g. Pack of 50)

0 commit comments

Comments
 (0)