[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:
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user