Skip to content

Commit 159090c

Browse files
[IMP]sale_pricelist_global_rule: Extend ancestor category rule logic (ref PR #3533)
Update Readme and logic code Update Readme and logic code Update Test
1 parent 53494cb commit 159090c

File tree

11 files changed

+499
-25
lines changed

11 files changed

+499
-25
lines changed

sale_pricelist_global_rule/README.rst

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
15
==========================
26
Sale pricelist global rule
37
==========================
48

5-
..
9+
..
610
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
711
!! This file is generated by oca-gen-addon-readme !!
812
!! changes will be overwritten. !!
@@ -13,7 +17,7 @@ Sale pricelist global rule
1317
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
1418
:target: https://odoo-community.org/page/development-status
1519
:alt: Beta
16-
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
20+
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
1721
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
1822
:alt: License: AGPL-3
1923
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github
@@ -52,6 +56,30 @@ quantities across lines allow the pricelist rule to apply, as they meet
5256
the minimum threshold (16 in the product template example and 20 in the
5357
product category example).
5458

59+
**Global by Ancestor Product Category**
60+
61+
This option allows defining a rule on an *ancestor category*, and it will
62+
apply to all products that belong to this category or any of its descendants.
63+
The minimum quantity check is performed on the **total ordered quantity**
64+
across all descendant categories.
65+
66+
For example, suppose we have the following category tree:
67+
68+
- Cat A
69+
- Cat B
70+
- Cat C
71+
- Cat D
72+
73+
And a pricelist rule is configured on *Cat C* with a minimum quantity = 5
74+
and a 20% discount.
75+
If a sales order contains:
76+
77+
- Line 1: Product in Cat C, quantity = 4
78+
- Line 2: Product in Cat D, quantity = 2
79+
80+
Then the total for Cat C’s branch = 6 (4 + 2), which meets the minimum
81+
threshold of 5. As a result, the 20% discount rule applies to both lines.
82+
5583
**Table of contents**
5684

5785
.. contents::
@@ -60,11 +88,14 @@ product category example).
6088
Configuration
6189
=============
6290

63-
- Go to Sales -> Products -> Pricelist.
64-
- Create a new Pricelist and add at least one line with the Apply On
65-
option set to Global - Product template or Global - Product category
66-
- Choose the specific product template or category for the rule.
67-
- Set the computation mode and save
91+
- Go to **Sales → Products → Pricelists**.
92+
- Create a new Pricelist and add at least one line with the *Apply On*
93+
option set to **Global - Product Template**, **Global - Product Category**, or
94+
**Global - Ancestor Product Category**.
95+
- When using *Ancestor Product Category*, select the top-level or ancestor
96+
category that will include all of its descendant categories.
97+
- Choose the specific product, template, or category for the rule.
98+
- Set the computation mode (e.g., percentage discount) and save.
6899

69100
Usage
70101
=====
@@ -75,6 +106,14 @@ Usage
75106
Category or Product).
76107
- Click the **Recompute pricelist global** button to update prices
77108
according to the specified pricelist rules.
109+
- When using **Ancestor Product Category**, the system will sum the ordered
110+
quantities of all products belonging to the selected ancestor category
111+
and all its descendants.
112+
If the accumulated quantity meets the rule’s minimum quantity, the
113+
discount will be applied to each matching order line.
114+
- When multiple percentage-based rules apply, the system automatically
115+
selects the **highest discount available**, ensuring that the most
116+
beneficial rule is applied to the sale order.
78117

79118
Known issues / Roadmap
80119
======================
@@ -100,6 +139,7 @@ Authors
100139
-------
101140

102141
* Tecnativa
142+
* Truong Duc (Komit-consulting)
103143

104144
Contributors
105145
------------
@@ -108,6 +148,7 @@ Contributors
108148

109149
- Pedro M. Baeza
110150
- Carlos López
151+
- Truong Duc (Komit-consulting)
111152

112153
Maintainers
113154
-----------

