Skip to content

Commit d479913

Browse files
committed
fixup! [19.0][ADD] website_sale_product_multiple_qty
1 parent 1b68e0e commit d479913

File tree

10 files changed

+409
-32
lines changed

10 files changed

+409
-32
lines changed

website_sale_product_multiple_qty/__manifest__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
],
2323
"assets": {
2424
"web.assets_frontend": [
25-
"website_sale_product_multiple_qty/static/src/**/*.js",
25+
"website_sale_product_multiple_qty/static/src/js/interactions/**/*.js",
26+
"sale/static/src/js/product_configurator_dialog/*",
27+
"sale/static/src/js/quantity_buttons/*",
28+
"website_sale_product_multiple_qty/static/src/js/product_configurator/**/*.js",
29+
"website_sale_product_multiple_qty/static/src/js/quantity_buttons/**/*.js",
2630
],
2731
},
2832
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
from . import product_configurator
12
from . import variant
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
4+
from odoo.http import request, route
5+
6+
from odoo.addons.website_sale.controllers.product_configurator import (
7+
WebsiteSaleProductConfiguratorController,
8+
)
9+
10+
11+
class WebsiteSaleRoundingProductConfiguratorController(
12+
WebsiteSaleProductConfiguratorController
13+
):
14+
@route(
15+
route="/website_sale/product_configurator/get_values",
16+
type="jsonrpc",
17+
auth="public",
18+
website=True,
19+
readonly=True,
20+
)
21+
def website_sale_product_configurator_get_values(
22+
self,
23+
product_template_id,
24+
quantity,
25+
*,
26+
currency_id=None,
27+
so_date=None,
28+
product_uom_id=None,
29+
company_id=None,
30+
pricelist_id=None,
31+
ptav_ids=None,
32+
only_main_product=False,
33+
show_packaging=True,
34+
**kwargs,
35+
):
36+
# OVERRIDE: round the initial quantity based on
37+
# the rounding method coming from the client.
38+
rounding = self._get_multiple_rounding(**kwargs)
39+
40+
if company_id:
41+
request.update_context(allowed_company_ids=[company_id])
42+
43+
product_template = self._get_product_template(product_template_id)
44+
product_uom = (
45+
request.env["uom.uom"].browse(product_uom_id) if product_uom_id else None
46+
)
47+
uom = product_uom or product_template.uom_id
48+
49+
rounded_quantity = self._get_rounded_qty(
50+
product_template,
51+
quantity or 0.0,
52+
uom,
53+
rounding=rounding,
54+
)
55+
56+
res = super().website_sale_product_configurator_get_values(
57+
product_template_id=product_template_id,
58+
quantity=rounded_quantity,
59+
currency_id=currency_id,
60+
so_date=so_date,
61+
product_uom_id=product_uom_id,
62+
company_id=company_id,
63+
pricelist_id=pricelist_id,
64+
ptav_ids=ptav_ids,
65+
only_main_product=only_main_product,
66+
**kwargs,
67+
)
68+
69+
# Ensure the returned quantity after super call is the rounded quantity
70+
if res.get("products"):
71+
res["products"][0]["quantity"] = rounded_quantity
72+
73+
# Round the quantity for the optional products as well
74+
for product in res.get("optional_products", []):
75+
tmpl = self._get_product_template(product.get("product_tmpl_id"))
76+
puom = None
77+
if product.get("uom") and product["uom"].get("id"):
78+
puom = request.env["uom.uom"].browse(product["uom"]["id"])
79+
product["quantity"] = self._get_rounded_qty(
80+
tmpl,
81+
product.get("quantity") or 0.0,
82+
puom or tmpl.uom_id,
83+
rounding=rounding,
84+
)
85+
return res
86+
87+
@route(
88+
route="/website_sale/product_configurator/update_combination",
89+
type="jsonrpc",
90+
auth="public",
91+
website=True,
92+
readonly=True,
93+
methods=["POST"],
94+
)
95+
def website_sale_product_configurator_update_combination(
96+
self,
97+
product_template_id,
98+
ptav_ids,
99+
*,
100+
currency_id=None,
101+
so_date=None,
102+
quantity=None,
103+
product_uom_id=None,
104+
company_id=None,
105+
pricelist_id=None,
106+
**kwargs,
107+
):
108+
# OVERRIDE: round the initial quantity based on
109+
# the rounding method coming from the frontend.
110+
rounding = self._get_multiple_rounding(**kwargs)
111+
112+
if company_id:
113+
request.update_context(allowed_company_ids=[company_id])
114+
115+
product_template = request.env["product.template"].browse(product_template_id)
116+
product_uom = (
117+
request.env["uom.uom"].browse(product_uom_id) if product_uom_id else None
118+
)
119+
uom = product_uom or product_template.uom_id
120+
121+
rounded_quantity = self._get_rounded_qty(
122+
product_template,
123+
quantity or 0.0,
124+
uom,
125+
rounding=rounding,
126+
)
127+
128+
values = super().website_sale_product_configurator_update_combination(
129+
product_template_id=product_template_id,
130+
ptav_ids=ptav_ids,
131+
currency_id=currency_id,
132+
so_date=so_date,
133+
quantity=rounded_quantity,
134+
product_uom_id=product_uom_id,
135+
company_id=company_id,
136+
pricelist_id=pricelist_id,
137+
**kwargs,
138+
)
139+
140+
values["quantity"] = rounded_quantity
141+
return values
142+
143+
def _get_multiple_rounding(self, **kwargs):
144+
return kwargs.get("multiple_rounding") or "UP"
145+
146+
def _get_rounded_qty(self, product_or_template, quantity, uom, *, rounding="UP"):
147+
"""Round quantity according to product `sale_multiple_uom_id`.
148+
149+
:param product_or_template: product.product or product.template
150+
:param float quantity: quantity to round if needed
151+
:param uom.uom uom: UoM of the quantity
152+
:param str rounding: 'UP' or 'DOWN'
153+
:return float: rounded quantity if multiple UoM applies, else original quantity
154+
"""
155+
multiple_uom = product_or_template.sale_multiple_uom_id
156+
if not multiple_uom:
157+
return quantity
158+
159+
uom = uom or product_or_template.uom_id
160+
rounded_qty = multiple_uom._check_qty(quantity, uom, rounding_method=rounding)
161+
return round(rounded_qty, 0)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
from . import product_template
2+
from . import sale_order_line
Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# Copyright 2026 Camptocamp SA
22
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3-
from odoo import models
3+
from odoo import api, models
44

