diff --git a/addons/loyalty/data/loyalty_data.xml b/addons/loyalty/data/loyalty_data.xml index 091e68cfd96..41bcf415915 100644 --- a/addons/loyalty/data/loyalty_data.xml +++ b/addons/loyalty/data/loyalty_data.xml @@ -15,4 +15,11 @@ service + + + + loyalty.compute_all_discount_product_ids + False + + diff --git a/addons/loyalty/models/loyalty_reward.py b/addons/loyalty/models/loyalty_reward.py index 1fae5a58046..28e76ecb9e7 100644 --- a/addons/loyalty/models/loyalty_reward.py +++ b/addons/loyalty/models/loyalty_reward.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import ast +import json from odoo import _, api, fields, models from odoo.osv import expression @@ -69,6 +70,7 @@ class LoyaltyReward(models.Model): discount_product_category_id = fields.Many2one('product.category', string="Discounted Prod. Categories") discount_product_tag_id = fields.Many2one('product.tag', string="Discounted Prod. Tag") all_discount_product_ids = fields.Many2many('product.product', compute='_compute_all_discount_product_ids') + reward_product_domain = fields.Char(compute='_compute_reward_product_domain', store=False) discount_max_amount = fields.Monetary('Max Discount', 'currency_id', help="This is the max amount this reward may discount, leave to 0 for no limit.") discount_line_product_id = fields.Many2one('product.product', copy=False, ondelete='restrict', @@ -103,23 +105,45 @@ class LoyaltyReward(models.Model): for reward in self: reward.reward_product_uom_id = reward.reward_product_ids.product_tmpl_id.uom_id[:1] + def _find_all_category_children(self, category_id, child_ids): + if len(category_id.child_id) > 0: + for child_id in category_id.child_id: + child_ids.append(child_id.id) + self._find_all_category_children(child_id, child_ids) + return child_ids + def _get_discount_product_domain(self): self.ensure_one() domain = [] if self.discount_product_ids: domain = [('id', 'in', self.discount_product_ids.ids)] if self.discount_product_category_id: - domain = expression.OR([domain, [('categ_id', 'child_of', self.discount_product_category_id.id)]]) + product_category_ids = self._find_all_category_children(self.discount_product_category_id, []) + product_category_ids.append(self.discount_product_category_id.id) + domain = expression.OR([domain, [('categ_id', 'in', product_category_ids)]]) if self.discount_product_tag_id: domain = expression.OR([domain, [('all_product_tag_ids', 'in', self.discount_product_tag_id.id)]]) if self.discount_product_domain and self.discount_product_domain != '[]': domain = expression.AND([domain, ast.literal_eval(self.discount_product_domain)]) return domain + @api.depends('discount_product_domain') + def _compute_reward_product_domain(self): + compute_all_discount_product = self.env['ir.config_parameter'].get_param('loyalty.compute_all_discount_product_ids', 'enabled') + for reward in self: + if compute_all_discount_product == 'enabled': + reward.reward_product_domain = "null" + else: + reward.reward_product_domain = json.dumps(reward._get_discount_product_domain()) + @api.depends('discount_product_ids', 'discount_product_category_id', 'discount_product_tag_id', 'discount_product_domain') def _compute_all_discount_product_ids(self): + compute_all_discount_product = self.env['ir.config_parameter'].get_param('loyalty.compute_all_discount_product_ids', 'enabled') for reward in self: - reward.all_discount_product_ids = self.env['product.product'].search(reward._get_discount_product_domain()) + if compute_all_discount_product == 'enabled': + reward.all_discount_product_ids = self.env['product.product'].search(reward._get_discount_product_domain()) + else: + reward.all_discount_product_ids = self.env['product.product'] @api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type') def _compute_multi_product(self): @@ -161,8 +185,9 @@ class LoyaltyReward(models.Model): elif reward.discount_applicability == 'cheapest': reward_string += _('the cheapest product') elif reward.discount_applicability == 'specific': - if len(reward.all_discount_product_ids) == 1: - reward_string += reward.all_discount_product_ids.name + product_available = self.env['product.product'].search(reward._get_discount_product_domain(), limit=2) + if len(product_available) == 1: + reward_string += product_available.name else: reward_string += _('specific products') if reward.discount_max_amount: diff --git a/addons/pos_loyalty/models/pos_session.py b/addons/pos_loyalty/models/pos_session.py index 270411b560b..83dc929e9e9 100644 --- a/addons/pos_loyalty/models/pos_session.py +++ b/addons/pos_loyalty/models/pos_session.py @@ -43,7 +43,7 @@ class PosSession(models.Model): 'fields': ['description', 'program_id', 'reward_type', 'required_points', 'clear_wallet', 'currency_id', 'discount', 'discount_mode', 'discount_applicability', 'all_discount_product_ids', 'is_global_discount', 'discount_max_amount', 'discount_line_product_id', - 'multi_product', 'reward_product_ids', 'reward_product_qty', 'reward_product_uom_id'], + 'multi_product', 'reward_product_ids', 'reward_product_qty', 'reward_product_uom_id', 'reward_product_domain'], } } @@ -112,3 +112,9 @@ class PosSession(models.Model): product_id_to_program_ids[product['id']].append(program['id']) loaded_data['product_id_to_program_ids'] = product_id_to_program_ids + + def _loader_params_product_product(self): + params = super()._loader_params_product_product() + # this is usefull to evaluate reward domain in frontend + params['search_params']['fields'].append('all_product_tag_ids') + return params diff --git a/addons/pos_loyalty/static/src/js/Loyalty.js b/addons/pos_loyalty/static/src/js/Loyalty.js index 1cb3d06bd0c..bfc5eacede2 100644 --- a/addons/pos_loyalty/static/src/js/Loyalty.js +++ b/addons/pos_loyalty/static/src/js/Loyalty.js @@ -7,7 +7,9 @@ import { roundDecimals, roundPrecision } from "@web/core/utils/numbers"; import { _t } from "@web/core/l10n/translation"; import { patch } from "@web/core/utils/patch"; import { ConfirmPopup } from "@point_of_sale/js/Popups/ConfirmPopup"; +import { Domain, InvalidDomainError } from "@web/core/domain"; import { sprintf } from "@web/core/utils/strings"; +import { ErrorPopup } from "@point_of_sale/js/Popups/ErrorPopup"; // FIXME: Perhaps MutexedDropPrevious can be replaced by the new KeepLast. // > This might require thorough investigation on how _updateRewards work. @@ -83,13 +85,61 @@ patch(PosGlobalState.prototype, "pos_loyalty.PosGlobalState", { async _processData(loadedData) { this.couponCache = {}; this.partnerId2CouponIds = {}; + this.rewards = loadedData["loyalty.reward"] || []; + + for (const reward of this.rewards) { + reward.all_discount_product_ids = new Set(reward.all_discount_product_ids); + } + await this._super(loadedData); this.productId2ProgramIds = loadedData["product_id_to_program_ids"]; this.programs = loadedData["loyalty.program"] || []; //TODO: rename to `loyaltyPrograms` etc this.rules = loadedData["loyalty.rule"] || []; - this.rewards = loadedData["loyalty.reward"] || []; this._loadLoyaltyData(); }, + + _loadProductProduct(products) { + this._super(...arguments); + + for (const reward of this.rewards) { + this.compute_discount_product_ids(reward, products); + } + + this.rewards = this.rewards.filter(Boolean); + }, + + compute_discount_product_ids(reward, products) { + const reward_product_domain = JSON.parse(reward.reward_product_domain); + if (!reward_product_domain) { + return; + } + + const domain = new Domain(reward_product_domain); + + try { + products + .filter((product) => domain.contains(product)) + .forEach((product) => reward.all_discount_product_ids.add(product.id)); + } catch (error) { + if (!(error instanceof InvalidDomainError)) { + throw error; + } + const index = this.rewards.indexOf(reward); + if (index != -1) { + this.pos.env.services.popup.add(ErrorPopup, { + title: _t("A reward could not be loaded"), + body: sprintf( + _t( + 'The reward "%s" contain an error in its domain, your domain must be compatible with the PoS client' + ), + this.rewards[index].description + ), + }); + this.rewards[index] = null; + } + } + }, + _loadLoyaltyData() { this.program_by_id = {}; this.reward_by_id = {}; diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py index 0b8e4e15097..a072195663a 100644 --- a/addons/sale_loyalty/models/sale_order.py +++ b/addons/sale_loyalty/models/sale_order.py @@ -200,7 +200,8 @@ class SaleOrder(models.Model): discountable_lines = self.env['sale.order.line'] for line in (self.order_line - self._get_no_effect_on_threshold_lines()): - if not line.reward_id and line.product_id in reward.all_discount_product_ids: + domain = reward._get_discount_product_domain() + if not line.reward_id and line.product_id.filtered_domain(domain): discountable_lines |= line return discountable_lines @@ -223,7 +224,8 @@ class SaleOrder(models.Model): if not line.product_uom_qty or not line.price_unit: continue remaining_amount_per_line[line] = line.price_total - if not line.reward_id and line.product_id in reward.all_discount_product_ids: + domain = reward._get_discount_product_domain() + if not line.reward_id and line.product_id.filtered_domain(domain): lines_to_discount |= line elif line.reward_id.reward_type == 'discount': discount_lines[line.reward_identifier_code] |= line diff --git a/addons/sale_loyalty/tests/test_program_numbers.py b/addons/sale_loyalty/tests/test_program_numbers.py index 53284f075a6..691794bfaa4 100644 --- a/addons/sale_loyalty/tests/test_program_numbers.py +++ b/addons/sale_loyalty/tests/test_program_numbers.py @@ -200,6 +200,7 @@ class TestSaleCouponProgramNumbers(TestSaleCouponNumbersCommon): def test_program_numbers_one_discount_line_per_tax(self): order = self.empty_order + self.env['ir.config_parameter'].set_param('loyalty.compute_all_discount_product_ids', 'enabled') # Create taxes self.tax_15pc_excl = self.env['account.tax'].create({ 'name': "15% Tax excl",