sale_pricelist_global_rule/__manifest__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"name": "Sale pricelist global rule",
33
"version": "16.0.1.0.0",
44
"summary": "Apply a global rule to all sale order",
5-
"author": "Tecnativa, Odoo Community Association (OCA)",
5+
"author": "Tecnativa, Odoo Community Association (OCA),"
6+
"Truong Duc (Komit-consulting)",
67
"category": "Sales Management",
78
"website": "https://github.com/OCA/sale-workflow",
89
"depends": [

sale_pricelist_global_rule/models/product_pricelist.py

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ class ProductPricelistItem(models.Model):
99
selection_add=[
1010
("3_1_global_product_template", "Global - Product template"),
1111
("3_2_global_product_category", "Global - Product category"),
12+
(
13+
"3_3_global_product_ancestor_category",
14+
"Global - Ancestor Product Category",
15+
),
1216
],
1317
ondelete={
1418
"3_1_global_product_template": "set default",
1519
"3_2_global_product_category": "set default",
20+
"3_3_global_product_ancestor_category": "set default",
1621
},
1722
)
1823
global_product_tmpl_id = fields.Many2one(
@@ -26,6 +31,9 @@ class ProductPricelistItem(models.Model):
2631
"Product Category",
2732
ondelete="cascade",
2833
)
34+
ancestor_product_category_id = fields.Many2one(
35+
"product.category", ondelete="cascade"
36+
)
2937

3038
@api.constrains(
3139
"product_id",
@@ -57,6 +65,16 @@ def _check_product_consistency(self):
5765
"for which this global rule should be applied"
5866
)
5967
)
68+
elif (
69+
item.applied_on == "3_3_global_product_ancestor_category"
70+
and not item.ancestor_product_category_id
71+
):
72+
raise ValidationError(
73+
_(
74+
"Please specify the product ancestor category for which this global"
75+
" rule should be applied"
76+
)
77+
)
6078
return res
6179

