diff --git a/addons/pos_loyalty/static/src/overrides/models/loyalty.js b/addons/pos_loyalty/static/src/overrides/models/loyalty.js index ca8795d9450..82baa505e49 100644 --- a/addons/pos_loyalty/static/src/overrides/models/loyalty.js +++ b/addons/pos_loyalty/static/src/overrides/models/loyalty.js @@ -721,26 +721,31 @@ patch(Order.prototype, { */ pointsForPrograms(programs) { pointsForProgramsCountedRules = {}; - const totalTaxed = this.get_total_with_tax(); - const totalUntaxed = this.get_total_without_tax(); - const totalsPerProgram = Object.fromEntries( - programs.map((program) => [program.id, { untaxed: totalUntaxed, taxed: totalTaxed }]) - ); const orderLines = this.get_orderlines(); + const linesPerRule = {}; for (const line of orderLines) { - if (!line.reward_id) { + const reward = line.reward_id + ? this.pos.reward_by_id[line.reward_id] + : undefined; + const isDiscount = reward && reward.reward_type === "discount"; + const rewardProgram = reward && reward.program_id; + // Skip lines for automatic discounts. + if (isDiscount && rewardProgram.trigger === "auto") { continue; } - const reward = this.pos.reward_by_id[line.reward_id]; - if (reward.reward_type !== "discount") { - continue; - } - const rewardProgram = reward.program_id; for (const program of programs) { - // Remove automatic discount and this program's discounts from the totals. - if (program.id === rewardProgram.id || rewardProgram.trigger === "auto") { - totalsPerProgram[program.id]["taxed"] -= line.get_price_with_tax(); - totalsPerProgram[program.id]["untaxed"] -= line.get_price_without_tax(); + // Skip lines for the current program's discounts. + if (isDiscount && rewardProgram.id === program.id) { + continue; + } + for (const rule of program.rules) { + // Skip lines to which the rule doesn't apply. + if (rule.any_product || rule.valid_product_ids.has(line.get_product().id)) { + if (!linesPerRule[rule.id]) { + linesPerRule[rule.id] = []; + } + linesPerRule[rule.id].push(line); + } } } } @@ -755,12 +760,16 @@ patch(Order.prototype, { ) { continue; } - const amountCheck = - (rule.minimum_amount_tax_mode === "incl" && - totalsPerProgram[program.id]["taxed"]) || - totalsPerProgram[program.id]["untaxed"]; + const linesForRule = linesPerRule[rule.id] ? linesPerRule[rule.id] : []; + const amountWithTax = linesForRule.reduce( + (sum, line) => sum + line.get_price_with_tax(), 0 + ); + const amountWithoutTax = linesForRule.reduce( + (sum, line) => sum + line.get_price_without_tax(), 0 + ); + const amountCheck = (rule.minimum_amount_tax_mode === 'incl' && amountWithTax) + || amountWithoutTax; if (rule.minimum_amount > amountCheck) { - // NOTE: big doutes par rapport au fait de compter tous les produits continue; } let totalProductQty = 0; diff --git a/addons/pos_loyalty/static/tests/tours/PosLoyaltyTour.js b/addons/pos_loyalty/static/tests/tours/PosLoyaltyTour.js index cfdba9dd6e9..3a190d846fa 100644 --- a/addons/pos_loyalty/static/tests/tours/PosLoyaltyTour.js +++ b/addons/pos_loyalty/static/tests/tours/PosLoyaltyTour.js @@ -406,3 +406,25 @@ registry.category("web_tour.tours").add('PosLoyaltyTour12', { PosLoyalty.hasRewardLine('Free Product', '-3.00'), ].flat(), }); + +registry.category("web_tour.tours").add('PosLoyaltyMinAmountAndSpecificProductTour', { + test: true, + url: '/pos/web', + steps: () => + [ + ProductScreen.clickHomeCategory(), + ProductScreen.confirmOpeningPopup(), + + ProductScreen.clickDisplayedProduct('Product A'), + ProductScreen.selectedOrderlineHas('Product A', '1.00', '20.00'), + PosLoyalty.orderTotalIs('20.00'), + + ProductScreen.clickDisplayedProduct('Product B'), + ProductScreen.selectedOrderlineHas('Product B', '1.00', '30.00'), + PosLoyalty.orderTotalIs('50.00'), + + ProductScreen.clickDisplayedProduct('Product A'), + ProductScreen.selectedOrderlineHas('Product A', '2.00', '40.00'), + PosLoyalty.orderTotalIs('66.00'), + ].flat(), +}); diff --git a/addons/pos_loyalty/tests/test_frontend.py b/addons/pos_loyalty/tests/test_frontend.py index de7e4f51713..b3168cab269 100644 --- a/addons/pos_loyalty/tests/test_frontend.py +++ b/addons/pos_loyalty/tests/test_frontend.py @@ -1,11 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import date, timedelta -from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon -from odoo.tests import tagged from odoo import Command +from odoo.tests import tagged + +from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon @tagged("post_install", "-at_install") @@ -1631,3 +1631,48 @@ class TestUi(TestPointOfSaleHttpCommon): "PosLoyaltyTour12", login="pos_user", ) + + def test_promotion_with_min_amount_and_specific_product_rule(self): + """ + Test that the discount is applied iff the min amount is reached for the specified product. + """ + self.env['loyalty.program'].search([]).action_archive() + self.product_a = self.env['product.product'].create({ + 'name': "Product A", + 'type': 'product', + 'list_price': 20, + 'available_in_pos': True, + 'taxes_id': False, + }) + self.env['product.product'].create({ + 'name': "Product B", + 'type': 'product', + 'list_price': 30, + 'available_in_pos': True, + 'taxes_id': False, + }) + self.env['loyalty.program'].create({ + 'name': "Discount on specific products", + 'program_type': 'promotion', + 'trigger': 'auto', + 'applies_on': 'current', + 'rule_ids': [Command.create({ + 'minimum_amount': 40, + 'product_ids': [Command.set(self.product_a.ids)], + })], + 'reward_ids': [Command.create({ + 'reward_type': 'discount', + 'discount': 10, + 'discount_mode': 'percent', + 'discount_applicability': 'specific', + 'discount_product_ids': [Command.set(self.product_a.ids)], + })], + 'pos_config_ids': [Command.link(self.main_pos_config.id)], + }) + + self.main_pos_config.open_ui() + self.start_tour( + '/pos/web?config_id=%d' % self.main_pos_config.id, + 'PosLoyaltyMinAmountAndSpecificProductTour', + login='pos_user', + ) diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py index 5d1358134a8..7357001f83d 100644 --- a/addons/sale_loyalty/models/sale_order.py +++ b/addons/sale_loyalty/models/sale_order.py @@ -1,17 +1,16 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import itertools +import random + from collections import defaultdict -import itertools - -import random - -from odoo import api, fields, models, _ +from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.fields import Command -from odoo.tools.float_utils import float_is_zero, float_round from odoo.osv import expression +from odoo.tools.float_utils import float_is_zero, float_round + def _generate_random_reward_code(): return str(random.getrandbits(32)) @@ -138,7 +137,7 @@ class SaleOrder(models.Model): reward_products = reward.reward_product_ids product = product or reward_products[:1] - if not product or not product in reward_products: + if not product or product not in reward_products: raise UserError(_('Invalid product to claim.')) taxes = self.fiscal_position_id.map_tax(product.taxes_id.filtered(lambda t: t.company_id == self.company_id)) points = self._get_real_points_for_coupon(coupon) @@ -853,24 +852,26 @@ class SaleOrder(models.Model): products_per_rule = programs._get_valid_products(products) # Prepare amounts - no_effect_lines = self._get_no_effect_on_threshold_lines() - base_untaxed_amount = self.amount_untaxed - sum(line.price_subtotal for line in no_effect_lines) - base_tax_amount = self.amount_tax - sum(line.price_tax for line in no_effect_lines) - amounts_per_program = {p: {'untaxed': base_untaxed_amount, 'tax': base_tax_amount} for p in programs} - for line in self.order_line: - if not line.reward_id or line.reward_id.reward_type != 'discount': + so_products_per_rule = programs._get_valid_products(self.order_line.product_id) + lines_per_rule = defaultdict(lambda: self.env['sale.order.line']) + # Skip lines that have no effect on the minimum amount to reach. + for line in self.order_line - self._get_no_effect_on_threshold_lines(): + is_discount = line.reward_id.reward_type == 'discount' + reward_program = line.reward_id.program_id + # Skip lines for automatic discounts. + if is_discount and reward_program.trigger == 'auto': continue for program in programs: - # Do not consider the program's discount + automatic discount lines for the amount to check. - if line.reward_id.program_id.trigger == 'auto' or line.reward_id.program_id == program: - amounts_per_program[program]['untaxed'] -= line.price_subtotal - amounts_per_program[program]['tax'] -= line.price_tax + # Skip lines for the current program's discounts. + if is_discount and reward_program == program: + continue + for rule in program.rule_ids: + # Skip lines to which the rule doesn't apply. + if line.product_id in so_products_per_rule.get(rule, []): + lines_per_rule[rule] |= line result = {} for program in programs: - untaxed_amount = amounts_per_program[program]['untaxed'] - tax_amount = amounts_per_program[program]['tax'] - # Used for error messages # By default False, but True if no rules and applies_on current -> misconfigured coupons program code_matched = not bool(program.rule_ids) and program.applies_on == 'current' # Stays false if all triggers have code and none have been activated @@ -889,6 +890,8 @@ class SaleOrder(models.Model): continue code_matched = True rule_amount = rule._compute_amount(self.currency_id) + untaxed_amount = sum(lines_per_rule[rule].mapped('price_subtotal')) + tax_amount = sum(lines_per_rule[rule].mapped('price_tax')) if rule_amount > (rule.minimum_amount_tax_mode == 'incl' and (untaxed_amount + tax_amount) or untaxed_amount): continue minimum_amount_matched = True diff --git a/addons/sale_loyalty/tests/test_program_rules.py b/addons/sale_loyalty/tests/test_program_rules.py index 72de181c7df..b77f38173b3 100644 --- a/addons/sale_loyalty/tests/test_program_rules.py +++ b/addons/sale_loyalty/tests/test_program_rules.py @@ -1,13 +1,14 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import date, timedelta from freezegun import freeze_time from odoo import Command -from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon from odoo.exceptions import ValidationError +from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon + + class TestProgramRules(TestSaleCouponCommon): # Test all the validity rules to allow a customer to have a reward. # The check based on the products is already done in the basic operations test @@ -16,6 +17,7 @@ class TestProgramRules(TestSaleCouponCommon): # Test case: Based on the minimum purchased self.immediate_promotion_program.rule_ids.write({ + 'product_ids': False, 'minimum_amount': 1006, 'minimum_amount_tax_mode': 'excl' }) @@ -71,6 +73,91 @@ class TestProgramRules(TestSaleCouponCommon): self._claim_reward(order, self.immediate_promotion_program) self.assertEqual(len(order.order_line.ids), 3, "The promo offer should be applied as the initial amount required is now tax included") + def test_program_rules_min_amount_not_reached_and_specific_product(self): + """ + Test that the discount isn't applied if the min amount isn't reached for the specified + product. + """ + self.env['loyalty.program'].search([]).active = False + order = self.empty_order + program = self.env['loyalty.program'].create({ + 'name': "Discount on Product A", + 'program_type': 'promotion', + 'trigger': 'auto', + 'applies_on': 'current', + 'rule_ids': [Command.create({ + 'minimum_amount': 110, + 'minimum_amount_tax_mode': 'excl', + 'product_ids': [Command.set(self.product_A.ids)], + })], + 'reward_ids': [Command.create({ + 'reward_type': 'discount', + 'discount': 10, + 'discount_mode': 'percent', + 'discount_applicability': 'specific', + 'discount_product_ids': [Command.set(self.product_A.ids)], + })], + }) + self.env['sale.order.line'].create([{ + 'product_id': self.product_A.id, + 'product_uom_qty': 1.0, + 'order_id': order.id, + }, { + 'product_id': self.product_B.id, + 'product_uom_qty': 40.0, + 'order_id': order.id, + }]) + self.assertEqual(len(order.order_line), 2) + self.assertEqual(order.amount_untaxed, 300) + + order._update_programs_and_rewards() + self._claim_reward(order, program) + + self.assertEqual(len(order.order_line), 2) + self.assertEqual(order.amount_untaxed, 300) + + def test_program_rules_min_amount_reached_and_specific_product(self): + """ + Test that the discount is applied if the min amount is reached for the specified product. + """ + self.env['loyalty.program'].search([]).active = False + order = self.empty_order + program = self.env['loyalty.program'].create({ + 'name': "Discount on Product A", + 'program_type': 'promotion', + 'trigger': 'auto', + 'applies_on': 'current', + 'rule_ids': [Command.create({ + 'minimum_amount': 110, + 'minimum_amount_tax_mode': 'excl', + 'product_ids': [Command.set(self.product_A.ids)], + })], + 'reward_ids': [Command.create({ + 'reward_type': 'discount', + 'discount': 10, + 'discount_mode': 'percent', + 'discount_applicability': 'specific', + 'discount_product_ids': [Command.set(self.product_A.ids)], + })], + }) + self.env['sale.order.line'].create([{ + 'product_id': self.product_A.id, + 'product_uom_qty': 2.0, + 'order_id': order.id, + }, { + 'product_id': self.product_B.id, + 'product_uom_qty': 20.0, + 'order_id': order.id, + }]) + self.assertEqual(len(order.order_line), 2) + self.assertEqual(order.amount_untaxed, 300) + + order._update_programs_and_rewards() + self._claim_reward(order, program) + + self.assertEqual(len(order.order_line), 3) + self.assertEqual(order.amount_untaxed, 280) + def test_program_rules_coupon_qty_and_amount_remove_not_eligible(self): ''' This test will: * Check quantity and amount requirements works as expected (since it's slightly different from a promotion_program)