diff --git a/addons/loyalty/models/loyalty_program.py b/addons/loyalty/models/loyalty_program.py index 90bc8214441..dce5d2c3315 100644 --- a/addons/loyalty/models/loyalty_program.py +++ b/addons/loyalty/models/loyalty_program.py @@ -21,6 +21,9 @@ class LoyaltyProgram(models.Model): currency_id = fields.Many2one('res.currency', 'Currency', compute='_compute_currency_id', readonly=False, required=True, store=True, precompute=True) currency_symbol = fields.Char(related='currency_id.symbol') + pricelist_ids = fields.Many2many( + 'product.pricelist', string="Pricelist", domain="[('currency_id', '=', currency_id)]" + ) total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count") @@ -100,6 +103,17 @@ class LoyaltyProgram(models.Model): 'Max usage must be strictly positive if a limit is used.'), ] + @api.constrains('currency_id', 'pricelist_ids') + def _check_pricelist_currency(self): + if any( + pricelist.currency_id != program.currency_id + for program in self + for pricelist in program.pricelist_ids + ): + raise UserError(_( + "The loyalty program's currency must be the same as all it's pricelists ones." + )) + @api.constrains('date_from', 'date_to') def _check_date_from_date_to(self): if any(p.date_to and p.date_from and p.date_from > p.date_to for p in self): diff --git a/addons/loyalty/views/loyalty_program_views.xml b/addons/loyalty/views/loyalty_program_views.xml index 93d7dae857f..c75ea42ca4e 100644 --- a/addons/loyalty/views/loyalty_program_views.xml +++ b/addons/loyalty/views/loyalty_program_views.xml @@ -85,6 +85,7 @@ + diff --git a/addons/pos_loyalty/models/pos_config.py b/addons/pos_loyalty/models/pos_config.py index eaa67334f61..bb91c17b5f7 100644 --- a/addons/pos_loyalty/models/pos_config.py +++ b/addons/pos_loyalty/models/pos_config.py @@ -71,7 +71,7 @@ class PosConfig(models.Model): return super()._check_before_creating_new_session() - def use_coupon_code(self, code, creation_date, partner_id): + def use_coupon_code(self, code, creation_date, partner_id, pricelist_id): self.ensure_one() # Ordering by partner id to use the first assigned to the partner in case multiple coupons have the same code # it could happen with loyalty programs using a code @@ -79,7 +79,8 @@ class PosConfig(models.Model): coupon = self.env['loyalty.card'].search( [('program_id', 'in', self._get_program_ids().ids), ('partner_id', 'in', (False, partner_id)), ('code', '=', code)], order='partner_id, points desc', limit=1) - if not coupon or not coupon.program_id.active: + program = coupon.program_id + if not coupon or not program.active: return { 'successful': False, 'payload': { @@ -91,17 +92,19 @@ class PosConfig(models.Model): error_message = False if ( (coupon.expiration_date and coupon.expiration_date < check_date) - or (coupon.program_id.date_to and coupon.program_id.date_to < today_date) - or (coupon.program_id.limit_usage and coupon.program_id.total_order_count >= coupon.program_id.max_usage) + or (program.date_to and program.date_to < today_date) + or (program.limit_usage and program.total_order_count >= program.max_usage) ): error_message = _("This coupon is expired (%s).", code) - elif coupon.program_id.date_from and coupon.program_id.date_from > today_date: + elif program.date_from and program.date_from > today_date: error_message = _("This coupon is not yet valid (%s).", code) elif ( - not coupon.program_id.reward_ids or - not any(r.required_points <= coupon.points for r in coupon.program_id.reward_ids) + not program.reward_ids or + not any(r.required_points <= coupon.points for r in program.reward_ids) ): error_message = _("No reward can be claimed with this coupon.") + elif program.pricelist_ids and pricelist_id not in program.pricelist_ids.ids: + error_message = _("This coupon is not available with the current pricelist.") if error_message: return { @@ -114,7 +117,7 @@ class PosConfig(models.Model): return { 'successful': True, 'payload': { - 'program_id': coupon.program_id.id, + 'program_id': program.id, 'coupon_id': coupon.id, 'coupon_partner_id': coupon.partner_id.id, 'points': coupon.points, diff --git a/addons/pos_loyalty/models/pos_session.py b/addons/pos_loyalty/models/pos_session.py index 48bbf7603bc..f91d384012c 100644 --- a/addons/pos_loyalty/models/pos_session.py +++ b/addons/pos_loyalty/models/pos_session.py @@ -21,8 +21,11 @@ class PosSession(models.Model): return { 'search_params': { 'domain': [('id', 'in', self.config_id._get_program_ids().ids)], - 'fields': ['name', 'trigger', 'applies_on', 'program_type', 'date_from', 'date_to', - 'limit_usage', 'max_usage', 'is_nominative', 'portal_visible', 'portal_point_name', 'trigger_product_ids'], + 'fields': [ + 'name', 'trigger', 'applies_on', 'program_type', 'pricelist_ids', 'date_from', + 'date_to', 'limit_usage', 'max_usage', 'is_nominative', 'portal_visible', + 'portal_point_name', 'trigger_product_ids', + ], }, } diff --git a/addons/pos_loyalty/static/src/app/pos_store.js b/addons/pos_loyalty/static/src/app/pos_store.js index 3318ed81e33..a466f69af00 100644 --- a/addons/pos_loyalty/static/src/app/pos_store.js +++ b/addons/pos_loyalty/static/src/app/pos_store.js @@ -163,6 +163,11 @@ patch(PosStore.prototype, "pos_loyalty.PosStore", { const result = []; for (const couponProgram of allCouponPrograms) { const program = this.program_by_id[couponProgram.program_id]; + if (program.pricelist_ids.length > 0 + && (!order.pricelist || !program.pricelist_ids.includes(order.pricelist.id))) { + continue; + } + const points = order._getRealCouponPoints(couponProgram.coupon_id); const hasLine = order.orderlines.filter((line) => !line.is_reward_line).length > 0; for (const reward of program.rewards.filter( diff --git a/addons/pos_loyalty/static/src/js/Loyalty.js b/addons/pos_loyalty/static/src/js/Loyalty.js index 46e3d24d375..19b2472978c 100644 --- a/addons/pos_loyalty/static/src/js/Loyalty.js +++ b/addons/pos_loyalty/static/src/js/Loyalty.js @@ -301,7 +301,23 @@ patch(Order.prototype, "pos_loyalty.Order", { return orderLines[orderLines.length - 1]; }, set_pricelist(pricelist) { + const oldPricelist = this.pricelist this._super(...arguments); + if (this.couponPointChanges && oldPricelist !== pricelist) { + // Remove couponPointChanges for cards in no longer available programs. + // This makes sure that counting of points on loyalty and ewallet programs is updated after pricelist changes. + const loyaltyProgramIds = new Set( + this.pos.programs + .filter((program) => program.pricelist_ids.length > 0 + && (!pricelist || !program.pricelist_ids.includes(pricelist.id))) + .map((program) => program.id) + ); + for (const [key, pointChange] of Object.entries(this.couponPointChanges)) { + if (loyaltyProgramIds.has(pointChange.program_id)) { + delete this.couponPointChanges[key]; + } + } + } this._updateRewards(); }, set_orderline_options(line, options) { @@ -701,6 +717,9 @@ patch(Order.prototype, "pos_loyalty.Order", { if (program.limit_usage && program.total_order_count >= program.max_usage) { return false; } + if (program.pricelist_ids.length > 0 && (!this.pricelist || !program.pricelist_ids.includes(this.pricelist.id))) { + return false; + } return true; }, /** @@ -929,6 +948,10 @@ patch(Order.prototype, "pos_loyalty.Order", { : 0; for (const couponProgram of allCouponPrograms) { const program = this.pos.program_by_id[couponProgram.program_id]; + if (program.pricelist_ids.length > 0 + && (!this.pricelist || !program.pricelist_ids.includes(this.pricelist.id))) { + continue; + } if (program.trigger == "with_code") { // For coupon programs, the rules become conditions. // Points to purchase rewards will only come from the scanned coupon. @@ -1471,6 +1494,11 @@ patch(Order.prototype, "pos_loyalty.Order", { let claimableRewards = null; let coupon = null; if (rule) { + let program_pricelists = rule.program_id.pricelist_ids; + if (program_pricelists.length > 0 + && (!this.pricelist || !program_pricelists.includes(this.pricelist.id))) { + return _t("That promo code program requires a specific pricelist."); + } if (this.codeActivatedProgramRules.includes(rule.id)) { return _t("That promo code program has already been activated."); } @@ -1481,11 +1509,11 @@ patch(Order.prototype, "pos_loyalty.Order", { if (this.codeActivatedCoupons.find((coupon) => coupon.code === code)) { return _t("That coupon code has already been scanned and activated."); } - const customer = this.get_partner(); + const customerId = this.get_partner() ? this.get_partner().id : false; const { successful, payload } = await this.env.services.orm.call( "pos.config", "use_coupon_code", - [[this.pos.config.id], code, this.creation_date, customer ? customer.id : false] + [[this.pos.config.id], code, this.creation_date, customerId, this.pricelist ? this.pricelist.id : false] ); if (successful) { // Allow rejecting a gift card that is not yet paid. diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py index f5e0407f902..40a2efccc59 100644 --- a/addons/sale_loyalty/models/sale_order.py +++ b/addons/sale_loyalty/models/sale_order.py @@ -386,6 +386,7 @@ class SaleOrder(models.Model): today = fields.Date.context_today(self) return [('active', '=', True), ('sale_ok', '=', True), ('company_id', 'in', (self.company_id.id, False)), + '|', ('pricelist_ids', '=', False), ('pricelist_ids', 'in', [self.pricelist_id.id]), '|', ('date_from', '=', False), ('date_from', '<=', today), '|', ('date_to', '=', False), ('date_to', '>=', today)] @@ -397,6 +398,8 @@ class SaleOrder(models.Model): today = fields.Date.context_today(self) return [('active', '=', True), ('program_id.sale_ok', '=', True), ('company_id', 'in', (self.company_id.id, False)), + '|', ('program_id.pricelist_ids', '=', False), + ('program_id.pricelist_ids', 'in', [self.pricelist_id.id]), '|', ('program_id.date_from', '=', False), ('program_id.date_from', '<=', today), '|', ('program_id.date_to', '=', False), ('program_id.date_to', '>=', today)] diff --git a/addons/sale_loyalty/tests/test_loyalty.py b/addons/sale_loyalty/tests/test_loyalty.py index a93cca75308..8ece9035efb 100644 --- a/addons/sale_loyalty/tests/test_loyalty.py +++ b/addons/sale_loyalty/tests/test_loyalty.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.tests import tagged, new_test_user -from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon -from odoo.tools.float_utils import float_compare from odoo import Command +from odoo.exceptions import ValidationError +from odoo.tests import tagged, new_test_user +from odoo.tools.float_utils import float_compare + +from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon + @tagged('post_install', '-at_install') class TestLoyalty(TestSaleCouponCommon): @@ -339,3 +342,133 @@ class TestLoyalty(TestSaleCouponCommon): order._update_programs_and_rewards() self._claim_reward(order, coupon_program) self.assertEqual(float_compare(order.amount_total, 218.7, precision_rounding=3), 0, "300 * 0.9 * 0.9 * 0.9 = 218.7") + + def test_promotion_program_restricted_to_pricelists(self): + self.env['product.pricelist'].search([]).action_archive() + company_currency = self.env.company.currency_id + pricelist_1, pricelist_2 = self.env['product.pricelist'].create([ + {'name': 'Basic company_currency pricelist', 'currency_id': company_currency.id}, + {'name': 'Other company_currency pricelist', 'currency_id': company_currency.id}, + ]) + self.immediate_promotion_program.active = True + order = self.empty_order.copy() + order.write({'order_line': [ + (0, False, { + 'product_id': self.product_A.id, + 'name': '1 Product A', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }), + (0, False, { + 'product_id': self.product_B.id, + 'name': '2 Product B', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }), + ]}) + + applied_message = "The promo offer should have been applied." + not_applied_message = "The promo offer should not have been applied because the order's " \ + "pricelist is not eligible to this promotion." + + order.pricelist_id = self.env['product.pricelist'] + order._update_programs_and_rewards() + self._claim_reward(order, self.immediate_promotion_program) + self.assertEqual(len(order.order_line.ids), 3, applied_message) + + order.pricelist_id = pricelist_1 + order._update_programs_and_rewards() + self._claim_reward(order, self.immediate_promotion_program) + self.assertEqual(len(order.order_line.ids), 3, applied_message) + + self.immediate_promotion_program.pricelist_ids = [pricelist_1.id] + order.pricelist_id = self.env['product.pricelist'] + order._update_programs_and_rewards() + self._claim_reward(order, self.immediate_promotion_program) + self.assertEqual(len(order.order_line.ids), 2, not_applied_message) + + order.pricelist_id = pricelist_1 + order._update_programs_and_rewards() + self._claim_reward(order, self.immediate_promotion_program) + self.assertEqual(len(order.order_line.ids), 3, applied_message) + + order.pricelist_id = pricelist_2 + order._update_programs_and_rewards() + self._claim_reward(order, self.immediate_promotion_program) + self.assertEqual(len(order.order_line.ids), 2, not_applied_message) + + self.immediate_promotion_program.pricelist_ids = [pricelist_1.id, pricelist_2.id] + order.pricelist_id = self.env['product.pricelist'] + order._update_programs_and_rewards() + self._claim_reward(order, self.immediate_promotion_program) + self.assertEqual(len(order.order_line.ids), 2, not_applied_message) + + order.pricelist_id = pricelist_1 + order._update_programs_and_rewards() + self._claim_reward(order, self.immediate_promotion_program) + self.assertEqual(len(order.order_line.ids), 3, applied_message) + + def test_coupon_program_restricted_to_pricelists(self): + self.env['product.pricelist'].search([]).action_archive() + company_currency = self.env.company.currency_id + pricelist_1, pricelist_2 = self.env['product.pricelist'].create([ + {'name': 'Basic company_currency pricelist', 'currency_id': company_currency.id}, + {'name': 'Other company_currency pricelist', 'currency_id': company_currency.id}, + ]) + + self.code_promotion_program.active = True + self.env['loyalty.generate.wizard'].with_context( + active_id=self.code_promotion_program.id + ).create({'coupon_qty': 7, 'points_granted': 1}).generate_coupons() + coupons = self.code_promotion_program.coupon_ids + + order_no_pricelist = self.empty_order.copy() + order_no_pricelist.write({'pricelist_id': None, 'order_line': [ + (0, False, { + 'product_id': self.product_A.id, + 'name': '1 Product A', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }), + ]}) + order_pricelist_1 = order_no_pricelist.copy() + order_pricelist_1.pricelist_id = pricelist_1 + order_pricelist_2 = order_no_pricelist.copy() + order_pricelist_2.pricelist_id = pricelist_2 + + applied_message = "The coupon code should have been applied." + not_applied_message = "The coupon code should not have been applied because the order's " \ + "pricelist is not eligible to this promotion." + + order_0 = order_no_pricelist.copy() + self._apply_promo_code(order_0, coupons[0].code) + self.assertEqual(len(order_0.order_line.ids), 2, applied_message) + + order_1 = order_pricelist_1.copy() + self._apply_promo_code(order_1, coupons[1].code) + self.assertEqual(len(order_1.order_line.ids), 2, applied_message) + + self.code_promotion_program.pricelist_ids = [pricelist_1.id] + order_2 = order_no_pricelist.copy() + with self.assertRaises(ValidationError): + self._apply_promo_code(order_2, coupons[2].code) + self.assertEqual(len(order_2.order_line.ids), 1, not_applied_message) + + order_3 = order_pricelist_1.copy() + self._apply_promo_code(order_3, coupons[3].code) + self.assertEqual(len(order_3.order_line.ids), 2, applied_message) + + order_4 = order_pricelist_2.copy() + with self.assertRaises(ValidationError): + self._apply_promo_code(order_4, coupons[4].code) + self.assertEqual(len(order_4.order_line.ids), 1, not_applied_message) + + self.code_promotion_program.pricelist_ids = [pricelist_1.id, pricelist_2.id] + order_5 = order_no_pricelist.copy() + with self.assertRaises(ValidationError): + self._apply_promo_code(order_5, coupons[5].code) + self.assertEqual(len(order_5.order_line.ids), 1, not_applied_message) + + order_6 = order_pricelist_1.copy() + self._apply_promo_code(order_6, coupons[6].code) + self.assertEqual(len(order_6.order_line.ids), 2, applied_message)