6280
@api.depends(
@@ -90,6 +108,13 @@ def _compute_name_and_price(self):
90108
item.name = _("Global product: %s") % (
91109
item.global_product_tmpl_id.display_name
92110
)
111+
elif (
112+
item.ancestor_product_category_id
113+
and item.applied_on == "3_3_global_product_ancestor_category"
114+
):
115+
item.name = _("Ancestor product category: %s") % (
116+
item.ancestor_product_category_id.display_name
117+
)
93118
return res
94119

95120
@api.model_create_multi
@@ -105,6 +130,7 @@ def create(self, vals_list):
105130
"product_tmpl_id": None,
106131
"categ_id": None,
107132
"global_product_tmpl_id": None,
133+
"ancestor_product_category_id": None,
108134
}
109135
)
110136
elif applied_on == "3_1_global_product_template":
@@ -114,6 +140,17 @@ def create(self, vals_list):
114140
"product_tmpl_id": None,
115141
"categ_id": None,
116142
"global_categ_id": None,
143+
"ancestor_product_category_id": None,
144+
}
145+
)
146+
elif applied_on == "3_3_global_product_ancestor_category":
147+
values.update(
148+
{
149+
"product_id": None,
150+
"product_tmpl_id": None,
151+
"categ_id": None,
152+
"global_categ_id": None,
153+
"global_product_tmpl_id": None,
117154
}
118155
)
119156
return super().create(vals_list)
@@ -129,6 +166,7 @@ def write(self, values):
129166
"product_tmpl_id": None,
130167
"categ_id": None,
131168
"global_product_tmpl_id": None,
169+
"ancestor_product_category_id": None,
132170
}
133171
)
134172
elif applied_on == "3_1_global_product_template":
@@ -138,6 +176,17 @@ def write(self, values):
138176
"product_tmpl_id": None,
139177
"categ_id": None,
140178
"global_categ_id": None,
179+
"ancestor_product_category_id": None,
180+
}
181+
)
182+
elif applied_on == "3_3_global_product_ancestor_category":
183+
values.update(
184+
{
185+
"product_id": None,
186+
"product_tmpl_id": None,
187+
"categ_id": None,
188+
"global_categ_id": None,
189+
"global_product_tmpl_id": None,
141190
}
142191
)
143192
return super().write(values)
@@ -155,19 +204,26 @@ def _is_applicable_for(self, product, qty_in_product_uom):
155204
:rtype: bool
156205
"""
157206
self.ensure_one()
158-
qty_data = self.env.context.get("pricelist_global_cummulative_quantity", {})
159-
if not qty_data or self.applied_on not in [
207+
qty_data = (
208+
self.env.context.get("pricelist_global_cummulative_quantity", {}) or {}
209+
)
210+
supported = {
160211
"3_1_global_product_template",
161212
"3_2_global_product_category",
162-
]:
213+
"3_3_global_product_ancestor_category",
214+
}
215+
# Fallback to base behavior if not a supported global case or no context
216+
if not qty_data or self.applied_on not in supported:
163217
return super()._is_applicable_for(product, qty_in_product_uom)
218+
164219
is_applicable = True
165220
if self.applied_on == "3_1_global_product_template":
166221
total_qty = qty_data["by_template"].get(product.product_tmpl_id, 0.0)
167222
if self.min_quantity and total_qty < self.min_quantity:
168223
is_applicable = False
169224
elif self.global_product_tmpl_id != product.product_tmpl_id:
170225
is_applicable = False
226+
# Global Product Category
171227
elif self.applied_on == "3_2_global_product_category":
172228
total_qty = qty_data["by_categ"].get(product.categ_id, 0.0)
173229
if self.min_quantity and total_qty < self.min_quantity:
@@ -176,4 +232,80 @@ def _is_applicable_for(self, product, qty_in_product_uom):
176232
self.global_categ_id.parent_path
177233
):
178234
is_applicable = False
235+
# Global Product Category
236+
elif self.applied_on == "3_3_global_product_ancestor_category":
237+
ancestor_categ = self.ancestor_product_category_id
238+
if not ancestor_categ:
239+
return False
240+
241+
Category = self.env["product.category"]
242+
243+
# ancestor + all descendants (fast, uses parent_path internally)
244+
child_categories = Category.search([("id", "child_of", ancestor_categ.id)])
245+
246+
# product's category must belong to this ancestor branch
247+
prod_categ = product.categ_id
248+
if not prod_categ or prod_categ not in child_categories:
249+
return False
250+
251+
# Normalize by_categ keys to ids: {id: qty}
252+
by_categ_raw = qty_data.get("by_categ") or {}
253+
by_categ_id = {
254+
(k.id if hasattr(k, "id") else int(k)): v
255+
for k, v in by_categ_raw.items()
256+
}
257+
258+
# Sum total quantity across the whole ancestor branch
259+
total_qty = sum(by_categ_id.get(cid, 0.0) for cid in child_categories.ids)
260+
261+
# Check minimum quantity threshold over the branch total
262+
if self.min_quantity and total_qty < self.min_quantity:
263+
is_applicable = False
264+
265+
if not is_applicable:
266+
return False
267+
# --------------------------------------------------------------
268+
# Ensure current price item has best discount
269+
#
270+
# By default Odoo will just pick the first applicable rule based on sequence.
271+
# That means if two percentage rules apply to the same product
272+
# (e.g. Cat A = 10%, Cat C = 20%), the system might pick the 10% rule just
273+
# because it comes first. Therefore, we need the code below to check if the
274+
# current price item is already higher discount.
275+
# --------------------------------------------------------------
276+
if self._has_better_percentage_rule(product, qty_in_product_uom):
277+
is_applicable = False
179278
return is_applicable
279+
280+
def _has_better_percentage_rule(self, product, qty_in_product_uom):
281+
"""
282+
Check if there is another percentage rule that should take precedence.
283+
This prevents a weaker discount (lower percent_price) from overriding
284+
a stronger one when multiple percentage rules apply.
285+
"""
286+
if (
287+
self.compute_price != "percentage"
288+
or not self.percent_price
289+
or self.env.context.get("skip_best_percent_check")
290+
):
291+
return False
292+
293+
# Fetch all other percentage rules from the same pricelist
294+
# context skip_best_percent_check to avoid run forever
295+
items = self.pricelist_id.item_ids.with_context(
296+
skip_best_percent_check=True
297+
).filtered(
298+
lambda it: it != self
299+
and it.compute_price == "percentage"
300+
and it.percent_price > 0
301+
)
302+
for it in items:
303+
# Check if rival rule also applies to this product & quantity
304+
if not it._is_applicable_for(product, qty_in_product_uom):
305+
continue
306+
307+
# Get another price item which discount is higher
308+
if it.percent_price > self.percent_price:
309+
return True
310+
311+
return False
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
- [Tecnativa](https://www.tecnativa.com)
22
- Pedro M. Baeza
33
- Carlos López
4+
- Truong Duc (Komit-consulting)

0 commit comments

Comments
 (0)