@@ -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
0 commit comments