From ef5d056403f11b48e42c1c2b63a2cb061e1c8ae9 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Mon, 18 Dec 2017 18:06:50 +0100 Subject: [PATCH 1/5] [FIX] sale_coupon: fix some use cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some use cases were wrong, this commit attempt to fix those. Here is the most important ones: 1. Discount was offered on free product 'value' aswell Cart: 4 iPad Mini + one free offered + 10% discount | qty | unit value | line total | ---------------------------------------------------- iPad Mini | 4 | 320 | 1280 | - 10% discount| 1 | 128 | 128 | = 1152 - free product| 1 | 320 | 320 | = 832 Should be | qty | unit value | line total | ---------------------------------------------------- iPad Mini | 4 | 320 | 1280 | - free product| 1 | 320 | 320 | = 960 - 10% discount| 1 | 96 | 96 | = 864 The error result is $32 which is the 10% discount applied on the free iPad. 2. Decreasing product quantity below the minimum threshold to be elligible for the promotion doesn't remove the product - (With a promotion offering a free iPad when you buy 3 (fourth is free)) - Create a quotation with 3 iPads Mini, save and update promotions, you don't have a free one, which is 'correct' - Edit, change the quantity to 4 iPads, save, update promotions, you have a free iPad (reduced from total), still correct - Edit, change the quantity back to 3 ipad, update ptomotions, you still have a free iPad even if you only bought 3 of them (the way it is designed, the free iPad should only appears when you order your fourth). The error result in a 'loss' of $320 since at the end we are offering a free iPad and the user only pays 2 instead of 3. 3. Free product will be considered as regular product when updating quantity of regular product afterward This error is kind of exponential: the more you play with the flow explained bellow, the more incorrect it becomes. Buy 100 iPads, get 25 free iPads. Then set quantity (on regular iPad) to 50, it will recalculate the amount of free iPad thinking the 25 previous free iPads are regular iPad. (50 + 25) = 75 regular iPads = 18 free iPads (25 previous free iPads were wrongly used for the calculation but they are simply gone at this point). Cart is now 50 regular iPads and 18 free one, total: $10240 (50×320 - 18×320). It should be 12 free iPads, not 18, so $12160 (50×320 − 12×320) The error result in a loss of $1920: ~ 20% of the total amount of the order. This error will also appear the other way around if you add quantity instead of removing some. Playing with this error multiple times, you can arrive to a negative total (more free than paid products), 0 total or very low total ($320 for 100 iPads). 4. Free product are wrongly calculated, user get too many of them - Create a quotation with 10 iPads, you get 3 free iPads instead of 2 - Indeed, your first 4 iPads should give you a free one - Your next 4 iPads should give you a second one (you got 8) - The last 2 (to reach 10) are not enough to give you a third free iPad The error result in a loss of $320 out of a total of 2560$ which is ~ 15% 5. Paid/regular products related to a promotion won't be taken into account when checking if you have the minimum amount threshold to apply the promotion Promotion: 3 Computer Case(25$) = 1 Free Little Server (40.000$), auto applied -Have a cart with 3 'Computer Case' and 2 'Little Server' -One little server of the 2 is free (because you have 3 computer cases) -You so pay 3x25 + 1x40000 which is 40075$ -Create a promo code that will give you 10% discount if you bought over $2000 -You won't get the reduction even if you bought over $40.000 because the little server you pay is considered as the product of the reward product even if you dont have it free. It doesn't check how many regular product should be substracted according to the number of free product you have 6. Wrong product is checked for the required quantity to apply the promotion Promotion: 3 iPads Mini ($320) = 1 free Bose Mini Bluetooth Speaker ($247) - Add 8 iPads Mini and a Bose Mini Bluetooth Speaker to your cart - The system should give you a free Bose Mini Bluetooth Speaker since you only have one regular Bose - But the system is giving you 2 free Bose Mini Bluetooth Speaker Cart is: 8 iPad ($2560), 1 Bose ($247), 2 free Bose (-$496) The system think that it should give you 2 free Bose because you are eligible to, since you have 8 iPad, but it doesn't check that you have enough regular Bose. The error result in a loss of $247 out of a total of $2311 7. Auto applying promotion are not applied if there is already a promotion code Promotion: - Free iPad Mini if you buy 3 (so the fourth become free), auto applied - 10% discount with code '10pc' - Add 4 iPads Mini, you got a free one - Set quantity back to 1 iPad, free one is gone - Add code 10pc, you got a 10% reduction line - Set quantity to 4 iPads again, you dont have the free iPad since there is already a promo code This could make sense but it is not coherent since the 2 promotions would be applied if they were both on auto apply mode. Note: - If both promotions are on auto apply, you will get both - If 10% discount becomes auto applied and iPad promotion now has a code (reverse situation) it will work 8. Discount is only applied on untaxed amount, not on tax Discount will ONLY be calculated on the untaxed amount and then reduced from the sale order/cart total. The taxes would be unchanged and charged like the product was full price. Let's illustrate with a $100 product, a 15% tax and a 20% discount: 1. Tax included: $100 : Product with 15% included taxes $86.96 : Untaxed product $13.04 : Taxes After aplying 20% discount, result should be: $80 : Product with 15% included taxes $69.57 : Untaxed product $10.43 : Taxes Instead we have: $82.61 : Product with 15% included taxes $69.57 : Untaxed product $13.04 : Taxes There is a difference of $2.61 which is the result of the taxes not being discounted: 13.04-10.43 = 2.61 2. Tax excluded: $115 : Product with 15% excluded taxes $100 : Untaxed product $15 : Taxes After aplying 20% discount, result should be: $92 : Product with 15% included taxes $80 : Untaxed product $12 : Taxes Instead we have: $95 : Product with 15% included taxes $80 : Untaxed product $15 : Taxes There is a difference of $3 which is the result of the taxes not being discounted: 15-12 = 3 9. Taxes of the free shipping reward would be considered as paid value when checking if required threshold is met. ------------------------------------------ Not really a bug, more an improvement: 1. The way it was designed, the rule_minimum_amount would check if your order amount was over the required threshold to be eligible for the reward. 'Problem' is that it your order has already a discount (eg: 10%), it would check if the price without discount is above the required amount. I don't think it is coherent, let's illustrate: - First pomotion (auto applied): 1 iPad Mini for free if you buy over $2000 - Second promotion (auto applied): 50% on order If an user is buying for $2000, he is actually paying $1000 thanks to the 50% discount. Should we give him the free iPad? The current implementation is giving it, which is not convenient IMHO. Now, we use the amount the user really pay to check if he is elligible. 2. Since new promotions have the same sequence by default, they will be sorted and applied by their creation order. This will result in inconsistent behavior: - Promotion 1 : 10% discount - Promotion 2 : free shipping - Both promotion are auto applied and need a minimum of $872.73 tax excluded Let's illustrate with a $873 order: If the promotions were created in the order above, you won't get both rewards since the 10% discount will be applied first and will lower the SO total. The order will then fall below the $872.73 required amount and won't be eligible for the free shipping. But if you created the free shipping promotion before the discount one, you will be eligible for both since the free shipping will be applied first and won't change the SO total. To keep the same behavior independently of the promotion creation order, we now sort by reward_type ('discount' > 'free_shipping' > 'product' to apply discounts first. This is better IMHO, since you want to give promotions with a minimum threshold ONLY if the user really pays that amount. It wouldn't be the case if the total is checked before the discount is applied. --- models/sale_coupon.py | 5 +---- models/sale_coupon_program.py | 24 +++++++++++--------- models/sale_order.py | 41 +++++++++++++++++++++++------------ 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/models/sale_coupon.py b/models/sale_coupon.py index 10bdc6f97d5..e60873df677 100644 --- a/models/sale_coupon.py +++ b/models/sale_coupon.py @@ -52,15 +52,12 @@ class SaleCoupon(models.Model): def _check_coupon_code(self, order): message = {} applicable_programs = order._get_applicable_programs() - amount_total = order.amount_untaxed + order.reward_amount - if self.program_id.rule_minimum_amount_tax_inclusion == 'tax_included': - amount_total += order.amount_tax if self.state in ('used', 'expired') or \ (self.expiration_date and self.expiration_date < order.date_order): message = {'error': _('This coupon %s has been used or is expired.') % (self.code)} elif self.state == 'reserved': message = {'error': _('This coupon %s exists but the origin sales order is not validated yet.') % (self.code)} - elif self.program_id._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total: + elif not self.program_id._filter_on_mimimum_amount(order): message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.program_id.rule_minimum_amount, self.program_id.currency_id.name)} elif not self.program_id.active: message = {'error': _('The coupon program for %s is in draft or closed state') % (self.code)} diff --git a/models/sale_coupon_program.py b/models/sale_coupon_program.py index b6d43713cec..6c487f7f281 100644 --- a/models/sale_coupon_program.py +++ b/models/sale_coupon_program.py @@ -10,7 +10,10 @@ class SaleCouponProgram(models.Model): _name = 'sale.coupon.program' _description = "Sales Coupon Program" _inherits = {'sale.coupon.rule': 'rule_id', 'sale.coupon.reward': 'reward_id'} - _order = "sequence" + # We should apply 'discount' promotion first to avoid offering free product when we should not. + # Eg: If the discount lower the SO total below the required threshold + # Note: This is only revelant when programs have the same sequence (which they have by default) + _order = "sequence, reward_type" name = fields.Char(required=True, translate=True) active = fields.Boolean('Active', default=True, help="A program is available for the customers when active") @@ -129,12 +132,9 @@ class SaleCouponProgram(models.Model): def _check_promo_code(self, order, coupon_code): message = {} applicable_programs = order._get_applicable_programs() - amount_total = order.amount_untaxed + order.reward_amount - if self.rule_minimum_amount_tax_inclusion == 'tax_included': - amount_total += order.amount_tax if self.maximum_use_number != 0 and self.order_count >= self.maximum_use_number: message = {'error': _('Promo code %s has been expired.') % (coupon_code)} - elif self._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total: + elif not self._filter_on_mimimum_amount(order): message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.rule_minimum_amount, self.currency_id.name)} elif self.promo_code and self.promo_code == order.promo_code: message = {'error': _('The promo code is already applied on this order')} @@ -144,7 +144,7 @@ class SaleCouponProgram(models.Model): message = {'error': _('Promo code is invalid')} elif self.rule_date_from and self.rule_date_from > order.date_order or self.rule_date_to and order.date_order > self.rule_date_to: message = {'error': _('Promo code is expired')} - elif order.promo_code: + elif order.promo_code and self.promo_code_usage == 'code_needed': message = {'error': _('Promotionals codes are not cumulable.')} elif self._is_global_discount_program() and order.applied_coupon_ids.mapped('program_id').filtered(lambda program: program._is_global_discount_program()): message = {'error': _('Global discounts are not cumulable.')} @@ -165,12 +165,16 @@ class SaleCouponProgram(models.Model): @api.model def _filter_on_mimimum_amount(self, order): - # To get actual total amount of SO without any reward or discount - discounted_amount = sum(order.order_line.filtered(lambda line: line.is_reward_line).mapped('price_total')) - untaxed_amount = order.amount_untaxed - discounted_amount + untaxed_amount = order.amount_untaxed + tax_amount = order.amount_tax + + # Some lines should not be considered when checking if threshold is met like delivery + untaxed_amount -= sum([line.price_subtotal for line in order._get_no_effect_on_threshold_lines()]) + tax_amount -= sum([line.price_tax for line in order._get_no_effect_on_threshold_lines()]) + return self.filtered(lambda program: program.rule_minimum_amount_tax_inclusion == 'tax_included' and - program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount + order.amount_tax or + program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount + tax_amount or program.rule_minimum_amount_tax_inclusion == 'tax_excluded' and program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount) diff --git a/models/sale_order.py b/models/sale_order.py index 9ce09c452e2..12876e46c4c 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ class SaleOrder(models.Model): @@ -19,7 +19,15 @@ class SaleOrder(models.Model): @api.depends('order_line') def _compute_reward_total(self): for order in self: - order.reward_amount = sum([line.price_subtotal for line in order.order_line.filtered(lambda line: line.is_reward_line)]) + order.reward_amount = sum([line.price_subtotal for line in order._get_reward_lines()]) + + def _get_no_effect_on_threshold_lines(self): + self.ensure_one() + lines = self.env['sale.order.line'] + # Do not count already applied promo_code discount; Do not substract itself + if self.code_promo_program_id and self.code_promo_program_id.reward_type == 'discount': + lines = self.order_line.filtered(lambda l: l.product_id == self.code_promo_program_id.discount_line_product_id) + return lines @api.multi def recompute_coupon_lines(self): @@ -30,7 +38,7 @@ class SaleOrder(models.Model): def copy(self, default=None): order = super(SaleOrder, self).copy(dict(default or {}, order_line=False)) - for line in self.order_line.filtered(lambda line: not line.is_reward_line): + for line in self._get_reward_lines(): line.copy({'order_id': order.id}) order.with_context(sale_coupon_no_loop=False)._create_new_no_code_promo_reward_lines() return order @@ -72,7 +80,7 @@ class SaleOrder(models.Model): if program._is_valid_product(program.reward_product_id): reward_product_qty = max_product_qty // (program.rule_min_quantity + program.reward_product_quantity) else: - reward_product_qty = max_product_qty + reward_product_qty = min(max_product_qty, self.order_line.filtered(lambda x: x.product_id == program.reward_product_id).product_uom_qty) reward_qty = min(int(int(max_product_qty / program.rule_min_quantity) * program.reward_product_quantity), reward_product_qty) # Take the default taxes on the reward product, mapped with the fiscal position @@ -84,14 +92,16 @@ class SaleOrder(models.Model): 'price_unit': - price_unit, 'product_uom_qty': reward_qty, 'is_reward_line': True, - 'name': "Free Product - " + program.reward_product_id.name, + 'name': _("Free Product") + " - " + program.reward_product_id.name, 'product_uom': program.reward_product_id.uom_id.id, 'tax_id': [(4, tax.id, False) for tax in taxes], } - def _get_order_lines_untaxed_amount(self): - """ Returns the untaxed sale order total amount without the rewards amount""" - return sum([x.price_subtotal for x in self.order_line.filtered(lambda x: not x.is_reward_line)]) + def _get_order_lines_tax_included_amount(self): + """ Returns the taxes included sale order total amount without the rewards amount""" + free_reward_product = self.env['sale.coupon.program'].search([('reward_type', '=', 'product')]).mapped('discount_line_product_id') + return sum([x.price_total for x in self.order_line.filtered(lambda x: not x.is_reward_line or x.product_id in free_reward_product)]) + def _get_reward_values_discount_fixed_amount(self, program): total_amount = sum(self.order_line.filtered(lambda line: not line.is_reward_line).mapped('price_total')) @@ -106,15 +116,15 @@ class SaleOrder(models.Model): def _get_reward_values_discount_percentage(self, program): discount_amount = 0 - amount_untaxed = self._get_order_lines_untaxed_amount() + amount_total = self._get_order_lines_tax_included_amount() max_amount = program._compute_program_amount('discount_max_amount', self.currency_id) if program.discount_apply_on == 'on_order': - discount_amount = amount_untaxed * (program.discount_percentage / 100) + discount_amount = amount_total * (program.discount_percentage / 100) if program.discount_apply_on == 'cheapest_product': unit_prices = self._get_lines_unit_prices() discount_amount = (min(unit_prices) * (program.discount_percentage) / 100) if program.discount_apply_on == 'specific_product': - discount_amount = sum([(x.price_unit * x.product_uom_qty) * (program.discount_percentage / 100) for x in self.order_line.filtered( + discount_amount = sum([(x.price_total * x.product_uom_qty) * (program.discount_percentage / 100) for x in self.order_line.filtered( lambda x: x.product_id == program.discount_specific_product_id)]) if program.discount_max_amount and discount_amount > max_amount: discount_amount = max_amount @@ -126,7 +136,7 @@ class SaleOrder(models.Model): elif program.discount_type == 'fixed_amount': discount_amount = self._get_reward_values_discount_fixed_amount(program) return { - 'name': "Discount: %s" % (program.name), + 'name': _("Discount: ") + program.name, 'product_id': program.discount_line_product_id.id, 'price_unit': - discount_amount, 'product_uom_qty': 1.0, @@ -155,7 +165,10 @@ class SaleOrder(models.Model): 'discount_line_product_id': program.discount_line_product_id.id }) self.generated_coupon_ids |= coupon - subject = '%s, a coupon has been generated from your order %s' % (self.partner_id.name, self.name) + subject = _('%(partner_name)s, a coupon has been generated from your order %(order_name)s') % { + 'partner_name': self.partner_id.name, + 'order_name': self.name, + } body = self.env.ref('sale_coupon.sale_coupon_created_coupon_email_template').render({ 'code': coupon.code, 'reward_description': coupon.program_id.discount_line_product_id.name @@ -183,7 +196,7 @@ class SaleOrder(models.Model): ('promo_code_usage', '=', 'no_code_needed'), # ('promo_applicability', '=', 'on_current_order') ])._filter_programs_from_common_rules(self) - return programs.sorted('sequence') + return programs def _create_new_no_code_promo_reward_lines(self): '''Apply new programs that are applicable''' From c69778fe6f0c462f7cc1acfb54dea28604725b18 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Fri, 15 Dec 2017 16:05:02 +0100 Subject: [PATCH 2/5] [IMP] sale_coupon, website_sale_coupon: add tests Commit a42c27f99e0061b7c4f recently fixed some bugs on sale_coupon. This commit add some tests to be sure theses bugs are fixed and won't appear again. This closes #1744 --- __manifest__.py | 1 + data/sale_coupon_demo.xml | 25 +++++ tests/__init__.py | 1 + tests/common.py | 7 ++ tests/test_program_numbers.py | 180 ++++++++++++++++++++++++++++++++++ tests/test_program_rules.py | 17 +++- 6 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 data/sale_coupon_demo.xml create mode 100644 tests/test_program_numbers.py diff --git a/__manifest__.py b/__manifest__.py index 157cbcb6dbc..b2480927632 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -16,5 +16,6 @@ 'views/sale_coupon_program_views.xml', 'views/sale_config_settings_views.xml', 'data/sale_coupon_data.xml', + 'data/sale_coupon_demo.xml', ], } diff --git a/data/sale_coupon_demo.xml b/data/sale_coupon_demo.xml new file mode 100644 index 00000000000..832b076c83a --- /dev/null +++ b/data/sale_coupon_demo.xml @@ -0,0 +1,25 @@ + + + + + Code for 10% on orders + code_needed + 10pc + on_order + percentage + 10.0 + promotion_program + + + + Buy 3 ipads, get one for free + no_code_needed + on_order + product + promotion_program + + 3 + [["name","ilike","ipad mini"]] + + + diff --git a/tests/__init__.py b/tests/__init__.py index 5392f5c4c27..daab1dd2d5c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,3 +5,4 @@ import test_program_without_code_operations import test_program_with_code_operations import test_program_rules import test_program_rewards +import test_program_numbers diff --git a/tests/common.py b/tests/common.py index 6bb58d7c751..1f4c5d4dd7b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -57,3 +57,10 @@ class TestSaleCouponCommon(common.TransactionCase): 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id), 'active': True, }) + + self.tax_10pc_incl = self.env['account.tax'].create({ + 'name': "10% Tax incl", + 'amount_type': 'percent', + 'amount': 10, + 'price_include': True, + }) diff --git a/tests/test_program_numbers.py b/tests/test_program_numbers.py new file mode 100644 index 00000000000..fcb5002d5c0 --- /dev/null +++ b/tests/test_program_numbers.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon + +class TestSaleCouponProgramNumbers(TestSaleCouponCommon): + + def setUp(self): + super(TestSaleCouponProgramNumbers, self).setUp() + + self.iPadMini = self.env.ref('product.product_product_6').copy() + self.computerCase = self.env.ref('product.product_product_16').copy() + self.littleServer = self.env.ref('product.consu_delivery_02').copy() + self.steve = self.env['res.partner'].create({ + 'name': 'Steve Bucknor', + 'customer': True, + 'email': 'steve.bucknor@example.com', + }) + self.empty_order = self.env['sale.order'].create({ + 'partner_id': self.steve.id + }) + + self.p1 = self.env['sale.coupon.program'].create({ + 'name': 'Code for 10% on orders', + 'promo_code_usage': 'code_needed', + 'promo_code': 'test_10pc', + 'discount_type': 'percentage', + 'discount_percentage': 10.0, + 'program_type': 'promotion_program', + }) + self.p2 = self.env['sale.coupon.program'].create({ + 'name': 'Buy 3 ipads, get one for free', + 'promo_code_usage': 'no_code_needed', + 'reward_type': 'product', + 'program_type': 'promotion_program', + 'reward_product_id': self.iPadMini.id, + 'rule_min_quantity': 3, + 'rule_products_domain': '[["name","ilike","ipad mini"]]', + }) + self.p3 = self.env['sale.coupon.program'].create({ + 'name': 'Buy 1 computer case, get a free little server', + 'promo_code_usage': 'no_code_needed', + 'reward_type': 'product', + 'program_type': 'promotion_program', + 'reward_product_id': self.littleServer.id, + 'rule_products_domain': '[["name","ilike","computer case"]]', + }) + + def test_program_numbers_free_and_paid_product_qty(self): + # These tests will focus on numbers (free product qty, SO total, reduction total..) + order = self.empty_order + sol1 = self.env['sale.order.line'].create({ + 'product_id': self.iPadMini.id, + 'name': 'iPad Mini', + 'product_uom_qty': 4.0, + 'order_id': order.id, + }) + + # Check we correctly get a free product + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 2, "We should have 2 lines as we now have one 'Free iPad Mini' line as we bought 4 of them") + + # Check free product's price is not added to total when applying reduction (Or the discount will also be applied on the free product's price) + self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc') + self.assertEqual(len(order.order_line.ids), 3, "We should 3 lines as we should have a new line for promo code reduction") + self.assertEqual(order.amount_total, 864, "Only paid product should have their price discounted") + order.order_line.filtered(lambda x: 'Discount' in x.name).unlink() # Remove Discount + + # Check free product is removed since we are below minimum required quantity + sol1.product_uom_qty = 3 + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 1, "Free iPad Mini should have been removed") + + # Free product in cart will be considered as paid product when changing quantity of paid product, so the free product quantity computation will be wrong. + # 100 iPad in cart, 25 free, set quantity to 10 ipad, you should have 2 free ipad but you get 8 because it add the 25 initial free ipad to the total paid ipad when computing (25+10 > 35 > /4 = 8 free ipad) + sol1.product_uom_qty = 100 + order.recompute_coupon_lines() + self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 25, "We should have 25 Free iPad Mini") + sol1.product_uom_qty = 10 + order.recompute_coupon_lines() + self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 2, "We should have 2 Free iPad Mini") + + def test_program_numbers_check_eligibility(self): + # These tests will focus on numbers (free product qty, SO total, reduction total..) + + # Check if we have enough paid product to receive free product in case of a free product that is different from the paid product required + # Buy A, get free b. (remember we need a paid B in cart to receive free b). If your cart is 4A 1B then you should receive 1b (you are eligible to receive 4 because you have 4A but since you dont have enought B in your cart, you are limited to the B quantity) + order = self.empty_order + sol1 = self.env['sale.order.line'].create({ + 'product_id': self.computerCase.id, + 'name': 'Computer Case', + 'product_uom_qty': 4.0, + 'order_id': order.id, + }) + sol2 = self.env['sale.order.line'].create({ + 'product_id': self.littleServer.id, + 'name': 'Little Server', + 'product_uom_qty': 1.0, + 'order_id': order.id, + }) + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 3, "We should have a 'Free Little Server' promotion line") + self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 1, "We should receive one and only one free Little Server") + + # Check the required value amount to be eligible for the program is correctly computed (eg: it does not add negative value (from free product) to total) + # A = free b | Have your cart with A 2B b | cart value should be A + 1B but in code it is only A (free b value is subsstract 2 times) + # This is because _amount_all() is summing all SO lines (so + (-b.value)) and again in _check_promo_code() order.amount_untaxed + order.reward_amount | amount_untaxed has already free product value substracted (_amount_all) + sol1.product_uom_qty = 1 + sol2.product_uom_qty = 2 + self.p1.rule_minimum_amount = 5000 + order.recompute_coupon_lines() + self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc') + self.assertEqual(len(order.order_line.ids), 4, "We should have 4 lines as we should have a new line for promo code reduction") + + # Check you can still have auto applied promotion if you have a promo code set to the order + sol4 = self.env['sale.order.line'].create({ + 'product_id': self.iPadMini.id, + 'name': 'iPad Mini', + 'product_uom_qty': 4.0, + 'order_id': order.id, + }) + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 6, "We should have 2 more lines as we now have one 'Free iPad Mini' line since we bought 4 of them") + + def test_program_numbers_taxes_and_rules(self): + percent_tax = self.env['account.tax'].create({ + 'name': "15% Tax", + 'amount_type': 'percent', + 'amount': 15, + 'price_include': True, + }) + p_specific_product = self.env['sale.coupon.program'].create({ + 'name': '20% reduction on ipad in cart', + 'promo_code_usage': 'no_code_needed', + 'reward_type': 'discount', + 'program_type': 'promotion_program', + 'discount_type': 'percentage', + 'discount_percentage': 20.0, + 'rule_minimum_amount': 320.00, + 'discount_apply_on': 'specific_product', + 'discount_specific_product_id': self.iPadMini.id, + }) + order = self.empty_order + self.iPadMini.taxes_id = percent_tax + sol1 = self.env['sale.order.line'].create({ + 'product_id': self.iPadMini.id, + 'name': 'iPad Mini', + 'product_uom_qty': 1.0, + 'order_id': order.id, + }) + + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 1, "We should not get the reduction line since we dont have 320$ tax excluded (ipad is 320$ tax included)") + sol1.tax_id.price_include = False + sol1._compute_tax_id() + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (ipad is 320$ tax included)") + # (320 +15% tax) - (20% of (320 + 15% tax) = 368 + 73.6 = 294.4 + self.assertEqual(order.amount_total, 294.4, "Check discount has been applied correctly (eg: on taxes aswell)") + + # test coupon with code works the same as auto applied_programs + p_specific_product.write({'promo_code_usage':'code_needed', 'promo_code':'20pc'}) + order.order_line.filtered(lambda l: l.is_reward_line).unlink() + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 1, "Reduction should be removed since we deleted it and it is now a promo code usage, it shouldn't be automatically reapplied") + + self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, '20pc') + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (ipad is 320$ tax included)") + + #check discount applied only on ipad + sol2 = self.env['sale.order.line'].create({ + 'product_id': self.computerCase.id, + 'name': 'Computer Case', + 'product_uom_qty': 10.0, + 'order_id': order.id, + }) + order.recompute_coupon_lines() + # (10x25) + (320 +15% tax) - (20% of (320 + 15% tax) = 250 + 368 - 73.6 = 544.4 + self.assertEqual(order.amount_total, 544.4, "We should only get reduction on ipad") diff --git a/tests/test_program_rules.py b/tests/test_program_rules.py index 17018dd49fd..181b5adb07e 100644 --- a/tests/test_program_rules.py +++ b/tests/test_program_rules.py @@ -55,7 +55,7 @@ class TestProgramRules(TestSaleCouponCommon): # Test case: Based on the minimum purchased self.immediate_promotion_program.write({ - 'rule_minimum_amount': 1000.0, + 'rule_minimum_amount': 1006, 'rule_minimum_amount_tax_inclusion': 'tax_excluded' }) @@ -81,7 +81,7 @@ class TestProgramRules(TestSaleCouponCommon): order.write({'order_line': [ (0, False, { 'product_id': self.product_A.id, - 'name': '1 Product A', + 'name': '10 Product A', 'product_uom': self.uom_unit.id, 'product_uom_qty': 10.0, }), @@ -93,7 +93,18 @@ class TestProgramRules(TestSaleCouponCommon): }) ]}) order.recompute_coupon_lines() - self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the purchased amount is enough") + # 10*100 + 5 = 1005 + self.assertEqual(len(order.order_line.ids), 2, "The promo offert should not be applied as the purchased amount is not enough") + + self.immediate_promotion_program.rule_minimum_amount = 1005 + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 3, "The promo offert should be applied as the purchased amount is now enough") + + # 10*(100*1.15) + (5*1.15) = 10*115 + 5.75 = 1155.75 + self.immediate_promotion_program.rule_minimum_amount = 1006 + self.immediate_promotion_program.rule_minimum_amount_tax_inclusion = 'tax_included' + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 3, "The promo offert should be applied as the initial amount required is now tax included") def test_program_rules_validity_dates_and_uses(self): # Test case: Based on the validity dates and the number of allowed uses From 11a089969f67bb2f7a05728097d10d9201e52d2c Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Thu, 1 Mar 2018 18:50:12 +0100 Subject: [PATCH 3/5] [FIX] sale_coupon: relative import --- tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index 3e6fdd6734d..86fd1a6e21e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,4 +5,4 @@ from . import test_program_without_code_operations from . import test_program_with_code_operations from . import test_program_rules from . import test_program_rewards -import test_program_numbers +from . import test_program_numbers From 7fc5738d36925a5a47891eb86f4a3a3eb8c0c612 Mon Sep 17 00:00:00 2001 From: Nicolas Lempereur Date: Mon, 5 Mar 2018 23:36:51 +0100 Subject: [PATCH 4/5] [FIX] sale_coupon: typo applying a42c27f in s11.2 --- models/sale_order.py | 1 - 1 file changed, 1 deletion(-) diff --git a/models/sale_order.py b/models/sale_order.py index 2c4993a73a6..b7c2fd7227e 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -167,7 +167,6 @@ class SaleOrder(models.Model): template = self.env.ref('sale_coupon.mail_template_sale_coupon', raise_if_not_found=False) if template: self.message_post_with_template(template.id, composition_mode='comment') - } return coupon def _get_applicable_programs(self): From 48a38e15cedcf8906a4493d80a001132a6accb88 Mon Sep 17 00:00:00 2001 From: Hugo Adan Date: Mon, 5 Mar 2018 20:13:57 +0000 Subject: [PATCH 5/5] [FIX] sale_coupon: Moved data/demo wrongly declared as data/data Before this commit, demo data were listed as normal data. opw-1823343 Closes #1949 --- __manifest__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/__manifest__.py b/__manifest__.py index b2480927632..8cf87f44142 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -16,6 +16,8 @@ 'views/sale_coupon_program_views.xml', 'views/sale_config_settings_views.xml', 'data/sale_coupon_data.xml', + ], + 'demo': [ 'data/sale_coupon_demo.xml', ], }