1 Commits

Author SHA1 Message Date
5d8293f73e [ADD] user friendly error handling for restricted qty on ecommerce app
Some checks failed
pre-commit / pre-commit (pull_request) Has been cancelled
2026-08-18 17:52:11 +02:00
10 changed files with 393 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,35 @@
# Copyright 2026 Elabore ()
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "sale_restricted_qty_website_sale",
"version": "16.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Elabore",
"license": "AGPL-3",
"category": "Sales",
"summary": "Improve UX handling on e-commerce product page",
# any module necessary for this one to work correctly
"depends": [
"sale_restricted_qty","website_sale",
],
"qweb": [],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/restricted_qty_website_sale_view.xml"
],
"assets": {
"web.assets_frontend": [
"sale_restricted_qty_website_sale/static/src/js/add_to_cart.js",
],
},
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": True,
"application": False,
}

View File

@@ -0,0 +1,80 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * elabore_website_sale_restricted_qty
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-08-18 00:00+0000\n"
"Last-Translator: Élabore <https://elabore.coop>\n"
"Language-Team: \n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. module: elabore_website_sale_restricted_qty
#. odoo-python
#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0
#, python-format
msgid ""
"The selected quantity is not available for sale:\n"
"\n"
"%s\n"
"\n"
"Please adjust the quantity."
msgstr ""
"La quantité choisie n'est pas disponible à la vente :\n"
"\n"
"%s\n"
"\n"
"Merci d'ajuster la quantité."
#. module: elabore_website_sale_restricted_qty
#. odoo-python
#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0
#, python-format
msgid "%(product)s: minimum order quantity is %(qty)s."
msgstr "%(product)s : quantité minimum de commande %(qty)s."
#. module: elabore_website_sale_restricted_qty
#. odoo-python
#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0
#, python-format
msgid "%(product)s: maximum order quantity is %(qty)s."
msgstr "%(product)s : quantité maximum de commande %(qty)s."
#. module: elabore_website_sale_restricted_qty
#. odoo-python
#: code:addons/elabore_website_sale_restricted_qty/models/sale_order_line.py:0
#, python-format
msgid "%(product)s: must be ordered in multiples of %(qty)s."
msgstr "%(product)s : à commander par multiples de %(qty)s."
#. module: elabore_website_sale_restricted_qty
#. odoo-python
#: code:addons/elabore_website_sale_restricted_qty/models/product_restricted_qty_mixin.py:0
#, python-format
msgid "minimum order: %(qty)s %(uom)s"
msgstr "commande minimum : %(qty)s %(uom)s"
#. module: elabore_website_sale_restricted_qty
#. odoo-python
#: code:addons/elabore_website_sale_restricted_qty/models/product_restricted_qty_mixin.py:0
#, python-format
msgid "in multiples of %(qty)s %(uom)s"
msgstr "par multiples de %(qty)s %(uom)s"
#. module: elabore_website_sale_restricted_qty
#. odoo-python
#: code:addons/elabore_website_sale_restricted_qty/models/product_restricted_qty_mixin.py:0
#, python-format
msgid "maximum: %(qty)s %(uom)s"
msgstr "maximum : %(qty)s %(uom)s"
#. module: elabore_website_sale_restricted_qty
#: model:ir.ui.view,name:elabore_website_sale_restricted_qty.product_restricted_qty_info
msgid "Restricted Quantity Information"
msgstr "Information sur les quantités restreintes"

View File

@@ -0,0 +1,2 @@
from . import product_restricted_qty_mixin
from . import sale_order_line

View File