55

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

9+
@api.model
910
def _get_additionnal_combination_info(
1011
self, product_or_template, quantity, uom, date, website
1112
):
@@ -14,21 +15,20 @@ def _get_additionnal_combination_info(
1415
product_or_template, quantity, uom, date, website
1516
)
1617

17-
if not product_or_template.sale_multiple_uom_id:
18+
multiple_uom = product_or_template.sale_multiple_uom_id
19+
if not multiple_uom:
1820
return combination_info
21+
1922
rounding = "UP"
2023
if to_rounding := self.env.context.get("multiple_rounding"):
2124
rounding = to_rounding
22-
rounded_qty = self.sale_multiple_uom_id._check_qty(
23-
quantity, self.uom_id, rounding_method=rounding
24-
)
25+
26+
rounded_qty = multiple_uom._check_qty(quantity, uom, rounding_method=rounding)
27+
2528
combination_info.update(
2629
{
2730
"is_multiple": 1,
28-
# The website expects an integer value as an input
29-
# ``website_sale::variant_mixin.js``
30-
# parseInt(parent.querySelector('input[name="add_qty"]').value).
31-
"multiple_qty": int(rounded_qty),
31+
"multiple_qty": round(rounded_qty, 0),
3232
}
3333
)
3434
return combination_info
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
from odoo import models
4+
5+
6+
class SaleOrderLine(models.Model):
7+
_inherit = "sale.order.line"
8+
9+
def _get_displayed_quantity(self):
10+
result = super()._get_displayed_quantity()
11+
multiple_uom = self.product_id.sale_multiple_uom_id
12+
if not multiple_uom:
13+
return result
14+
15+
qty_to_order = self.product_uom_qty or 0.0
16+
if qty_to_order <= 0:
17+
return result
18+
return self._round_sale_qty_to_multiple(qty_to_order)

