diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py index 5b957911d21..0ff4fa37b47 100644 --- a/addons/sale_coupon/models/sale_order.py +++ b/addons/sale_coupon/models/sale_order.py @@ -116,8 +116,13 @@ class SaleOrder(models.Model): free_reward_product = self.env['coupon.program'].search([('reward_type', '=', 'product')]).mapped('discount_line_product_id') return self.order_line.filtered(lambda x: not x.is_reward_line or x.product_id in free_reward_product) + def _get_base_order_lines(self, program): + """ Returns the sale order lines not linked to the given program. + """ + return self.order_line.filtered(lambda x: not (x.is_reward_line and x.product_id == program.discount_line_product_id)) + def _get_reward_values_discount_fixed_amount(self, program): - total_amount = sum(self._get_paid_order_lines().mapped('price_total')) + total_amount = sum(self._get_base_order_lines(program).mapped('price_total')) fixed_amount = program._compute_program_amount('discount_fixed_amount', self.currency_id) if total_amount < fixed_amount: return total_amount @@ -145,17 +150,18 @@ class SaleOrder(models.Model): }] reward_dict = {} lines = self._get_paid_order_lines() + amount_total = sum(self._get_base_order_lines(program).mapped('price_subtotal')) if program.discount_apply_on == 'cheapest_product': line = self._get_cheapest_line() if line: - discount_line_amount = line.price_reduce * (program.discount_percentage / 100) + discount_line_amount = min(line.price_reduce * (program.discount_percentage / 100), amount_total) if discount_line_amount: taxes = self.fiscal_position_id.map_tax(line.tax_id) reward_dict[line.tax_id] = { 'name': _("Discount: %s", program.name), 'product_id': program.discount_line_product_id.id, - 'price_unit': - discount_line_amount, + 'price_unit': - discount_line_amount if discount_line_amount > 0 else 0, 'product_uom_qty': 1.0, 'product_uom': program.discount_line_product_id.uom_id.id, 'is_reward_line': True, @@ -167,8 +173,10 @@ class SaleOrder(models.Model): free_product_lines = self.env['coupon.program'].search([('reward_type', '=', 'product'), ('reward_product_id', 'in', program.discount_specific_product_ids.ids)]).mapped('discount_line_product_id') lines = lines.filtered(lambda x: x.product_id in (program.discount_specific_product_ids | free_product_lines)) + # when processing lines we should not discount more than the order remaining total + currently_discounted_amount = 0 for line in lines: - discount_line_amount = self._get_reward_values_discount_percentage_per_line(program, line) + discount_line_amount = min(self._get_reward_values_discount_percentage_per_line(program, line), amount_total - currently_discounted_amount) if discount_line_amount: @@ -184,12 +192,13 @@ class SaleOrder(models.Model): taxes=", ".join(taxes.mapped('name')), ), 'product_id': program.discount_line_product_id.id, - 'price_unit': - discount_line_amount, + 'price_unit': - discount_line_amount if discount_line_amount > 0 else 0, 'product_uom_qty': 1.0, 'product_uom': program.discount_line_product_id.uom_id.id, 'is_reward_line': True, 'tax_id': [(4, tax.id, False) for tax in taxes], } + currently_discounted_amount += discount_line_amount # If there is a max amount for discount, we might have to limit some discount lines or completely remove some lines max_amount = program._compute_program_amount('discount_max_amount', self.currency_id) diff --git a/addons/sale_coupon/tests/test_program_numbers.py b/addons/sale_coupon/tests/test_program_numbers.py index 89ce03c3607..e142be5a661 100644 --- a/addons/sale_coupon/tests/test_program_numbers.py +++ b/addons/sale_coupon/tests/test_program_numbers.py @@ -640,6 +640,182 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): order.recompute_coupon_lines() self.assertEqual(order.amount_total, 82.5, "The promotion programs should have been removed from the order to avoid negative amount") + def test_coupon_and_coupon_discount_fixed_amount_tax_excl(self): + """ Ensure multiple coupon can cohexists without making + the order go below 0 + * Have an order of 300 (3 lines: 1 tax excl 15%, 2 notax) + * Apply a coupon A of 10% discount, unconditioned + * Apply a coupon B of 288.5 discount, unconditioned + * Order should not go below 0 + * Even applying the coupon in reverse order should yield same result + """ + + coupon_program = self.env['coupon.program'].create({ + 'name': '$288.5 coupon', + 'program_type': 'coupon_program', + 'reward_type': 'discount', + 'discount_type': 'fixed_amount', + 'discount_fixed_amount': 288.5, + 'active': True, + 'discount_apply_on': 'on_order', + }) + + order = self.empty_order + orderline = self.env['sale.order.line'].create([ + { + 'product_id': self.conferenceChair.id, + 'name': 'Conference Chair', + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + 'order_id': order.id, + 'tax_id': [(6, 0, (self.tax_15pc_excl.id,))], + }, + { + 'product_id': self.pedalBin.id, + 'name': 'Computer Case', + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + 'order_id': order.id, + 'tax_id': [(6, 0, [])], + }, + { + 'product_id': self.product_A.id, + 'name': 'Computer Case', + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + 'order_id': order.id, + 'tax_id': [(6, 0, [])], + }, + ]) + + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'test_10pc' + }).process_coupon() + self.assertEqual(order.amount_total, 283.5, "The promotion program should be correctly applied") + + self.env['coupon.generate.wizard'].with_context(active_id=coupon_program.id).create({ + 'generation_type': 'nbr_coupon', + 'nbr_coupons': 1, + }).generate_coupon() + coupon = coupon_program.coupon_ids + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + order.recompute_coupon_lines() + #TODO fix numbers + # Need an in-depth inspection on the behavior with + # - multiple product with different VAT + + # - a fixed amount (greater than remaining amount to pay) + + # - discount amount + # And user should be able to swap the promotion order with a meaningful result. + self.assertEqual(order.amount_tax, 13.5) + self.assertEqual(order.amount_untaxed, 0.0, "The untaxed amount should not go below 0") + self.assertEqual(order.amount_total, 13.5, "The promotion program should not make the order total go below 0") + + order.order_line[3:].unlink() #remove all coupon + + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line), 3, "The promotion program should be removed") + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + self.assertEqual(order.amount_total, 26.5, "The promotion program should be correctly applied") + order.recompute_coupon_lines() + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'test_10pc' + }).process_coupon() + order.recompute_coupon_lines() + #TODO fix numbers + self.assertEqual(order.amount_tax, 13.5) + self.assertEqual(order.amount_untaxed, 0.0) + self.assertEqual(order.amount_total, 13.5, "The promotion program should not make the order total go below 0be altered after recomputation") + + def test_coupon_and_coupon_discount_fixed_amount_tax_incl(self): + """ Ensure multiple coupon can cohexists without making + the order go below 0 + * Have an order of 300 (3 lines: 1 tax incl 10%, 2 notax) + * Apply a coupon A of 10% discount, unconditioned + * Apply a coupon B of 290 discount, unconditioned + * Order should not go below 0 + * Even applying the coupon in reverse order should yield same result + """ + + coupon_program = self.env['coupon.program'].create({ + 'name': '$290 coupon', + 'program_type': 'coupon_program', + 'reward_type': 'discount', + 'discount_type': 'fixed_amount', + 'discount_fixed_amount': 290, + 'active': True, + 'discount_apply_on': 'on_order', + }) + + order = self.empty_order + orderline = self.env['sale.order.line'].create([ + { + 'product_id': self.conferenceChair.id, + 'name': 'Conference Chair', + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + 'order_id': order.id, + 'tax_id': [(6, 0, (self.tax_10pc_incl.id,))], + }, + { + 'product_id': self.pedalBin.id, + 'name': 'Computer Case', + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + 'order_id': order.id, + 'tax_id': [(6, 0, [])], + }, + { + 'product_id': self.product_A.id, + 'name': 'Computer Case', + 'product_uom_qty': 1.0, + 'price_unit': 100.0, + 'order_id': order.id, + 'tax_id': [(6, 0, [])], + }, + ]) + + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'test_10pc' + }).process_coupon() + self.assertEqual(order.amount_total, 270.0, "The promotion program should be correctly applied") + + self.env['coupon.generate.wizard'].with_context(active_id=coupon_program.id).create({ + 'generation_type': 'nbr_coupon', + 'nbr_coupons': 1, + }).generate_coupon() + coupon = coupon_program.coupon_ids + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + self.assertEqual(order.amount_total, 0.0, "The promotion program should not make the order total go below 0") + order.recompute_coupon_lines() + #TODO fix numbers + self.assertEqual(order.amount_total, 9.09, "The promotion program should not be altered after recomputation") + self.assertEqual(order.amount_tax, 8.18) + self.assertEqual(order.amount_untaxed, 0.91) + + order.order_line[3:].unlink() #remove all coupon + + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line), 3, "The promotion program should be removed") + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': coupon.code + }).process_coupon() + self.assertEqual(order.amount_total, 10.0, "The promotion program should be correctly applied") + order.recompute_coupon_lines() + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'test_10pc' + }).process_coupon() + order.recompute_coupon_lines() + #TODO fix numbers + self.assertEqual(order.amount_tax, 9.01) + self.assertEqual(order.amount_untaxed, 0.08) + self.assertEqual(order.amount_total, 9.09, "The promotion program should not be altered after recomputation") + def test_program_discount_on_multiple_specific_products(self): """ Ensure a discount on multiple specific products is correctly computed. - Simple: Discount must be applied on all the products set on the promotion