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/README.rst b/website_sale_product_multiple_qty/README.rst new file mode 100644 index 00000000000..bda18361400 --- /dev/null +++ b/website_sale_product_multiple_qty/README.rst @@ -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 `_. +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..92309f881ca --- /dev/null +++ b/website_sale_product_multiple_qty/__manifest__.py @@ -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/**/*", + ], + }, +} 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..ab37d28ef7d --- /dev/null +++ b/website_sale_product_multiple_qty/controllers/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000000..95c168d50d4 --- /dev/null +++ b/website_sale_product_multiple_qty/controllers/variant.py @@ -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 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..ce8c7080b2e --- /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 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 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..d97ad6f6ded --- /dev/null +++ b/website_sale_product_multiple_qty/readme/DESCRIPTION.md @@ -0,0 +1,83 @@ +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. 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..b0b1bdf89c2 --- /dev/null +++ b/website_sale_product_multiple_qty/static/description/index.html @@ -0,0 +1,546 @@ + + + + + +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, 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

+
+
+
+

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/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/views/templates.xml b/website_sale_product_multiple_qty/views/templates.xml new file mode 100644 index 00000000000..26560c67809 --- /dev/null +++ b/website_sale_product_multiple_qty/views/templates.xml @@ -0,0 +1,31 @@ + + + + +