From 90f16d097913e7ddf9ef629cc6de7a91e04683f9 Mon Sep 17 00:00:00 2001 From: "Louis (loti)" Date: Thu, 22 Feb 2024 17:19:15 +0100 Subject: [PATCH] [FIX] *_loyalty: consider product domains for min amount to reach Product domains were not considered when computing the min amount to reach to get a discount. For example, if you created a discount with a minimum amount of 100 for product "X", the discount would have been applied, even if the amount for "X" was less than 100, as long as the total amount of the SO was at least 100. After this fix, only products that match the domain will be considered when computing the amount that should be compared to the discount's min amount. opw-3626345 closes odoo/odoo#155085 closes odoo/odoo#162020 Signed-off-by: Victor Feyens (vfe) Signed-off-by: Louis Tinel (loti) --- .../static/src/overrides/models/loyalty.js | 49 ++++++---- .../static/tests/tours/PosLoyaltyTour.js | 22 +++++ addons/pos_loyalty/tests/test_frontend.py | 51 ++++++++++- addons/sale_loyalty/models/sale_order.py | 45 ++++----- .../sale_loyalty/tests/test_program_rules.py | 91 ++++++++++++++++++- 5 files changed, 212 insertions(+), 46 deletions(-) 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)