@@ -0,0 +1,59 @@
# Copyright 2026 Élabore (https://elabore.coop)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import _, models
from odoo.tools import float_is_zero
from odoo.tools.misc import formatLang
class ProductRestrictedQtyMixin(models.AbstractModel):
"""Extending the OCA abstract mixin makes these helpers available at once
on product.product, product.template and product.category."""
_inherit = "product.restricted.qty.mixin"
def _format_restricted_qty(self, qty):
"""Short form: 6 rather than 6.0, and a locale-aware separator
for decimals."""
if float_is_zero(qty - int(qty), precision_digits=3):
return str(int(qty))
return formatLang(self.env, qty)
def _get_restricted_qty_hint(self):
"""Sentence shown to the customer. Empty when nothing restricts him.
OCA semantics: `force_sale_min_qty` / `force_sale_max_qty` set to Yes
make the bound purely indicative -- the constraint does not block, so
we announce nothing rather than promising a rule that does not exist.
"""
self.ensure_one()
uom = self.uom_id.name if "uom_id" in self._fields else ""
parts = []
if self.sale_min_qty and not self.force_sale_min_qty:
parts.append(
_(
"minimum order: %(qty)s %(uom)s",
qty=self._format_restricted_qty(self.sale_min_qty),
uom=uom,
)
)
if self.sale_multiple_qty:
parts.append(
_(
"in multiples of %(qty)s %(uom)s",
qty=self._format_restricted_qty(self.sale_multiple_qty),
uom=uom,
)
)
if self.sale_max_qty and not self.force_sale_max_qty:
parts.append(
_(
"maximum: %(qty)s %(uom)s",
qty=self._format_restricted_qty(self.sale_max_qty),
uom=uom,
)
)
if not parts:
return ""
hint = " · ".join(parts)
return hint[0].upper() + hint[1:]

View File

@@ -0,0 +1,62 @@
from odoo import _, api, models
from odoo.exceptions import ValidationError
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
@api.constrains(
"product_uom_qty", "sale_min_qty", "sale_max_qty", "sale_multiple_qty"
)
def check_constraint_restricted_qty(self):
"""Rewrite the error message raised by `sale_restricted_qty`.
The upstream message is written for a salesperson: it dumps the
offending products and explains how to tick "force min quantity" on
the product form. Unreadable for an eCommerce customer.
Detection is left to the OCA module -- the `is_qty_*` fields stay the
single source of truth. Only the wording is replaced, so an upstream
change to the rules is followed without breaking anything.
"""
try:
return super().check_constraint_restricted_qty()
except ValidationError:
raise ValidationError(self._get_restricted_qty_error_message()) from None
def _get_restricted_qty_error_message(self):
"""Customer-facing message, one line per unmet restriction."""
details = []
for line in self:
product = line.product_id
if not product.force_sale_min_qty and line.is_qty_less_min_qty:
details.append(
_(
"%(product)s: minimum order quantity is %(qty)s.",
product=product.display_name,
qty=product._format_restricted_qty(line.sale_min_qty),
)
)
if not product.force_sale_max_qty and line.is_qty_bigger_max_qty:
details.append(
_(
"%(product)s: maximum order quantity is %(qty)s.",
product=product.display_name,
qty=product._format_restricted_qty(line.sale_max_qty),
)
)
if line.is_qty_not_multiple_qty:
details.append(
_(
"%(product)s: must be ordered in multiples of %(qty)s.",
product=product.display_name,
qty=product._format_restricted_qty(line.sale_multiple_qty),
)
)
return _(
"The selected quantity is not available for sale:\n\n"
"%s\n\n"
"Please adjust the quantity.",
"\n".join(details),
)

View File

@@ -0,0 +1,45 @@
odoo.define("sale_restricted_qty_website_sale.add_to_cart", function (require) {
"use strict";
const publicWidget = require("web.public.widget");
const wSaleUtils = require("website_sale.utils");
const WebsiteSale = require("website_sale.website_sale").WebsiteSale;
WebsiteSale.include({
/**
*
* @override
*/
_addToCartInPage: function () {
return this._super.apply(this, arguments).catch((error) => {
const message = this._getRestrictedQtyMessage(error);
if (!message) {
// Not a quantity restriction: let the real error surface.
return Promise.reject(error);
}
if (error && error.event && error.event.preventDefault) {
// Legacy convention: tells the crash manager the error is
// handled, so it does not open its own dialog on top.
error.event.preventDefault();
}
wSaleUtils.showWarning(message);
});
},
/**
* @private
* @param {Object} error rejection from the legacy RPC
* @returns {string|false} the message, or false to re-throw
*/
_getRestrictedQtyMessage: function (error) {
const data =
(error && error.message && error.message.data) ||
(error && error.data);
if (!data || data.name !== "odoo.exceptions.ValidationError") {
return false;
}
return data.message;
},
});
});

