Skip to content
Closed
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
1 change: 1 addition & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
odoo-addon-sale_product_multiple_qty @ git+https://github.com/OCA/sale-workflow.git@refs/pull/4143/head#subdirectory=sale_product_multiple_qty
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This references PR #4143 which is still unmerged, causing the CI Detect unreleased dependencies failure. This will resolve once sale_product_multiple_qty is merged, but it blocks merge of this PR as well.

215 changes: 215 additions & 0 deletions website_sale_product_multiple_qty/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association

=================================
Website 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/website_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-website_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|

Website Sale Product Multiple Quantity
======================================

This module extends the eCommerce flow to support **Sales Multiples**
(packaging quantities) directly on the product page, in the cart, and in
the product configurator.

When a product (or variant) has a *Sales Multiple* configured, the
quantity entered by the customer on the website is automatically rounded
to a valid multiple according to the interaction type.

The rounding logic is applied dynamically when the customer:

- Opens the product page
- Changes the product variant
- Clicks the "+" (increase) button
- Clicks the "–" (decrease) button
- Manually enters a quantity
- Presses **Enter** inside the quantity input
- Changes quantities in the cart

Rounding Rules
--------------

The behavior is designed to be predictable and consistent with
packaging-based sales.

Product Page
~~~~~~~~~~~~

- On page load (or variant switch):

- If the product is a multiple product, the default quantity is set to
at least one valid multiple.
- Otherwise, the standard minimum quantity is used.

- When clicking "+":

- The quantity increases by one full multiple step.

- When clicking "–":

- The quantity decreases by one full multiple step.
- The quantity never goes below the minimum allowed value.

- When manually entering a quantity:

- The value is rounded **UP** to the nearest valid multiple.

- When pressing **Enter**:

- The value is processed like a manual change (no form submission).
- Rounding logic is applied before any RPC call.

Cart
~~~~

- When clicking "+":

- The quantity increases by one full multiple step.

- When clicking "–":

- The quantity decreases by one full multiple step.
- If it goes below the first multiple, it becomes ``0`` (line removal
behavior).

- When manually entering a quantity:

- The value is rounded **UP** to the nearest valid multiple.
- ``0`` remains allowed to preserve standard cart removal behavior.

Example
-------

If a product is sold in multiples of 500:

- Entering ``1`` → becomes ``500``
- Entering ``499`` → becomes ``500``
- Entering ``501`` → becomes ``1000``
- Clicking "–" from ``500`` (product page) → becomes ``500`` (minimum)
- Clicking "–" from ``500`` (cart) → becomes ``0``
- Clicking "+" from ``0`` (cart) → becomes ``500``

Configuration
-------------

It is the responsibility of the user to configure compatible Units of
Measure.

The Sales Multiple UoM must belong to the same UoM category as the
product's sales UoM. Incorrect configuration (for example, mixing
unrelated UoM categories) may lead to unexpected quantity conversions
and rounding results.

The module assumes that Units of Measure are properly defined and
conversion ratios are accurate.

**Table of contents**

.. contents::
:local:

Usage
=====

Usage
=====

Configuration
-------------

1. Go to *Sales → Products*.
2. Open a product.
3. Set a **Sales Multiple UoM** (for example, *Pack of 500*).

The selected UoM must belong to the same UoM category as the product's
sales unit of measure.

Important
---------

Ensure that the Sales Multiple UoM is correctly configured:

- It must belong to the same UoM category as the product's sales UoM.
- Conversion ratios must be accurate.
- The multiple should reflect the real packaging quantity.

Incorrect UoM configuration may result in unexpected rounding behavior.

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:%20website_sale_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/website_sale_product_multiple_qty>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
2 changes: 2 additions & 0 deletions website_sale_product_multiple_qty/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
32 changes: 32 additions & 0 deletions website_sale_product_multiple_qty/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Website Sale Product Multiple Qty",
"summary": "Allows setting a multiple quantity for products on the website.",
"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": [
# Odoo/core
"website_sale",
# OCA/sale-workflow
"sale_product_multiple_qty",
],
"maintainers": ["yankinmax"],
"data": [
# Views
"views/templates.xml",
],
"assets": {
"web.assets_backend": [
"website_sale_product_multiple_qty/static/src/js/product/**/*",
"website_sale_product_multiple_qty/static/src/js/quantity_buttons/**/*",
],
"web.assets_frontend": [
"website_sale_product_multiple_qty/static/src/js/interactions/**/*",
],
},
}
2 changes: 2 additions & 0 deletions website_sale_product_multiple_qty/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import product_configurator
from . import variant
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo.addons.website_sale.controllers.product_configurator import (
WebsiteSaleProductConfiguratorController,
)


