diff --git a/models/sale_coupon_program.py b/models/sale_coupon_program.py index 544bca39b24..c20f3386ce9 100644 --- a/models/sale_coupon_program.py +++ b/models/sale_coupon_program.py @@ -229,10 +229,9 @@ class SaleCouponProgram(models.Model): valid_products = program._get_valid_products(products) ordered_rule_products_qty = sum(products_qties[product] for product in valid_products) # Avoid program if 1 ordered foo on a program '1 foo, 1 free foo' - if program._is_valid_product(program.reward_product_id) and program.reward_type == 'product': - line = order.order_line.filtered(lambda line: line.product_id == program.reward_product_id) + if program.promo_applicability == 'on_current_order' and \ + program._is_valid_product(program.reward_product_id) and program.reward_type == 'product': ordered_rule_products_qty -= program.reward_product_quantity - # needed_quantity = program.rule_min_quantity if self. if ordered_rule_products_qty >= program.rule_min_quantity: valid_programs |= program return valid_programs @@ -267,7 +266,12 @@ class SaleCouponProgram(models.Model): # Product requirement should not be checked if the coupon got generated by a promotion program (the requirement should have only be checked to generate the coupon) if not next_order: programs = programs and programs._filter_programs_on_products(order) - programs = programs and programs._filter_not_ordered_reward_programs(order) + + programs_curr_order = programs.filtered(lambda p: p.promo_applicability == 'on_current_order') + programs = programs.filtered(lambda p: p.promo_applicability == 'on_next_order') + if programs_curr_order: + # Checking if rewards are in the SO should not be performed for rewards on_next_order + programs += programs_curr_order._filter_not_ordered_reward_programs(order) return programs def _is_valid_partner(self, partner): diff --git a/models/sale_order.py b/models/sale_order.py index 83cfb7f24ca..7702b93bb0c 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -217,13 +217,24 @@ class SaleOrder(models.Model): self.write({'order_line': [(0, False, value) for value in self._get_reward_line_values(program)]}) def _create_reward_coupon(self, program): - coupon = self.env['sale.coupon'].create({ - 'program_id': program.id, - 'state': 'reserved', - 'partner_id': self.partner_id.id, - 'order_id': self.id, - 'discount_line_product_id': program.discount_line_product_id.id - }) + # if there is already a coupon that was set as expired, reactivate that one instead of creating a new one + coupon = self.env['sale.coupon'].search([ + ('program_id', '=', program.id), + ('state', '=', 'expired'), + ('partner_id', '=', self.partner_id.id), + ('order_id', '=', self.id), + ('discount_line_product_id', '=', program.discount_line_product_id.id), + ], limit=1) + if coupon: + coupon.write({'state': 'reserved'}) + else: + coupon = self.env['sale.coupon'].create({ + 'program_id': program.id, + 'state': 'reserved', + 'partner_id': self.partner_id.id, + 'order_id': self.id, + 'discount_line_product_id': program.discount_line_product_id.id + }) self.generated_coupon_ids |= coupon return coupon @@ -258,8 +269,17 @@ class SaleOrder(models.Model): return programs def _get_applied_coupon_program_coming_from_another_so(self): + # TODO: Remove me in master as no more used + pass + + def _get_valid_applied_coupon_program(self): self.ensure_one() - programs = self.applied_coupon_ids.mapped('program_id')._filter_programs_from_common_rules(self, True) + # applied_coupon_ids's coupons might be coming from: + # * a coupon generated from a previous order that benefited from a promotion_program that rewarded the next sale order. + # In that case requirements to benefit from the program (Quantity and price) should not be checked anymore + # * a coupon_program, in that case the promo_applicability is always for the current order and everything should be checked (filtered) + programs = self.applied_coupon_ids.mapped('program_id').filtered(lambda p: p.promo_applicability == 'on_next_order')._filter_programs_from_common_rules(self, True) + programs += self.applied_coupon_ids.mapped('program_id').filtered(lambda p: p.promo_applicability == 'on_current_order')._filter_programs_from_common_rules(self) return programs def _create_new_no_code_promo_reward_lines(self): @@ -334,18 +354,26 @@ class SaleOrder(models.Model): self.ensure_one() order = self - applicable_programs = order._get_applicable_no_code_promo_program() + order._get_applicable_programs() + order._get_applied_coupon_program_coming_from_another_so() - applied_programs = order._get_applied_programs_with_rewards_on_current_order() + applicable_programs = order._get_applicable_no_code_promo_program() + order._get_applicable_programs() + order._get_valid_applied_coupon_program() + applied_programs = order._get_applied_programs_with_rewards_on_current_order() + order._get_applied_programs_with_rewards_on_next_order() programs_to_remove = applied_programs - applicable_programs products_to_remove = programs_to_remove.mapped('discount_line_product_id') # delete reward line coming from an archived coupon (it will never be updated/removed when recomputing the order) invalid_lines = order.order_line.filtered(lambda line: line.is_reward_line and line.product_id.id not in (applied_programs).mapped('discount_line_product_id').ids) + # Invalid generated coupon for which we are not eligible anymore ('expired' since it is specific to this SO and we may again met the requirements) self.generated_coupon_ids.filtered(lambda coupon: coupon.program_id.discount_line_product_id.id in products_to_remove.ids).write({'state': 'expired'}) + # Reset applied coupons for which we are not eligible anymore ('valid' so it can be use on another ) + coupons_to_remove = order.applied_coupon_ids.filtered(lambda coupon: coupon.program_id in programs_to_remove) + coupons_to_remove.write({'state': 'new'}) + + # Unbind promotion and coupon programs which requirements are not met anymore order.no_code_promo_program_ids -= programs_to_remove order.code_promo_program_id -= programs_to_remove - order.applied_coupon_ids -= order.applied_coupon_ids.filtered(lambda coupon: coupon.program_id in programs_to_remove) + order.applied_coupon_ids -= coupons_to_remove + + # Remove their reward lines invalid_lines |= order.order_line.filtered(lambda line: line.product_id.id in products_to_remove.ids) invalid_lines.unlink() @@ -358,6 +386,10 @@ class SaleOrder(models.Model): self.applied_coupon_ids.mapped('program_id') + \ self.code_promo_program_id.filtered(lambda p: p.promo_applicability == 'on_current_order') + def _get_applied_programs_with_rewards_on_next_order(self): + return self.no_code_promo_program_ids.filtered(lambda p: p.promo_applicability == 'on_next_order') + \ + self.code_promo_program_id.filtered(lambda p: p.promo_applicability == 'on_next_order') + class SaleOrderLine(models.Model): _inherit = "sale.order.line" diff --git a/tests/test_program_numbers.py b/tests/test_program_numbers.py index 656ddcaad6f..550b3b66447 100644 --- a/tests/test_program_numbers.py +++ b/tests/test_program_numbers.py @@ -490,3 +490,44 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): fixed_amount_program.write({'active': False}) # Check archived product will remove discount lines on recompute order.recompute_coupon_lines() self.assertEqual(len(order.order_line.ids), 1, "Archiving the program should remove the program reward line") + + def test_program_next_order(self): + order = self.empty_order + self.env['sale.coupon.program'].create({ + 'name': 'Free Keyboard if at least 1 article', + 'promo_code_usage': 'no_code_needed', + 'promo_applicability': 'on_next_order', + 'program_type': 'promotion_program', + 'reward_type': 'product', + 'reward_product_id': self.wirelessKeyboard.id, + 'rule_min_quantity': 2, + }) + 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, "Nothing should be added to the cart") + self.assertEqual(len(order.generated_coupon_ids), 0, "No coupon should have been generated yet") + + sol1.product_uom_qty = 2 + order.recompute_coupon_lines() + generated_coupon = order.generated_coupon_ids + self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart (2)") + self.assertEqual(len(generated_coupon), 1, "A coupon should have been generated") + self.assertEqual(generated_coupon.state, 'reserved', "The coupon should be reserved") + + sol1.product_uom_qty = 1 + order.recompute_coupon_lines() + generated_coupon = order.generated_coupon_ids + self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart (3)") + self.assertEqual(len(generated_coupon), 1, "No more coupon should have been generated and the existing one should not have been deleted") + self.assertEqual(generated_coupon.state, 'expired', "The coupon should have been set as expired as it is no more valid since we don't have the required quantity") + + sol1.product_uom_qty = 2 + order.recompute_coupon_lines() + generated_coupon = order.generated_coupon_ids + self.assertEqual(len(generated_coupon), 1, "We should still have only 1 coupon as we now benefit again from the program but no need to create a new one (see next assert)") + self.assertEqual(generated_coupon.state, 'reserved', "The coupon should be set back to reserved as we had already an expired one, no need to create a new one") diff --git a/tests/test_program_rules.py b/tests/test_program_rules.py index 181b5adb07e..727f71bcadc 100644 --- a/tests/test_program_rules.py +++ b/tests/test_program_rules.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon +from odoo.exceptions import UserError from odoo.fields import Date class TestProgramRules(TestSaleCouponCommon): @@ -171,3 +172,71 @@ class TestProgramRules(TestSaleCouponCommon): ]}) order.recompute_coupon_lines() self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied as the number of uses is exceeded") + + 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) + * Ensure that if a reward from a coupon_program was allowed and the conditions are not met anymore, + the reward will be removed on recompute. + ''' + self.immediate_promotion_program.active = False # Avoid having this program to add rewards on this test + order = self.empty_order + + program = self.env['sale.coupon.program'].create({ + 'name': 'Get 10% discount if buy at least 4 Product A and $320', + 'program_type': 'coupon_program', + 'reward_type': 'discount', + 'discount_type': 'percentage', + 'discount_percentage': 10.0, + 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id), + 'rule_min_quantity': 3, + 'rule_minimum_amount': 320.00, + }) + + sol1 = self.env['sale.order.line'].create({ + 'product_id': self.product_A.id, + 'name': 'Product A', + 'product_uom_qty': 2.0, + 'order_id': order.id, + }) + + sol2 = self.env['sale.order.line'].create({ + 'product_id': self.product_B.id, + 'name': 'Product B', + 'product_uom_qty': 4.0, + 'order_id': order.id, + }) + + # Default value for coupon generate wizard is generate by quantity and generate only one coupon + self.env['sale.coupon.generate'].with_context(active_id=program.id).create({}).generate_coupon() + coupon = program.coupon_ids[0] + + # Not enough amount since we only have 220 (100*2 + 5*4) + with self.assertRaises(UserError): + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + + sol2.product_uom_qty = 24 + + # Not enough qty since we only have 3 Product A (Amount is ok: 100*2 + 5*24 = 320) + with self.assertRaises(UserError): + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + + sol1.product_uom_qty = 3 + + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + order.recompute_coupon_lines() + + self.assertEqual(len(order.order_line.ids), 3, "The order should contains the Product A line, the Product B line and the discount line") + self.assertEqual(coupon.state, 'used', "The coupon should be set to Consumed as it has been used") + + sol1.product_uom_qty = 2 + order.recompute_coupon_lines() + + self.assertEqual(len(order.order_line.ids), 2, "The discount line should have been removed as we don't meet the program requirements") + self.assertEqual(coupon.state, 'new', "The coupon should be reset to Valid as it's reward got removed")