diff --git a/models/loyalty_reward.py b/models/loyalty_reward.py index cdd27b4..bba827a 100644 --- a/models/loyalty_reward.py +++ b/models/loyalty_reward.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import json from odoo import models, fields, api from odoo.fields import Domain @@ -16,9 +17,38 @@ class LoyaltyReward(models.Model): help='If specified, only products under this category (including subcategories) will be considered for the cheapest product reward.' ) + reward_product_ids_json = fields.Char( + compute='_compute_reward_product_ids_json', + string='Reward Product IDs JSON' + ) + @api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type', 'reward_product_applicability', 'reward_product_category_id') def _compute_multi_product(self): - super()._compute_multi_product() + # Override completely (no super()) to avoid recursive field invalidation. + # In Odoo 19, reassigning a computed field from within its own compute method + # (after calling super()) triggers cache invalidation and re-computation, + # causing a RecursionError via WeakSet.__iter__ during environment cleanup. + for reward in self: + tag_products = reward.reward_product_tag_id.product_ids.filtered( + lambda product: product.type != 'combo' + ) + if reward.reward_type == 'product' and reward.reward_product_applicability == 'cheapest': + # For 'cheapest' applicability: include direct product + tag products, + # but not category products (to avoid large sets and reference cycles). + products = reward.reward_product_id + tag_products + reward.multi_product = False + reward.reward_product_ids = products + else: + # Default behaviour (mirrors the standard Odoo computation) + products = reward.reward_product_id + tag_products + reward.multi_product = reward.reward_type == 'product' and len(products) > 1 + reward.reward_product_ids = ( + products if reward.reward_type == 'product' + else self.env['product.product'] + ) + + @api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type', 'reward_product_applicability', 'reward_product_category_id') + def _compute_reward_product_ids_json(self): for reward in self: if reward.reward_type == 'product' and reward.reward_product_applicability == 'cheapest': products = reward.reward_product_id + reward.reward_product_tag_id.product_ids.filtered( @@ -29,11 +59,13 @@ class LoyaltyReward(models.Model): ('id', 'child_of', reward.reward_product_category_id.ids) ]).ids cat_products = self.env['product.product'].search([ - ('categ_id', 'in', category_ids) + ('categ_id', 'in', category_ids), + ('type', '!=', 'combo') ]) - products |= cat_products.filtered(lambda product: product.type != 'combo') - reward.multi_product = False - reward.reward_product_ids = products + products |= cat_products + reward.reward_product_ids_json = json.dumps(products.ids) + else: + reward.reward_product_ids_json = json.dumps([]) @api.model def _load_pos_data_fields(self, config): @@ -42,6 +74,8 @@ class LoyaltyReward(models.Model): fields.append('reward_product_applicability') if 'reward_product_category_id' not in fields: fields.append('reward_product_category_id') + if 'reward_product_ids_json' not in fields: + fields.append('reward_product_ids_json') return fields @api.model @@ -61,3 +95,4 @@ class LoyaltyReward(models.Model): reward_product_tag_domain, ]), ]) + diff --git a/models/sale_order.py b/models/sale_order.py index 12024be..3a30fb6 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -17,9 +17,21 @@ class SaleOrder(models.Model): candidate_lines = self.order_line.filtered( lambda l: not l.is_reward_line and l.product_uom_qty > 0 and l.price_unit > 0 ) - if reward.reward_product_ids: + if reward.reward_product_id or reward.reward_product_tag_id or reward.reward_product_category_id: + allowed_products = reward.reward_product_id + if reward.reward_product_tag_id: + allowed_products |= reward.reward_product_tag_id.product_ids + + category_ids = [] + if reward.reward_product_category_id: + category_ids = self.env['product.category'].search([ + ('id', 'child_of', reward.reward_product_category_id.ids) + ]).ids + candidate_lines = candidate_lines.filtered( - lambda l: l.product_id in reward.reward_product_ids + lambda l: l.product_id in allowed_products or ( + category_ids and l.product_id.categ_id.id in category_ids + ) ) if not candidate_lines: return None @@ -36,8 +48,20 @@ class SaleOrder(models.Model): product = self._get_cheapest_matching_product(reward) if not product: # Fallback if no eligible product in cart - if reward.reward_product_ids: - product = reward.reward_product_ids[:1] + allowed_products = reward.reward_product_id + if reward.reward_product_tag_id: + allowed_products |= reward.reward_product_tag_id.product_ids + if reward.reward_product_category_id: + category_ids = self.env['product.category'].search([ + ('id', 'child_of', reward.reward_product_category_id.ids) + ]).ids + allowed_products |= self.env['product.product'].search([ + ('categ_id', 'in', category_ids), + ('type', '!=', 'combo') + ], limit=1) + + if allowed_products: + product = allowed_products[:1] else: first_line = self.order_line.filtered(lambda l: not l.is_reward_line and l.product_uom_qty > 0)[:1] product = first_line.product_id diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js index 28041c7..c5bd7bb 100644 --- a/static/src/app/models/pos_order.js +++ b/static/src/app/models/pos_order.js @@ -5,6 +5,26 @@ import { patch } from "@web/core/utils/patch"; import { _t } from "@web/core/l10n/translation"; patch(PosOrder.prototype, { + /** + * Helper to resolve allowed product IDs for a reward, including from reward_product_ids_json. + */ + _getRewardProductIds(reward) { + let rewardProductIds = new Set(); + if (reward?.reward_product_applicability === 'cheapest') { + if (reward.reward_product_ids_json) { + try { + const ids = JSON.parse(reward.reward_product_ids_json); + rewardProductIds = new Set(ids); + } catch (e) { + console.error("Failed to parse reward_product_ids_json", e); + } + } + } else { + rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id)); + } + return rewardProductIds; + }, + /** * Returns the single cheapest product in the order (used for qty/availability checks). */ @@ -13,7 +33,7 @@ patch(PosOrder.prototype, { let cheapestProduct = null; let lowestPrice = Infinity; - const rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id)); + const rewardProductIds = this._getRewardProductIds(reward); for (const line of orderLines || []) { if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) { @@ -41,7 +61,7 @@ patch(PosOrder.prototype, { * Used to build multiple reward lines (1st cheapest free, 2nd cheapest free, etc.). */ _getCheapestProductsInOrder(reward, n) { - const rewardProductIds = new Set((reward?.reward_product_ids || []).map(p => p.id)); + const rewardProductIds = this._getRewardProductIds(reward); const candidateLines = this.getOrderlines().filter(line => { if (line.is_reward_line || line.qty <= 0 || line.price_unit <= 0) return false;