class WebsiteSaleProductConfiguratorMultipleController(
WebsiteSaleProductConfiguratorController
):
def _get_sale_multiple_vals(self, product_or_template):
# Get product variant if we got a single variant template
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This method is duplicated verbatim in models/product_template.py. Consider reusing the model method instead:

product_or_template._get_sale_multiple_vals(product_or_template)

Or extract to a shared mixin to avoid drift.

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.

This is addressed, thx

product = product_or_template
if product._name == "product.template":
product = product.product_variant_id

if multiple_uom := product.sale_multiple_uom_id:
return {
"is_multiple": 1,
"sale_multiple_qty": multiple_uom.factor,
}
return {
"is_multiple": 0,
"sale_multiple_qty": 1,
}

def _get_basic_product_information(
self,
product_or_template,
pricelist,
combination,
currency=None,
date=None,
**kwargs,
):
product_info = super()._get_basic_product_information(
product_or_template,
pricelist,
combination,
currency=currency,
date=date,
**kwargs,
)
product_info.update(self._get_sale_multiple_vals(product_or_template))
return product_info
43 changes: 43 additions & 0 deletions website_sale_product_multiple_qty/controllers/variant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo.http import route

from odoo.addons.website_sale.controllers.variant import WebsiteSaleVariantController


class WebsiteSaleRoundingVariantController(WebsiteSaleVariantController):
@route(
"/website_sale/get_combination_info",
type="jsonrpc",
auth="public",
methods=["POST"],
website=True,
readonly=True,
)
def get_combination_info_website(
self,
product_template_id,
product_id,
combination,
add_qty,
uom_id=None,
**kwargs,
):
combination_info = super().get_combination_info_website(
product_template_id=product_template_id,
product_id=product_id,
combination=combination,
add_qty=add_qty,
uom_id=uom_id,
**kwargs,
)
incoming_pid = int(product_id or 0)
resolved_pid = int(combination_info.get("product_id") or 0)

# Detect if the variant has changed to be able
# to reset the quantity to the default value for the new variant if needed.
combination_info["variant_switched"] = bool(
resolved_pid and resolved_pid != incoming_pid
)
return combination_info
1 change: 1 addition & 0 deletions website_sale_product_multiple_qty/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import product_template
34 changes: 34 additions & 0 deletions website_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, models


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

def _get_sale_multiple_vals(self, product_or_template):
# Get product variant if we got a single variant template
product = product_or_template
if product._name == "product.template":
product = product.product_variant_id

if multiple_uom := product.sale_multiple_uom_id:
return {
"is_multiple": 1,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth double-checking which UoM field gives the correct step value here. In Odoo's UoM model, factor is the ratio to the reference UoM (e.g. 0.002 for a pack of 500 units). If the intent is to get 500 as the step, you may need factor_inv / relative_factor instead. Or does the sale_product_multiple_qty module set up UoMs where factor already holds the packaging quantity?

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.

I've checked and it's ok to use factor if I always have the same "Units" relative UoM.
It could make sense to use relative_factor if at some point I have "Box of 10" with relative UoM as "Box of 500". So, it's a multi-level architecture, so the sales multiple is itself a reference UoM. Then factor will be 500 and relative_factor will be 10: 10 boxes of 500 boxes.
I don't cover such cases here for now.

"sale_multiple_qty": multiple_uom.factor,
}
return {
"is_multiple": 0,
"sale_multiple_qty": 1,
}

@api.model
def _get_additionnal_combination_info(
self, product_or_template, quantity, uom, date, website
):
# OVERRIDE: to update the combination info with the multiple related info
combination_info = super()._get_additionnal_combination_info(
product_or_template, quantity, uom, date, website
)
combination_info.update(self._get_sale_multiple_vals(product_or_template))
return combination_info
3 changes: 3 additions & 0 deletions website_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 website_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>\>
Loading
Loading