From 68b796a2cc4eaef44845b7e45b21d418fbeff7cb Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Thu, 19 Feb 2026 17:19:53 +0200 Subject: [PATCH 1/3] [19.0][ADD] website_sale_product_multiple_qty --- website_sale_product_multiple_qty/README.rst | 176 ++++++ website_sale_product_multiple_qty/__init__.py | 2 + .../__manifest__.py | 28 + .../controllers/__init__.py | 1 + .../controllers/variant.py | 39 ++ .../models/__init__.py | 1 + .../models/product_template.py | 34 ++ .../pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 4 + .../readme/DESCRIPTION.md | 56 ++ .../readme/USAGE.md | 23 + .../static/description/index.html | 500 ++++++++++++++++++ .../static/src/js/website_sale.esm.js | 80 +++ .../views/templates.xml | 102 ++++ 14 files changed, 1049 insertions(+) create mode 100644 website_sale_product_multiple_qty/README.rst create mode 100644 website_sale_product_multiple_qty/__init__.py create mode 100644 website_sale_product_multiple_qty/__manifest__.py create mode 100644 website_sale_product_multiple_qty/controllers/__init__.py create mode 100644 website_sale_product_multiple_qty/controllers/variant.py create mode 100644 website_sale_product_multiple_qty/models/__init__.py create mode 100644 website_sale_product_multiple_qty/models/product_template.py create mode 100644 website_sale_product_multiple_qty/pyproject.toml create mode 100644 website_sale_product_multiple_qty/readme/CONTRIBUTORS.md create mode 100644 website_sale_product_multiple_qty/readme/DESCRIPTION.md create mode 100644 website_sale_product_multiple_qty/readme/USAGE.md create mode 100644 website_sale_product_multiple_qty/static/description/index.html create mode 100644 website_sale_product_multiple_qty/static/src/js/website_sale.esm.js create mode 100644 website_sale_product_multiple_qty/views/templates.xml diff --git a/website_sale_product_multiple_qty/README.rst b/website_sale_product_multiple_qty/README.rst new file mode 100644 index 00000000000..1195597a7a4 --- /dev/null +++ b/website_sale_product_multiple_qty/README.rst @@ -0,0 +1,176 @@ +.. 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. + +When a product has a *Sales Multiple UoM* 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 +- Clicks the "+" (increase) button +- Clicks the "–" (decrease) button +- Manually enters a quantity + +Rounding Rules +-------------- + +The behavior is designed to be human-friendly and predictable: + +- On page load: The default quantity is rounded **UP** to the nearest + multiple. + +- When clicking "+": The quantity is rounded **UP** to the next valid + multiple. + +- When clicking "–": The quantity is rounded **DOWN** to the previous + valid multiple. + +- When manually entering a quantity: The value is rounded **UP** to the + nearest valid multiple. + +It is possible to set the quantity to ``0`` if the user decreases the +quantity below the first multiple. + +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`` → becomes ``0`` +- Clicking "+" from ``0`` → becomes ``500`` + +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 `_. +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp SA + +Contributors +------------ + +- `Camptocamp `__: + + - Maksym Yankin + - Ivan Todorovich + - Gaëtan Vaujour + +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 `__: + +|maintainer-yankinmax| + +This module is part of the `OCA/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_sale_product_multiple_qty/__init__.py b/website_sale_product_multiple_qty/__init__.py new file mode 100644 index 00000000000..91c5580fed3 --- /dev/null +++ b/website_sale_product_multiple_qty/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_sale_product_multiple_qty/__manifest__.py b/website_sale_product_multiple_qty/__manifest__.py new file mode 100644 index 00000000000..0d7334a4309 --- /dev/null +++ b/website_sale_product_multiple_qty/__manifest__.py @@ -0,0 +1,28 @@ +# 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_frontend": [ + "website_sale_product_multiple_qty/static/src/**/*.js", + ], + }, +} diff --git a/website_sale_product_multiple_qty/controllers/__init__.py b/website_sale_product_multiple_qty/controllers/__init__.py new file mode 100644 index 00000000000..ca58e65bf7b --- /dev/null +++ b/website_sale_product_multiple_qty/controllers/__init__.py @@ -0,0 +1 @@ +from . import variant diff --git a/website_sale_product_multiple_qty/controllers/variant.py b/website_sale_product_multiple_qty/controllers/variant.py new file mode 100644 index 00000000000..07c1365569a --- /dev/null +++ b/website_sale_product_multiple_qty/controllers/variant.py @@ -0,0 +1,39 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.http import request, 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, + ): + if rounding := kwargs.get("multiple_rounding"): + request.update_env( + context=dict(request.env.context, multiple_rounding=rounding) + ) + + return 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, + ) diff --git a/website_sale_product_multiple_qty/models/__init__.py b/website_sale_product_multiple_qty/models/__init__.py new file mode 100644 index 00000000000..e8fa8f6bf1e --- /dev/null +++ b/website_sale_product_multiple_qty/models/__init__.py @@ -0,0 +1 @@ +from . import product_template diff --git a/website_sale_product_multiple_qty/models/product_template.py b/website_sale_product_multiple_qty/models/product_template.py new file mode 100644 index 00000000000..c0781e6eee1 --- /dev/null +++ b/website_sale_product_multiple_qty/models/product_template.py @@ -0,0 +1,34 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + 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 + ) + + if not product_or_template.sale_multiple_uom_id: + return combination_info + rounding = "UP" + if to_rounding := self.env.context.get("multiple_rounding"): + rounding = to_rounding + rounded_qty = self.sale_multiple_uom_id._check_qty( + quantity, self.uom_id, rounding_method=rounding + ) + combination_info.update( + { + "is_multiple": 1, + # The website expects an integer value as an input + # ``website_sale::variant_mixin.js`` + # parseInt(parent.querySelector('input[name="add_qty"]').value). + "multiple_qty": int(rounded_qty), + } + ) + return combination_info diff --git a/website_sale_product_multiple_qty/pyproject.toml b/website_sale_product_multiple_qty/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/website_sale_product_multiple_qty/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_sale_product_multiple_qty/readme/CONTRIBUTORS.md b/website_sale_product_multiple_qty/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..5d29cd16cbf --- /dev/null +++ b/website_sale_product_multiple_qty/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- [Camptocamp](https://www.camptocamp.com): + - Maksym Yankin \<\> + - Ivan Todorovich \<\> + - Gaëtan Vaujour \<\> diff --git a/website_sale_product_multiple_qty/readme/DESCRIPTION.md b/website_sale_product_multiple_qty/readme/DESCRIPTION.md new file mode 100644 index 00000000000..8e2c58d17fa --- /dev/null +++ b/website_sale_product_multiple_qty/readme/DESCRIPTION.md @@ -0,0 +1,56 @@ +Website Sale Product Multiple Quantity +======================================= + +This module extends the eCommerce flow to support **sales multiples** +(packaging quantities) directly on the product page. + +When a product has a *Sales Multiple UoM* 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 +- Clicks the "+" (increase) button +- Clicks the "–" (decrease) button +- Manually enters a quantity + +Rounding Rules +-------------- + +The behavior is designed to be human-friendly and predictable: + +* On page load: + The default quantity is rounded **UP** to the nearest multiple. + +* When clicking "+": + The quantity is rounded **UP** to the next valid multiple. + +* When clicking "–": + The quantity is rounded **DOWN** to the previous valid multiple. + +* When manually entering a quantity: + The value is rounded **UP** to the nearest valid multiple. + +It is possible to set the quantity to ``0`` if the user decreases +the quantity below the first multiple. + +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`` → becomes ``0`` +- Clicking "+" from ``0`` → becomes ``500`` + +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. diff --git a/website_sale_product_multiple_qty/readme/USAGE.md b/website_sale_product_multiple_qty/readme/USAGE.md new file mode 100644 index 00000000000..86d2dd23a76 --- /dev/null +++ b/website_sale_product_multiple_qty/readme/USAGE.md @@ -0,0 +1,23 @@ +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. diff --git a/website_sale_product_multiple_qty/static/description/index.html b/website_sale_product_multiple_qty/static/description/index.html new file mode 100644 index 00000000000..fda689ac1b9 --- /dev/null +++ b/website_sale_product_multiple_qty/static/description/index.html @@ -0,0 +1,500 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Sale Product Multiple Qty

+ +

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+
+

Website Sale Product Multiple Quantity

+

This module extends the eCommerce flow to support sales multiples +(packaging quantities) directly on the product page.

+

When a product has a Sales Multiple UoM 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
  • +
  • Clicks the “+” (increase) button
  • +
  • Clicks the “–” (decrease) button
  • +
  • Manually enters a quantity
  • +
+
+

Rounding Rules

+

The behavior is designed to be human-friendly and predictable:

+
    +
  • On page load: The default quantity is rounded UP to the nearest +multiple.
  • +
  • When clicking “+”: The quantity is rounded UP to the next valid +multiple.
  • +
  • When clicking “–”: The quantity is rounded DOWN to the previous +valid multiple.
  • +
  • When manually entering a quantity: The value is rounded UP to the +nearest valid multiple.
  • +
+

It is possible to set the quantity to 0 if the user decreases the +quantity below the first multiple.

+
+
+

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 → becomes 0
  • +
  • Clicking “+” from 0 → becomes 500
  • +
+

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

+
+
+
+

Usage

+
+
+

Usage

+
+

Configuration

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

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. +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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp SA
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

yankinmax

+

This module is part of the OCA/sale-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js b/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js new file mode 100644 index 00000000000..e5af4b21b1c --- /dev/null +++ b/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js @@ -0,0 +1,80 @@ +import {WebsiteSale} from "@website_sale/interactions/website_sale"; +import {patch} from "@web/core/utils/patch"; + +patch(WebsiteSale.prototype, { + /** + * Catch the rounding method + * + * @override + */ + async start() { + const res = await super.start(...arguments); + // Capture the rounding method on quantity change, either by +/- buttons + this.el.addEventListener( + "pointerdown", + (ev) => { + const minus = ev.target.closest(".css_quantity_minus"); + const plus = ev.target.closest(".css_quantity_plus"); + if (!minus && !plus) return; + const parent = (minus || plus).closest(".js_product"); + if (!parent) return; + parent.dataset.multipleRounding = minus ? "DOWN" : "UP"; + }, + true + ); + // Capture the rounding method when the user directly inputs a quantity + this.el.addEventListener( + "input", + (ev) => { + const input = ev.target.closest('input[name="add_qty"]'); + if (!input) return; + const parent = input.closest(".js_product"); + if (!parent) return; + if (ev.inputType) { + parent.dataset.multipleRounding = "UP"; + } + }, + true + ); + + return res; + }, + /** + * Update the combination info params with the multiple rounding method + * + * @override + */ + _getOptionalCombinationInfoParam(parent) { + const params = super._getOptionalCombinationInfoParam?.(parent) || {}; + const rounding = parent?.dataset?.multipleRounding; + if (rounding) { + params.multiple_rounding = rounding; + } + return params; + }, + /** + * Force the displayed quantity to the rounded one + * when multiple is enabled (sale_multiple_uom_id is set). + * + * This makes the user see the rounded qty immediately after +/- or variant changes, + * because those actions trigger a combination refresh. + * + * @override + */ + _onChangeCombination(ev, parent, combination) { + const result = super._onChangeCombination(...arguments); + const qtyInput = parent?.querySelector?.('input[name="add_qty"]'); + if (!qtyInput) { + return; + } + if (combination?.is_multiple && combination?.multiple_qty !== null) { + const rounded = Number(combination.multiple_qty); + if (!Number.isNaN(rounded)) { + if (Number(qtyInput.value || 0) !== rounded) { + qtyInput.value = rounded; + } + } + } + return result; + }, +}); diff --git a/website_sale_product_multiple_qty/views/templates.xml b/website_sale_product_multiple_qty/views/templates.xml new file mode 100644 index 00000000000..c84b97c9e6c --- /dev/null +++ b/website_sale_product_multiple_qty/views/templates.xml @@ -0,0 +1,102 @@ + + + + + + + + From 1b68e0e3c24b9c5074be9a63eae6a404a946bcfa Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Thu, 19 Feb 2026 17:23:06 +0200 Subject: [PATCH 2/3] don't merge, test-requirements --- test-requirements.txt | 1 + .../static/src/js/website_sale.esm.js | 66 ++++++++++++++----- 2 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 test-requirements.txt diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000000..6981848e83b --- /dev/null +++ b/test-requirements.txt @@ -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 diff --git a/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js b/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js index e5af4b21b1c..3439ad64529 100644 --- a/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js +++ b/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js @@ -3,13 +3,18 @@ import {patch} from "@web/core/utils/patch"; patch(WebsiteSale.prototype, { /** - * Catch the rounding method + * Capture the rounding method. * * @override */ async start() { const res = await super.start(...arguments); - // Capture the rounding method on quantity change, either by +/- buttons + + // Capture +/- button clicks. + // - "+" means rounding UP + // - "-" means rounding DOWN + // We rely on pointerdown (instead of click) to set the rounding method + // before the change of the input value is processed. this.el.addEventListener( "pointerdown", (ev) => { @@ -22,17 +27,32 @@ patch(WebsiteSale.prototype, { }, true ); - // Capture the rounding method when the user directly inputs a quantity + + // Capture manual typing in the quantity input. Always rounds "UP" this.el.addEventListener( "input", (ev) => { - const input = ev.target.closest('input[name="add_qty"]'); + const input = ev.target.closest?.('input[name="add_qty"]'); if (!input) return; const parent = input.closest(".js_product"); if (!parent) return; - if (ev.inputType) { - parent.dataset.multipleRounding = "UP"; - } + parent.dataset.multipleRounding = "UP"; + }, + true + ); + + // Handle "Enter" key inside quantity input: + // prevent submit + trigger rounding (UP) + this.el.addEventListener( + "keydown", + (ev) => { + const input = ev.target.closest?.('input[name="add_qty"]'); + if (!input || ev.key !== "Enter") return; + ev.preventDefault(); + const parent = input.closest(".js_product"); + if (!parent) return; + parent.dataset.multipleRounding = "UP"; + input.dispatchEvent(new Event("change", {bubbles: true})); }, true ); @@ -40,7 +60,25 @@ patch(WebsiteSale.prototype, { return res; }, /** - * Update the combination info params with the multiple rounding method + * Prevent duplicate ``get_combination_info`` RPC calls to avoid rounding method mess-up. + * The original method is triggered on each ``ul[data-attribute-exclusions]`` change. + * The goal is to trigger it only once, with the correct rounding method set by the user. + * + * @override + */ + triggerVariantChange(container) { + const ul = container.querySelector("ul[data-attribute-exclusions]"); + if (ul) { + ul.dispatchEvent(new Event("change")); + } + container + .querySelectorAll( + "input.js_variant_change:checked, select.js_variant_change" + ) + .forEach((el) => this.handleCustomValues(el)); + }, + /** + * Inject the rounding method into the RPC payload. * * @override */ @@ -49,15 +87,12 @@ patch(WebsiteSale.prototype, { const rounding = parent?.dataset?.multipleRounding; if (rounding) { params.multiple_rounding = rounding; + delete parent.dataset.multipleRounding; } return params; }, /** - * Force the displayed quantity to the rounded one - * when multiple is enabled (sale_multiple_uom_id is set). - * - * This makes the user see the rounded qty immediately after +/- or variant changes, - * because those actions trigger a combination refresh. + * Update the quantity input value with the rounded quantity returned by the RPC. * * @override */ @@ -65,12 +100,13 @@ patch(WebsiteSale.prototype, { const result = super._onChangeCombination(...arguments); const qtyInput = parent?.querySelector?.('input[name="add_qty"]'); if (!qtyInput) { - return; + return result; } if (combination?.is_multiple && combination?.multiple_qty !== null) { const rounded = Number(combination.multiple_qty); if (!Number.isNaN(rounded)) { - if (Number(qtyInput.value || 0) !== rounded) { + const current = parseInt(qtyInput.value || "0"); + if (!Number.isNaN(current) && current !== rounded) { qtyInput.value = rounded; } } From 5f740b30c11be924c05c0a8824a4f92c0fc7b65c Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Mon, 23 Feb 2026 19:14:16 +0200 Subject: [PATCH 3/3] fixup! [19.0][ADD] website_sale_product_multiple_qty --- website_sale_product_multiple_qty/README.rst | 75 +++++-- .../__manifest__.py | 9 +- .../controllers/__init__.py | 1 + .../controllers/product_configurator.py | 46 ++++ .../controllers/variant.py | 18 +- .../models/product_template.py | 38 ++-- .../readme/DESCRIPTION.md | 57 +++-- .../static/description/index.html | 84 ++++++-- .../src/js/interactions/cart_line.esm.js | 157 ++++++++++++++ .../src/js/interactions/website_sale.esm.js | 201 ++++++++++++++++++ .../static/src/js/product/product.esm.js | 16 ++ .../static/src/js/product/product.xml | 9 + .../quantity_buttons/quantity_buttons.esm.js | 107 ++++++++++ .../static/src/js/website_sale.esm.js | 116 ---------- .../views/templates.xml | 105 ++------- 15 files changed, 756 insertions(+), 283 deletions(-) create mode 100644 website_sale_product_multiple_qty/controllers/product_configurator.py create mode 100644 website_sale_product_multiple_qty/static/src/js/interactions/cart_line.esm.js create mode 100644 website_sale_product_multiple_qty/static/src/js/interactions/website_sale.esm.js create mode 100644 website_sale_product_multiple_qty/static/src/js/product/product.esm.js create mode 100644 website_sale_product_multiple_qty/static/src/js/product/product.xml create mode 100644 website_sale_product_multiple_qty/static/src/js/quantity_buttons/quantity_buttons.esm.js delete mode 100644 website_sale_product_multiple_qty/static/src/js/website_sale.esm.js diff --git a/website_sale_product_multiple_qty/README.rst b/website_sale_product_multiple_qty/README.rst index 1195597a7a4..bda18361400 100644 --- a/website_sale_product_multiple_qty/README.rst +++ b/website_sale_product_multiple_qty/README.rst @@ -35,39 +35,74 @@ Website Sale Product Multiple Qty Website Sale Product Multiple Quantity ====================================== -This module extends the eCommerce flow to support **sales multiples** -(packaging quantities) directly on the product page. +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 has a *Sales Multiple UoM* configured, the quantity -entered by the customer on the website is automatically rounded to a -valid multiple according to the interaction type. +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 human-friendly and predictable: +The behavior is designed to be predictable and consistent with +packaging-based sales. -- On page load: The default quantity is rounded **UP** to the nearest - multiple. +Product Page +~~~~~~~~~~~~ -- When clicking "+": The quantity is rounded **UP** to the next valid - multiple. +- On page load (or variant switch): -- When clicking "–": The quantity is rounded **DOWN** to the previous - valid multiple. + - 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 manually entering a quantity: The value is rounded **UP** to the - nearest valid multiple. +- When clicking "+": -It is possible to set the quantity to ``0`` if the user decreases the -quantity below the first multiple. + - 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 ------- @@ -77,8 +112,12 @@ If a product is sold in multiples of 500: - Entering ``1`` → becomes ``500`` - Entering ``499`` → becomes ``500`` - Entering ``501`` → becomes ``1000`` -- Clicking "–" from ``500`` → becomes ``0`` -- Clicking "+" from ``0`` → becomes ``500`` +- 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. diff --git a/website_sale_product_multiple_qty/__manifest__.py b/website_sale_product_multiple_qty/__manifest__.py index 0d7334a4309..92309f881ca 100644 --- a/website_sale_product_multiple_qty/__manifest__.py +++ b/website_sale_product_multiple_qty/__manifest__.py @@ -21,8 +21,15 @@ "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", + "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/**/*", ], }, } diff --git a/website_sale_product_multiple_qty/controllers/__init__.py b/website_sale_product_multiple_qty/controllers/__init__.py index ca58e65bf7b..ab37d28ef7d 100644 --- a/website_sale_product_multiple_qty/controllers/__init__.py +++ b/website_sale_product_multiple_qty/controllers/__init__.py @@ -1 +1,2 @@ +from . import product_configurator from . import variant diff --git a/website_sale_product_multiple_qty/controllers/product_configurator.py b/website_sale_product_multiple_qty/controllers/product_configurator.py new file mode 100644 index 00000000000..8d4248b9352 --- /dev/null +++ b/website_sale_product_multiple_qty/controllers/product_configurator.py @@ -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 diff --git a/website_sale_product_multiple_qty/controllers/variant.py b/website_sale_product_multiple_qty/controllers/variant.py index 07c1365569a..95c168d50d4 100644 --- a/website_sale_product_multiple_qty/controllers/variant.py +++ b/website_sale_product_multiple_qty/controllers/variant.py @@ -1,7 +1,7 @@ # Copyright 2026 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo.http import request, route +from odoo.http import route from odoo.addons.website_sale.controllers.variant import WebsiteSaleVariantController @@ -24,12 +24,7 @@ def get_combination_info_website( uom_id=None, **kwargs, ): - if rounding := kwargs.get("multiple_rounding"): - request.update_env( - context=dict(request.env.context, multiple_rounding=rounding) - ) - - return super().get_combination_info_website( + combination_info = super().get_combination_info_website( product_template_id=product_template_id, product_id=product_id, combination=combination, @@ -37,3 +32,12 @@ def get_combination_info_website( 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 diff --git a/website_sale_product_multiple_qty/models/product_template.py b/website_sale_product_multiple_qty/models/product_template.py index c0781e6eee1..ce8c7080b2e 100644 --- a/website_sale_product_multiple_qty/models/product_template.py +++ b/website_sale_product_multiple_qty/models/product_template.py @@ -1,11 +1,28 @@ # Copyright 2026 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import models +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 ): @@ -13,22 +30,5 @@ def _get_additionnal_combination_info( combination_info = super()._get_additionnal_combination_info( product_or_template, quantity, uom, date, website ) - - if not product_or_template.sale_multiple_uom_id: - return combination_info - rounding = "UP" - if to_rounding := self.env.context.get("multiple_rounding"): - rounding = to_rounding - rounded_qty = self.sale_multiple_uom_id._check_qty( - quantity, self.uom_id, rounding_method=rounding - ) - combination_info.update( - { - "is_multiple": 1, - # The website expects an integer value as an input - # ``website_sale::variant_mixin.js`` - # parseInt(parent.querySelector('input[name="add_qty"]').value). - "multiple_qty": int(rounded_qty), - } - ) + combination_info.update(self._get_sale_multiple_vals(product_or_template)) return combination_info diff --git a/website_sale_product_multiple_qty/readme/DESCRIPTION.md b/website_sale_product_multiple_qty/readme/DESCRIPTION.md index 8e2c58d17fa..d97ad6f6ded 100644 --- a/website_sale_product_multiple_qty/readme/DESCRIPTION.md +++ b/website_sale_product_multiple_qty/readme/DESCRIPTION.md @@ -1,39 +1,62 @@ Website Sale Product Multiple Quantity ======================================= -This module extends the eCommerce flow to support **sales multiples** -(packaging quantities) directly on the product page. +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 has a *Sales Multiple UoM* configured, the quantity entered -by the customer on the website is automatically rounded to a valid multiple -according to the interaction type. +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 human-friendly and predictable: +The behavior is designed to be predictable and consistent +with packaging-based sales. -* On page load: - The default quantity is rounded **UP** to the nearest multiple. +### 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 is rounded **UP** to the next valid multiple. + - The quantity increases by one full multiple step. * When clicking "–": - The quantity is rounded **DOWN** to the previous valid multiple. + - 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. + - 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. -It is possible to set the quantity to ``0`` if the user decreases -the quantity below the first multiple. +### 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 ------- @@ -43,8 +66,12 @@ If a product is sold in multiples of 500: - Entering ``1`` → becomes ``500`` - Entering ``499`` → becomes ``500`` - Entering ``501`` → becomes ``1000`` -- Clicking "–" from ``500`` → becomes ``0`` -- Clicking "+" from ``0`` → becomes ``500`` +- 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. diff --git a/website_sale_product_multiple_qty/static/description/index.html b/website_sale_product_multiple_qty/static/description/index.html index fda689ac1b9..b0b1bdf89c2 100644 --- a/website_sale_product_multiple_qty/static/description/index.html +++ b/website_sale_product_multiple_qty/static/description/index.html @@ -377,33 +377,75 @@

Website Sale Product Multiple Qty

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

Website Sale Product Multiple Quantity

-

This module extends the eCommerce flow to support sales multiples -(packaging quantities) directly on the product page.

-

When a product has a Sales Multiple UoM configured, the quantity -entered by the customer on the website is automatically rounded to a -valid multiple according to the interaction type.

+

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 human-friendly and predictable:

+

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

+
+

Product Page

    -
  • On page load: The default quantity is rounded UP to the nearest -multiple.
  • -
  • When clicking “+”: The quantity is rounded UP to the next valid -multiple.
  • -
  • When clicking “–”: The quantity is rounded DOWN to the previous -valid multiple.
  • -
  • When manually entering a quantity: The value is rounded UP to the -nearest valid multiple.
  • +
  • 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.
    -

    It is possible to set the quantity to 0 if the user decreases the -quantity below the first multiple.

    +
  • +
  • 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

@@ -412,9 +454,13 @@

Example

  • Entering 1 → becomes 500
  • Entering 499 → becomes 500
  • Entering 501 → becomes 1000
  • -
  • Clicking “–” from 500 → becomes 0
  • -
  • Clicking “+” from 0 → becomes 500
  • +
  • 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 @@ -431,7 +477,7 @@

    Usage

    Usage

    -
    +

    Configuration

    1. Go to Sales → Products.
    2. diff --git a/website_sale_product_multiple_qty/static/src/js/interactions/cart_line.esm.js b/website_sale_product_multiple_qty/static/src/js/interactions/cart_line.esm.js new file mode 100644 index 00000000000..2a0b07bbfda --- /dev/null +++ b/website_sale_product_multiple_qty/static/src/js/interactions/cart_line.esm.js @@ -0,0 +1,157 @@ +import {CartLine} from "@website_sale/interactions/cart_line"; +import {patch} from "@web/core/utils/patch"; + +patch(CartLine.prototype, { + /** + * Prevent "Enter" keypress in the cart quantity input + * from being ignored by the browser and leaving a stale value. + * Keep rounding logic with manual input. + * + * @override + */ + setup() { + super.setup?.(...arguments); + + // Keydown capture handler to intercept Enter before default handlers. + this._onQtyKeydown = (ev) => { + const input = ev.target; + + // Only handle the cart qty input inside `.css_quantity` + if (!input?.matches?.(".css_quantity > input.js_quantity")) return; + if (ev.key !== "Enter") return; + ev.preventDefault(); + ev.stopPropagation(); + + // Force the standard "change" flow (including the rounding). + input.dispatchEvent(new Event("change", {bubbles: true})); + }; + + this.el.addEventListener("keydown", this._onQtyKeydown, true); + }, + + /** + * Make sure to remove the event listener + * when the interaction is destroyed to prevent memory leaks. + * + * @override + */ + destroy() { + this.el?.removeEventListener?.("keydown", this._onQtyKeydown, true); + this._onQtyKeydown = null; + return super.destroy?.(...arguments); + }, + + /** + * Read "sale multiple" info from the cart quantity input dataset. + * + */ + _getMultipleStep(input) { + const isMultiple = input?.dataset?.isMultiple === "1"; + const step = isMultiple + ? parseFloat(input.dataset.saleMultipleQty || 1) || 1 + : 1; + return {isMultiple, step}; + }, + + /** + * Round the quantity UP to the nearest step. + * Preserve 0 to keep the standard cart removal behavior. + */ + _roundUpToStep(qty, step) { + let value = parseFloat(qty || 0); + if (!Number.isFinite(value)) value = 0; + if (value <= 0) return 0; + + // -1e-9 prevents rounding artifacts when value is already a multiple. + return Math.ceil(value / step - 1e-9) * step; + }, + + /** + * Snap the input value to a valid quantity for multiple products + * and apply the max constraint (if provided). + * + * Returns the new numeric quantity (or rawQty for non-multiple products). + */ + _snapMultipleQuantity(input, rawQty) { + const {isMultiple, step} = this._getMultipleStep(input); + if (!isMultiple) return rawQty; + + let newQty = this._roundUpToStep(rawQty, step); + + // Respect cart max if present on the input + const maxQty = parseFloat(input.dataset.max || Infinity); + if (Number.isFinite(maxQty)) { + newQty = Math.min(newQty, maxQty); + } + + input.value = String(newQty); + return newQty; + }, + + /** + * When the quantity is manually changed, apply rounding logic for multiples. + * + * @override + */ + async changeQuantity(ev, currentTargetEl) { + const input = currentTargetEl; + const {isMultiple} = this._getMultipleStep(input); + + // Non-multiple products: keep standard behavior. + if (!isMultiple) { + return await super.changeQuantity(ev, currentTargetEl); + } + + // Multiple products: snap then apply the change through the standard pipeline. + const rawQty = parseFloat(input.value || 0); + this._snapMultipleQuantity(input, rawQty); + + return await this._changeQuantity(input); + }, + + /** + * When the "+" or "-" buttons are clicked, update the quantity + * according to the step and keep 0 as a valid "remove" value. + * + * IMPORTANT: for multiple products we must not call super, + * because super applies +/- 1 and would break the step logic. + * + * @override + */ + async incOrDecQuantity(ev, currentTargetEl) { + const input = currentTargetEl + .closest(".css_quantity") + ?.querySelector("input.js_quantity"); + if (!input) { + return await super.incOrDecQuantity(ev, currentTargetEl); + } + + const {isMultiple, step} = this._getMultipleStep(input); + if (!isMultiple) { + return await super.incOrDecQuantity(ev, currentTargetEl); + } + + const oldQty = parseFloat(input.value || 0) || 0; + const isMinus = currentTargetEl + .querySelector("i") + ?.classList?.contains("oi-minus"); + + /** + * For multiples: + * - plus => +step + * - minus => -step + * - if it goes below one step => 0 (remove line behavior) + */ + let rawQty = 0; + if (isMinus) { + rawQty = oldQty - step; + if (rawQty < step) rawQty = 0; + } else { + rawQty = oldQty + step; + } + + this._snapMultipleQuantity(input, rawQty); + + return await this._changeQuantity(input); + }, +}); diff --git a/website_sale_product_multiple_qty/static/src/js/interactions/website_sale.esm.js b/website_sale_product_multiple_qty/static/src/js/interactions/website_sale.esm.js new file mode 100644 index 00000000000..db359bdd655 --- /dev/null +++ b/website_sale_product_multiple_qty/static/src/js/interactions/website_sale.esm.js @@ -0,0 +1,201 @@ +import {patch} from "@web/core/utils/patch"; +import {WebsiteSale} from "@website_sale/interactions/website_sale"; +import wSaleUtils from "@website_sale/js/website_sale_utils"; + +patch(WebsiteSale.prototype, { + /** + * Prevent "Enter" keypress in the quantity input + * from submitting the form and reloading the page. + * Keep rounding logic with manual input. + * + * @override + */ + start() { + const res = super.start?.(...arguments); + + this._onQtyKeydown = (ev) => { + const input = ev.target; + if (!input?.matches?.('input[name="add_qty"]')) return; + if (ev.key !== "Enter") return; + + ev.preventDefault(); + ev.stopPropagation(); + + // Force "change" including the multiple rounding + input.dispatchEvent(new Event("change", {bubbles: true})); + }; + + this.el.addEventListener("keydown", this._onQtyKeydown, true); + return res; + }, + + /** + * Make sure to remove the event listener + * when the widget is destroyed to prevent memory leaks. + * + * @override + */ + destroy() { + this.el?.removeEventListener?.("keydown", this._onQtyKeydown, true); + this._onQtyKeydown = null; + return super.destroy?.(...arguments); + }, + + /** + * Resolve the root DOM node and return the add_qty input. + */ + _getAddQtyInput(parent) { + const root = parent?.el || parent?.[0] || parent || this.el; + return root?.querySelector?.('input[name="add_qty"]'); + }, + + /** + * Read multiple step info from dataset. + * Dataset is refreshed on each combination change. + */ + _getMultipleInfoFromInput(input) { + const isMultiple = input?.dataset?.isMultiple === "1"; + const step = parseFloat(input?.dataset?.saleMultipleQty || 1) || 1; + return {isMultiple, step}; + }, + + /** + * Compute constraints for add_qty for both multiple/non-multiple cases. + * For multiple products, effectiveMin must be >= step (we want one pack minimum). + */ + _getAddQtyConstraints(input) { + const min = parseFloat(input.dataset.min || 0); + const max = parseFloat(input.dataset.max || Infinity); + const {isMultiple, step} = this._getMultipleInfoFromInput(input); + + const effectiveMin = isMultiple ? Math.max(min, step) : min; + return {min, max, isMultiple, step, effectiveMin}; + }, + + /** + * Update the dataset attributes for the quantity input + * based on the selected combination with sale multiple info. + * + */ + updateSaleMultiple(parent, combination) { + const input = this._getAddQtyInput(parent); + if (!input) return; + + input.dataset.saleMultipleQty = String(combination?.sale_multiple_qty ?? 1); + input.dataset.isMultiple = combination?.is_multiple ? "1" : "0"; + }, + + /** + * When the combination changes, update the dataset with sale multiple info. + * Reset the quantity to the default value for the new variant + * only if the variant has changed. + * + * @override + */ + _onChangeCombination(ev, parent, combination) { + const res = super._onChangeCombination?.(...arguments); + + this.updateSaleMultiple(parent, combination); + + // Reset to default only when switching variant. + // This avoids "random" qty resets when only price/availability changes. + if (combination?.variant_switched) { + const input = this._getAddQtyInput(parent); + if (!input) return res; + + const {isMultiple, step, effectiveMin} = this._getAddQtyConstraints(input); + + /** + * Default qty per variant: + * - multiple => at least one step (and still respect min if it is bigger) + * - non-multiple => min + */ + const defaultQty = isMultiple ? Math.max(step, effectiveMin) : effectiveMin; + input.value = defaultQty; + } + + return res; + }, + + /** + * When the quantity is manually changed, apply rounding logic for multiples. + * + * @override + */ + onChangeAddQuantity(ev) { + const input = ev.currentTarget; + + // Non-multiple: keep standard logic. + if (input.dataset.isMultiple !== "1") { + return super.onChangeAddQuantity?.(...arguments); + } + + const parent = wSaleUtils.getClosestProductForm(input); + if (!parent) return; + + const {max, step, effectiveMin} = this._getAddQtyConstraints(input); + + let qty = parseFloat(input.value || 0); + if (!Number.isFinite(qty) || qty <= 0) { + qty = effectiveMin; + } + + // Always round UP to the step. + qty = Math.ceil(qty / step - 1e-9) * step; + + // Clamp to constraints (multiple effective min + max) + qty = Math.min(Math.max(qty, effectiveMin), max); + if (qty !== parseFloat(input.value || 0)) { + input.value = qty; + } + + // Keep standard behavior + this.triggerVariantChange(parent); + }, + + /** + * Apply a new qty to the input and trigger change. + * This keeps one place where we dispatch the event. + */ + _applyAddQtyAndTriggerChange(input, newQty) { + const previousQty = parseFloat(input.value || 0); + if (newQty === previousQty) return; + + input.value = newQty; + input.dispatchEvent(new Event("change", {bubbles: true})); + }, + + /** + * When the "+" or "-" buttons are clicked, update the quantity + * according to the step and respecting the min/max constraints for multiples. + * + * @override + */ + onChangeQuantity(ev) { + const btn = ev?.currentTarget; + const group = btn?.closest?.(".input-group"); + const input = group?.querySelector?.('input[name="add_qty"]'); + if (!input) return; + + // Non-multiple: keep standard behavior. + if (input.dataset.isMultiple !== "1") { + return super.onChangeQuantity?.(...arguments); + } + + const {max, step, effectiveMin} = this._getAddQtyConstraints(input); + + const previousQty = parseFloat(input.value || 0); + const delta = btn.name === "remove_one" ? -step : step; + const quantity = previousQty + delta; + + /** + * For multiple products: + * - enforce effectiveMin (>= step) + * - clamp to max + * - we do not go below effectiveMin (product page does not support "0" remove) + */ + const newQty = Math.min(Math.max(quantity, effectiveMin), max); + + this._applyAddQtyAndTriggerChange(input, newQty); + }, +}); diff --git a/website_sale_product_multiple_qty/static/src/js/product/product.esm.js b/website_sale_product_multiple_qty/static/src/js/product/product.esm.js new file mode 100644 index 00000000000..c87f27d5d9c --- /dev/null +++ b/website_sale_product_multiple_qty/static/src/js/product/product.esm.js @@ -0,0 +1,16 @@ +import {Product} from "@sale/js/product/product"; +import {patch} from "@web/core/utils/patch"; + +/** + * Extend the Product component props so the configurator/product templates + * can pass variant-level "sale multiple" information. + * + * This is used by QuantityButtons to enforce step logic. + */ +patch(Product, { + props: { + ...Product.props, + is_multiple: {type: Number, optional: true}, + sale_multiple_qty: {type: Number, optional: true}, + }, +}); diff --git a/website_sale_product_multiple_qty/static/src/js/product/product.xml b/website_sale_product_multiple_qty/static/src/js/product/product.xml new file mode 100644 index 00000000000..d23501ca5ac --- /dev/null +++ b/website_sale_product_multiple_qty/static/src/js/product/product.xml @@ -0,0 +1,9 @@ + + + + + this.props.is_multiple + this.props.sale_multiple_qty + + + diff --git a/website_sale_product_multiple_qty/static/src/js/quantity_buttons/quantity_buttons.esm.js b/website_sale_product_multiple_qty/static/src/js/quantity_buttons/quantity_buttons.esm.js new file mode 100644 index 00000000000..99ce650440a --- /dev/null +++ b/website_sale_product_multiple_qty/static/src/js/quantity_buttons/quantity_buttons.esm.js @@ -0,0 +1,107 @@ +import {QuantityButtons} from "@sale/js/quantity_buttons/quantity_buttons"; +import {patch} from "@web/core/utils/patch"; + +/** + * Extend QuantityButtons props with "sale multiple" info + */ +patch(QuantityButtons, { + props: { + ...QuantityButtons.props, + isMultiple: {type: [Boolean, Number], optional: true}, + saleMultipleQty: {type: Number, optional: true}, + }, +}); + +patch(QuantityButtons.prototype, { + /** + * Get the step and isMultiple values from component props. + * + */ + _getMultipleStep() { + const isMultiple = Boolean(this.props.isMultiple); + if (!isMultiple) { + return {isMultiple: false, step: 1}; + } + const step = parseFloat(this.props.saleMultipleQty || 1) || 1; + return {isMultiple: true, step}; + }, + + /** + * Round up the quantity to the nearest step, with a minimum of 1 step. + * + * This matches "product page" behavior: + * - no 0 here (configurator qty should not remove the product) + * - always round UP when typing an arbitrary value + */ + _roundUpToStep(qty, step) { + const effectiveMin = Math.max(1, step); + let v = parseFloat(qty || 0); + + if (!Number.isFinite(v) || v <= 0) { + v = effectiveMin; + } + + // -1e-9 prevents rounding artifacts when value is already a multiple. + v = Math.ceil(v / step - 1e-9) * step; + v = Math.max(v, effectiveMin); + return v; + }, + + /** + * "+" button behavior: + * - non-multiple => standard flow + * - multiple => +step + * + * @override + */ + increaseQuantity() { + const {isMultiple, step} = this._getMultipleStep(); + if (!isMultiple) { + return super.increaseQuantity(...arguments); + } + const current = parseFloat(this.props.quantity || 0) || 0; + const next = this._roundUpToStep(current + step, step); + this.props.setQuantity(next); + }, + + /** + * "-" button behavior: + * - non-multiple => standard flow + * - multiple => -step but never below effectiveMin (>= one step) + * + * @override + */ + decreaseQuantity() { + const {isMultiple, step} = this._getMultipleStep(); + if (!isMultiple) { + return super.decreaseQuantity(...arguments); + } + const current = parseFloat(this.props.quantity || 0) || 0; + const effectiveMin = Math.max(1, step); + const nextRaw = current - step; + const next = nextRaw <= effectiveMin ? effectiveMin : nextRaw; + this.props.setQuantity(next); + }, + + /** + * Manual input behavior (typing): + * - non-multiple => standard flow + * - multiple => round UP to step, then setQuantity + * + * @override + */ + async setQuantity(event) { + const {isMultiple, step} = this._getMultipleStep(); + if (!isMultiple) { + return super.setQuantity(...arguments); + } + + const inputQty = parseFloat(event.target.value); + const rounded = this._roundUpToStep(inputQty, step); + const didUpdateQuantity = await this.props.setQuantity(rounded); + + if (!didUpdateQuantity) { + this.render(); + } + }, +}); diff --git a/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js b/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js deleted file mode 100644 index 3439ad64529..00000000000 --- a/website_sale_product_multiple_qty/static/src/js/website_sale.esm.js +++ /dev/null @@ -1,116 +0,0 @@ -import {WebsiteSale} from "@website_sale/interactions/website_sale"; -import {patch} from "@web/core/utils/patch"; - -patch(WebsiteSale.prototype, { - /** - * Capture the rounding method. - * - * @override - */ - async start() { - const res = await super.start(...arguments); - - // Capture +/- button clicks. - // - "+" means rounding UP - // - "-" means rounding DOWN - // We rely on pointerdown (instead of click) to set the rounding method - // before the change of the input value is processed. - this.el.addEventListener( - "pointerdown", - (ev) => { - const minus = ev.target.closest(".css_quantity_minus"); - const plus = ev.target.closest(".css_quantity_plus"); - if (!minus && !plus) return; - const parent = (minus || plus).closest(".js_product"); - if (!parent) return; - parent.dataset.multipleRounding = minus ? "DOWN" : "UP"; - }, - true - ); - - // Capture manual typing in the quantity input. Always rounds "UP" - this.el.addEventListener( - "input", - (ev) => { - const input = ev.target.closest?.('input[name="add_qty"]'); - if (!input) return; - const parent = input.closest(".js_product"); - if (!parent) return; - parent.dataset.multipleRounding = "UP"; - }, - true - ); - - // Handle "Enter" key inside quantity input: - // prevent submit + trigger rounding (UP) - this.el.addEventListener( - "keydown", - (ev) => { - const input = ev.target.closest?.('input[name="add_qty"]'); - if (!input || ev.key !== "Enter") return; - ev.preventDefault(); - const parent = input.closest(".js_product"); - if (!parent) return; - parent.dataset.multipleRounding = "UP"; - input.dispatchEvent(new Event("change", {bubbles: true})); - }, - true - ); - - return res; - }, - /** - * Prevent duplicate ``get_combination_info`` RPC calls to avoid rounding method mess-up. - * The original method is triggered on each ``ul[data-attribute-exclusions]`` change. - * The goal is to trigger it only once, with the correct rounding method set by the user. - * - * @override - */ - triggerVariantChange(container) { - const ul = container.querySelector("ul[data-attribute-exclusions]"); - if (ul) { - ul.dispatchEvent(new Event("change")); - } - container - .querySelectorAll( - "input.js_variant_change:checked, select.js_variant_change" - ) - .forEach((el) => this.handleCustomValues(el)); - }, - /** - * Inject the rounding method into the RPC payload. - * - * @override - */ - _getOptionalCombinationInfoParam(parent) { - const params = super._getOptionalCombinationInfoParam?.(parent) || {}; - const rounding = parent?.dataset?.multipleRounding; - if (rounding) { - params.multiple_rounding = rounding; - delete parent.dataset.multipleRounding; - } - return params; - }, - /** - * Update the quantity input value with the rounded quantity returned by the RPC. - * - * @override - */ - _onChangeCombination(ev, parent, combination) { - const result = super._onChangeCombination(...arguments); - const qtyInput = parent?.querySelector?.('input[name="add_qty"]'); - if (!qtyInput) { - return result; - } - if (combination?.is_multiple && combination?.multiple_qty !== null) { - const rounded = Number(combination.multiple_qty); - if (!Number.isNaN(rounded)) { - const current = parseInt(qtyInput.value || "0"); - if (!Number.isNaN(current) && current !== rounded) { - qtyInput.value = rounded; - } - } - } - return result; - }, -}); diff --git a/website_sale_product_multiple_qty/views/templates.xml b/website_sale_product_multiple_qty/views/templates.xml index c84b97c9e6c..26560c67809 100644 --- a/website_sale_product_multiple_qty/views/templates.xml +++ b/website_sale_product_multiple_qty/views/templates.xml @@ -3,100 +3,29 @@ - - -