-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
[ADD] sale_product_multiple_qty #4143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 19.0
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from . import models |
| 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", | ||
| ], | ||
| } |
| 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 |
| 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.", | ||
| ) | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| ) | ||||||||||
|
|
||||||||||
| @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 | ||||||||||
| ) | ||||||||||
| 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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMHO, as
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ivantodorovich what do you think?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 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) 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| [build-system] | ||
| requires = ["whool"] | ||
| build-backend = "whool.buildapi" |
| 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>\> |
| 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. | ||||
|
|
||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.