View File

@@ -0,0 +1 @@
from . import test_restricted_qty_website_sale

View File

@@ -0,0 +1,89 @@
from lxml import html
from odoo.exceptions import ValidationError
from odoo.tests import HttpCase, tagged
from odoo.addons.http_routing.models.ir_http import slug
@tagged("post_install", "-at_install")
class TestRestrictedQtyInfo(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env["res.partner"].create({"name": "eCommerce customer"})
cls.product_min = cls._create_product(
"Product min 6", manual_sale_min_qty=6.0
)
cls.product_free = cls._create_product("Unrestricted product")
# force = Yes -> purely indicative bound, nothing to tell the customer
cls.product_indicative = cls._create_product(
"Product min 6 indicative",
manual_sale_min_qty=6.0,
manual_force_sale_min_qty="force",
)
@classmethod
def _create_product(cls, name, **restrictions):
values = {
"name": name,
"type": "consu",
"list_price": 10.0,
"sale_ok": True,
"is_published": True,
}
values.update(restrictions)
return cls.env["product.template"].create(values).product_variant_id
# ------------------------------------------------------------------
# Error message
# ------------------------------------------------------------------
def _create_line(self, product, qty):
order = self.env["sale.order"].create({"partner_id": self.partner.id})
self.env["sale.order.line"].create(
{
"order_id": order.id,
"product_id": product.id,
"product_uom_qty": qty,
}
)
# @api.constrains are evaluated on flush, not on create().
self.env.flush_all()
return order
def test_error_message_is_customer_facing(self):
with self.assertRaises(ValidationError) as error:
self._create_line(self.product_min, 1)
message = str(error.exception)
self.assertIn("Product min 6", message)
self.assertIn("6", message)
self.assertNotIn("force min", message, "Upstream jargon still present")
def test_valid_quantity_raises_nothing(self):
self._create_line(self.product_min, 6)
def test_indicative_minimum_still_does_not_block(self):
self._create_line(self.product_indicative, 1)
# ------------------------------------------------------------------
# Product page display
# ------------------------------------------------------------------
def _hint_on_page(self, product):
response = self.url_open("/shop/%s" % slug(product.product_tmpl_id))
self.assertEqual(response.status_code, 200)
tree = html.fromstring(response.content)
return tree.xpath("//div[contains(@class, 'o_wsale_qty_restriction')]")
def test_hint_is_displayed(self):
hint = self._hint_on_page(self.product_min)
self.assertEqual(len(hint), 1)
self.assertIn("6", hint[0].text_content())
def test_no_hint_without_restriction(self):
self.assertFalse(self._hint_on_page(self.product_free))
def test_no_hint_for_an_indicative_minimum(self):
self.assertFalse(self._hint_on_page(self.product_indicative))

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Product page: tell the customer about the orderable quantities,
right below the quantity selector and the add-to-cart button. -->
<template id="product_restricted_qty_info"
inherit_id="website_sale.product_quantity"
name="Restricted Quantity Information">
<xpath expr="//div[@id='add_to_cart_wrap']" position="after">
<t t-set="qty_hint" t-value="product._get_restricted_qty_hint()"/>
<div t-if="qty_hint"
class="o_wsale_qty_restriction alert alert-info d-flex align-items-center w-100 mt-2 mb-0 py-2 px-3">
<i class="fa fa-info-circle me-2" role="img" aria-label="Information"/>
<span t-out="qty_hint"/>
</div>
</xpath>
</template>
</odoo>