diff --git a/__manifest__.py b/__manifest__.py index 3471add5356..320d1ac4ee3 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -19,4 +19,7 @@ 'report/sale_coupon_report_templates.xml', 'data/sale_coupon_email_data.xml', ], + 'demo': [ + '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/models/sale_coupon.py b/models/sale_coupon.py index e905b547904..8af3a9485ce 100644 --- a/models/sale_coupon.py +++ b/models/sale_coupon.py @@ -53,15 +53,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 e620d4b1b10..2419aa652ae 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") @@ -136,12 +139,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')} @@ -151,7 +151,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 cumulative.')} 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 cumulative.')} @@ -172,12 +172,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 58038b4cffc..be5925fffe5 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): @@ -32,7 +40,7 @@ class SaleOrder(models.Model): @api.returns('self', lambda value: value.id) 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 @@ -73,7 +81,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 @@ -85,14 +93,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')) @@ -107,15 +117,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 @@ -127,7 +137,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, @@ -181,7 +191,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''' diff --git a/tests/__init__.py b/tests/__init__.py index fd56e193d8c..86fd1a6e21e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -5,3 +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 +from . import test_program_numbers diff --git a/tests/common.py b/tests/common.py index 1cde9b0d33a..87f21a0d52a 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