website_sale_product_multiple_qty/static/src/js/website_sale.esm.js renamed to website_sale_product_multiple_qty/static/src/js/interactions/website_sale.esm.js

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ patch(WebsiteSale.prototype, {
2121
const minus = ev.target.closest(".css_quantity_minus");
2222
const plus = ev.target.closest(".css_quantity_plus");
2323
if (!minus && !plus) return;
24+
2425
const parent = (minus || plus).closest(".js_product");
2526
if (!parent) return;
27+
2628
parent.dataset.multipleRounding = minus ? "DOWN" : "UP";
2729
},
2830
true
@@ -34,8 +36,10 @@ patch(WebsiteSale.prototype, {
3436
(ev) => {
3537
const input = ev.target.closest?.('input[name="add_qty"]');
3638
if (!input) return;
39+
3740
const parent = input.closest(".js_product");
3841
if (!parent) return;
42+
3943
parent.dataset.multipleRounding = "UP";
4044
},
4145
true
@@ -48,61 +52,80 @@ patch(WebsiteSale.prototype, {
4852
(ev) => {
4953
const input = ev.target.closest?.('input[name="add_qty"]');
5054
if (!input || ev.key !== "Enter") return;
55+
5156
ev.preventDefault();
57+
5258
const parent = input.closest(".js_product");
5359
if (!parent) return;
60+
5461
parent.dataset.multipleRounding = "UP";
55-
input.dispatchEvent(new Event("change", {bubbles: true}));
5662
},
5763
true
5864
);
5965

6066
return res;
6167
},
68+
6269
/**
63-
* Prevent duplicate ``get_combination_info`` RPC calls to avoid rounding method mess-up.
64-
* The original method is triggered on each ``ul[data-attribute-exclusions]`` change.
65-
* The goal is to trigger it only once, with the correct rounding method set by the user.
70+
* Reset to default values when changing the variant.
6671
*
6772
* @override
6873
*/
69-
triggerVariantChange(container) {
70-
const ul = container.querySelector("ul[data-attribute-exclusions]");
71-
if (ul) {
72-
ul.dispatchEvent(new Event("change"));
74+
onChangeVariant(ev) {
75+
// OnChangeVariant may also be triggered by qty updates; do not treat that as a variant change.
76+
// Only reset when the event comes from a variant control (not from the quantity input).
77+
if (ev?.target?.closest?.('input[name="add_qty"]')) {
78+
return super.onChangeVariant(...arguments);
7379
}
74-
container
75-
.querySelectorAll(
76-
"input.js_variant_change:checked, select.js_variant_change"
77-
)
78-
.forEach((el) => this.handleCustomValues(el));
80+
81+
const parent = ev.currentTarget.closest(".js_product");
82+
if (parent) {
83+
const qtyInput = parent.querySelector('input[name="add_qty"]');
84+
if (qtyInput) {
85+
// Reset to the default "min" quantity if present, otherwise 1.
86+
const min = Number(qtyInput.dataset.min || "1");
87+
const defaultQty = !Number.isNaN(min) && min > 0 ? min : 1;
88+
qtyInput.value = String(defaultQty);
89+
}
90+
91+
// When switching variants, we consider it a "default" state.
92+
// Do not keep a stale rounding method from a previous "-" click.
93+
delete parent.dataset.multipleRounding;
94+
}
95+
96+
return super.onChangeVariant(...arguments);
7997
},
98+
8099
/**
81100
* Inject the rounding method into the RPC payload.
82101
*
83102
* @override
84103
*/
85104
_getOptionalCombinationInfoParam(parent) {
86105
const params = super._getOptionalCombinationInfoParam?.(parent) || {};
106+
87107
const rounding = parent?.dataset?.multipleRounding;
88108
if (rounding) {
89109
params.multiple_rounding = rounding;
90-
delete parent.dataset.multipleRounding;
91110
}
111+
92112
return params;
93113
},
114+
94115
/**
95116
* Update the quantity input value with the rounded quantity returned by the RPC.
96117
*
97118
* @override
98119
*/
99120
_onChangeCombination(ev, parent, combination) {
100121
const result = super._onChangeCombination(...arguments);
122+
101123
const qtyInput = parent?.querySelector?.('input[name="add_qty"]');
102-
if (!qtyInput) {
103-
return result;
104-
}
105-
if (combination?.is_multiple && combination?.multiple_qty !== null) {
124+
if (
125+
qtyInput &&
126+
combination?.is_multiple &&
127+
combination?.multiple_qty !== undefined
128+
) {
106129
const rounded = Number(combination.multiple_qty);
107130
if (!Number.isNaN(rounded)) {
108131
const current = parseInt(qtyInput.value || "0");

0 commit comments

Comments
 (0)