[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) <vfe@odoo.com>
Signed-off-by: Louis Tinel (loti) <loti@odoo.com>
This commit is contained in:
Louis (loti)
2024-04-16 10:32:32 +00:00
parent 994aef18c5
commit 90f16d0979
5 changed files with 212 additions and 46 deletions
@@ -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;
@@ -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(),
});
+48 -3
View File
@@ -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',
)
+24 -21
View File
@@ -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
@@ -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)