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
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
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
35 changes: 35 additions & 0 deletions website_sale_product_multiple_qty/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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/interactions/**/*",
"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/**/*",
"website_sale_product_multiple_qty/static/src/js/product/**/*",
"website_sale_product_multiple_qty/static/src/js/quantity_buttons/**/*",
],
},
}
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
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,
"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