From 034fb31c40a2353c233cfc479860e3832b481ccd Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Tue, 24 Apr 2018 16:09:49 +0200 Subject: [PATCH 1/7] [FIX] sale_coupon(_delivery): fix some flows (mostly on next order) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit is the continuity of a42c27f9 that fixed some bugs. One of the bug was that a discount was only applied on price without taxes. E.G: A $100 product with 20% excluded tax would cost $120 but a 50% discount would give the user only $50 discount and not $60 as expected. User would lose $10 discount. With these fixes, the discount line would correctly be set to $60 but since the discount line would not have tax, the merchant would lose the discount amount on the tax, in this case 50% of $20. This commit will improve this case by adding a tax on a discount line. Thus, since a global discount can be applied to multiple products having different taxes or even multiple taxes on the same product, a discount line should be split for every product taxes the discount applies on. Obviously, this is a big behavior change, even if the discount is now finally correct: the discount is applied on the total price and the merchant does not lose any taxes. To make it more clear, the discount lines description will mention on which taxes it is applied. There won't be any changes in the e-commerce since we will group the discount lines so it will appear as before even if there are multiple lines. This is only possible because we don't show taxes on line on the e-commerce. This commit also fixes more complex flows/use cases. Note: Fixes & flows will be illustrated with a SO but the behavior is the same with a cart on front-end. == Fixes == =========================================================================== The biggest flow that needed to be fixed is when a promotion program give a reward on the next order. Let's illustrate with a promotion program giving a free `product_B` on next order (so it will generate a coupon) if the SO meets the requirements. The requirements are: buy a `product_A` and buy for at least $600. (This program needs a code: 'free_b_on_next') 1. If the SO has a `product_A` and over $600, applying the promotion code `free_b_on_next` will error saying that `product_B` is not in the cart. This error should not appear, since the `product_B` is the reward for the next sale order that will use the generated coupon. Anyway, if you add `product_B` and apply the promotion code `free_b_on_next` to generate the coupon to check the rest of the flow. It will generate the coupon and send email to the customer. 2. Every time you will apply the promotion code `free_b_on_next`, it will regenerate a new coupon and resend a new email to the client. Then, create a second SO, add 2 `product_B` and try to apply the generated coupon from the first SO. 3. It will error saying that the $600 requirement is not met. It should not since the requirement was only required to generate the coupon on the first SO. User has now a coupon to enjoy a free `product_B` and should not buy $600. Anyway, let's add some products to meet the 600$ requirement to check the rest of the flow. 4. It will now error saying that `product_A` is not in the cart. As bug 3., it should not since this requirement was also required to generate the coupon on the first SO. Anyway, let's add `product_A` to meet the requirement to check the rest of the flow. 5. Updating the promotions will remove the reward and you won't be able to reapply the coupon since it was `used`. This bug was even more critical in front-end since any action on the cart will recompute the coupons and automatically delete the reward after adding it. The customer never saw the reward on his cart. This bug is related to 3. and 4. Indeed, when recomputing coupons, it will remove programs that are not applicable anymore. The code wrongly think it is the case with this coupon since we don't have $600 or a `product_A`. `_remove_invalid_reward_lines` should treat coupon coming from a promotion program set on `next_order` differently and not filter on requirements. =========================================================================== 6. (extension of 2.) Every time an user would meet the requirements of a promotion program generating a coupon for a next order, it would generate a coupon and send an email. E.g: If buy 2 iPads get 1 for free on next order. If user add and remove iPad for some reason, every time he has 2 iPads in his cart it will receive an email with a new coupon. 6'. When applying a promotion code that will reward next order, it will not be set in the SO `code_promo_program_id` (I did not change that behavior since it could make sense to be able to apply another coupon that will give -20% on current order). This will allow applying multiple promotion rewarding a next order and/or a promotion rewarding current order. (Which would not be possible if was stored on `code_promo_program_id` since you can only apply one code on an order) But this cause a bug: since the promotion program is not stored on the order, every time you will apply the coupon it will generate a new coupon and send an email. So if you work on a draft sale order and do some tests, it will send many emails to customer.. 7. Having multiple global discounts (on the whole SO) was forbidden: you can't apply a coupon promotion or a program promotion if there is already a global coupon discount or a global promotion discount with code. But still you could apply a global discount if there were a global promotion discount automatically applied (without a code). 8. On the generate coupon wizard, if you select `Number of Selected Customers`, by default it is set to 'Match all records (X records)'. But when you click on generate, it won't generate anything even if visually it selected every partner by default. This is because the domain default value is an empty string. Thus, the domain widget is selecting everything but once it is sent back to the server, since it's an empty string it won't get through the `if` condition. It was even weirder when you add something to the domain and remove it, you then visually come back to where you were at the beginning but the domain got changed from '' to '[]' so in this case it will generate the coupon for every partner. == More like improvements == 1. `action_cancel` and `action_confirm` would handle correctly the states of the applied and generated coupons (valid, consumed, expired..). But `action_draft` would not handle the states at all. 2. The generated coupon on a SO are now sent by email only when the SO is validated. This is part of the fix n°2 on coupon being generated multiple times and sent by email multiple times. The coupon sent by email was not usable anyway since it would error saying the SO that generated the coupon was not validated. 3. Discount line limited to a max amount (set in the program) will mention on the line description that the discount is limited. This will avoid cases where you don't understand why 50% discount of $2000 SO shows $40. 4. If you archive a program and there is still some SO containing this program's reward line, these line will be 'floating free' and will never be updated or removed. Indeed, the `_remove_invalid_reward_lines` is basically checking which programs could be applied on the order and which one are applied on the order. If a program is applied on the order but is not supposed to, his reward line will be removed. Of course, since the program got archived, it won't get retrieve and the line will never get removed. Even if the case of a free iPad: if you buy an iPad and you removed the paid iPad from the SO. The free iPad will remain forever. Note: We can't remove these discount line directly when archiving a program because there might be quotations that we don't want to modify since it is waiting for customer confirmation. Improved behavior: - Archiving a program archives its related reward product but unarchiving the program does not unarchived the product. task-1866977 opw-1832967 opw-1857843 Closes #2224 Comes with https://github.com/odoo/odoo/pull/24971 --- models/sale_coupon.py | 6 +- models/sale_coupon_program.py | 20 ++- models/sale_order.py | 261 +++++++++++++++++++++---------- wizard/sale_coupon_apply_code.py | 14 +- wizard/sale_coupon_generate.py | 2 +- 5 files changed, 205 insertions(+), 98 deletions(-) diff --git a/models/sale_coupon.py b/models/sale_coupon.py index e60873df677..8e709fe9055 100644 --- a/models/sale_coupon.py +++ b/models/sale_coupon.py @@ -57,7 +57,8 @@ class SaleCoupon(models.Model): 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 not self.program_id._filter_on_mimimum_amount(order): + # Minimum 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) + elif self.program_id.program_type == 'coupon_program' and 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)} @@ -71,7 +72,8 @@ class SaleCoupon(models.Model): message = {'error': _('The reward products should be in the sales order lines to apply the discount.')} elif not self.program_id._is_valid_partner(order.partner_id): message = {'error': _("The customer doesn't have access to this reward.")} - elif not self.program_id._filter_programs_on_products(order): + # 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) + elif self.program_id.program_type == 'coupon_program' and not self.program_id._filter_programs_on_products(order): message = {'error': _("You don't have the required product quantities on your sales order. All the products should be recorded on the sales order. (Example: You need to have 3 T-shirts on your sales order if the promotion is 'Buy 2, Get 1 Free'.")} else: if self.program_id not in applicable_programs and self.program_id.promo_applicability == 'on_current_order': diff --git a/models/sale_coupon_program.py b/models/sale_coupon_program.py index 1e702ef1a8c..e96b3a8bd32 100644 --- a/models/sale_coupon_program.py +++ b/models/sale_coupon_program.py @@ -114,7 +114,7 @@ class SaleCouponProgram(models.Model): if not self.active: coupons = self.filtered(lambda p: p.promo_code_usage == 'code_needed').mapped('coupon_ids') coupons.filtered(lambda x: x.state != 'used').write({'state': 'expired'}) - self.mapped('discount_line_product_id').write({'active': False}) + self.mapped('discount_line_product_id').write({'active': self.active}) def action_view_sales_orders(self): self.ensure_one() @@ -151,7 +151,7 @@ class SaleCouponProgram(models.Model): message = {'error': _('Promo code is expired')} 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()): + elif self._is_global_discount_program() and order._is_global_discount_already_applied(): message = {'error': _('Global discounts are not cumulable.')} elif self.promo_applicability == 'on_current_order' and self.reward_type == 'product' and not order._is_reward_in_order_lines(self): message = {'error': _('The reward products should be in the sales order lines to apply the discount.')} @@ -160,7 +160,7 @@ class SaleCouponProgram(models.Model): elif not self._filter_programs_on_products(order): message = {'error': _("You don't have the required product quantities on your sales order. If the reward is same product quantity, please make sure that all the products are recorded on the sales order (Example: You need to have 3 T-shirts on your sales order if the promotion is 'Buy 2, Get 1 Free'.")} else: - if self not in applicable_programs: + if self not in applicable_programs and self.promo_applicability == 'on_current_order': message = {'error': _('At least one of the required conditions is not met to get the reward!')} return message @@ -240,12 +240,20 @@ class SaleCouponProgram(models.Model): return programs @api.model - def _filter_programs_from_common_rules(self, order): - programs = self._filter_on_mimimum_amount(order) + def _filter_programs_from_common_rules(self, order, next_order=False): + """ Return the programs if every conditions is met + :param bool next_order: is the reward given from a previous order + """ + programs = self + # Minimum 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_on_mimimum_amount(order) programs = programs and programs._filter_on_validity_dates(order) programs = programs and programs._filter_unexpired_programs(order) programs = programs and programs._filter_programs_on_partners(order) - programs = programs and programs._filter_programs_on_products(order) + # 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) return programs diff --git a/models/sale_order.py b/models/sale_order.py index cd835ac4b18..689920f9905 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ +from odoo.tools.misc import formatLang class SaleOrder(models.Model): @@ -39,12 +40,14 @@ class SaleOrder(models.Model): def copy(self, default=None): order = super(SaleOrder, self).copy(default) order._get_reward_lines().unlink() - order.with_context(sale_coupon_no_loop=False)._create_new_no_code_promo_reward_lines() + order._create_new_no_code_promo_reward_lines() return order def action_confirm(self): self.recompute_coupon_lines() self.generated_coupon_ids.write({'state': 'new'}) + self.applied_coupon_ids.write({'state': 'used'}) + self._send_reward_coupon_mail() return super(SaleOrder, self).action_confirm() def action_cancel(self): @@ -53,6 +56,11 @@ class SaleOrder(models.Model): self.applied_coupon_ids.write({'state': 'new'}) return res + def action_draft(self): + res = super(SaleOrder, self).action_draft() + self.generated_coupon_ids.write({'state': 'reserved'}) + return res + def _get_reward_lines(self): self.ensure_one() return self.order_line.filtered(lambda line: line.is_reward_line) @@ -95,11 +103,11 @@ class SaleOrder(models.Model): 'tax_id': [(4, tax.id, False) for tax in taxes], } - def _get_order_lines_tax_included_amount(self): - """ Returns the taxes included sale order total amount without the rewards amount""" + def _get_paid_order_lines(self): + """ Returns the sale order lines that are not reward lines. + It will also return reward lines being free product lines. """ 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)]) - + return 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')) @@ -109,51 +117,93 @@ class SaleOrder(models.Model): else: return fixed_amount - def _get_lines_unit_prices(self): + def _get_cheapest_line(self): # Unit prices tax included - return [x.price_total / x.product_uom_qty for x in self.order_line.filtered(lambda x: not x.is_reward_line)] + return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_unit > 0), key=lambda x: x['price_unit']) - def _get_reward_values_discount_percentage(self, program): - discount_amount = 0 - 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_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_total * (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 + def _get_reward_values_discount_percentage_per_line(self, program, line): + discount_amount = line.product_uom_qty * line.price_unit * (program.discount_percentage / 100) return discount_amount def _get_reward_values_discount(self, program): - if program.discount_type == 'percentage': - discount_amount = self._get_reward_values_discount_percentage(program) - elif program.discount_type == 'fixed_amount': - discount_amount = self._get_reward_values_discount_fixed_amount(program) - return { - 'name': _("Discount: ") + program.name, - 'product_id': program.discount_line_product_id.id, - 'price_unit': - discount_amount, - 'product_uom_qty': 1.0, - 'product_uom': program.discount_line_product_id.uom_id.id, - 'is_reward_line': True - } + reward_dict = {} + lines = self._get_paid_order_lines() + if program.discount_apply_on == 'cheapest_product': + line = self._get_cheapest_line() + if line: + discount_line_amount = line.price_unit * (program.discount_percentage / 100) + if discount_line_amount: + taxes = line.tax_id + if self.fiscal_position_id: + taxes = self.fiscal_position_id.map_tax(taxes) + + reward_dict[line.tax_id] = { + 'name': _("Discount: ") + program.name, + 'product_id': program.discount_line_product_id.id, + 'price_unit': - discount_line_amount, + '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], + } + elif program.discount_apply_on in ['specific_product', 'on_order']: + if program.discount_apply_on == 'specific_product': + # We should not exclude reward line that offer this product since we need to offer only the discount on the real paid product (regular product - free product) + free_product_lines = self.env['sale.coupon.program'].search([('reward_type', '=', 'product'), ('reward_product_id', '=', program.discount_specific_product_id.id)]).mapped('discount_line_product_id') + lines = lines.filtered(lambda x: x.product_id == program.discount_specific_product_id or x.product_id in free_product_lines) + + for line in lines: + discount_line_amount = self._get_reward_values_discount_percentage_per_line(program, line) + + if discount_line_amount: + + if line.tax_id in reward_dict: + reward_dict[line.tax_id]['price_unit'] -= discount_line_amount + else: + taxes = line.tax_id + if self.fiscal_position_id: + taxes = self.fiscal_position_id.map_tax(taxes) + + tax_name = "" + if len(taxes) == 1: + tax_name = " - " + _("On product with following tax: ") + ', '.join(taxes.mapped('name')) + elif len(taxes) > 1: + tax_name = " - " + _("On product with following taxes: ") + ', '.join(taxes.mapped('name')) + + reward_dict[line.tax_id] = { + 'name': _("Discount: ") + program.name + tax_name, + 'product_id': program.discount_line_product_id.id, + 'price_unit': - discount_line_amount, + '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], + } + + # 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) + if max_amount > 0: + amount_already_given = 0 + for val in list(reward_dict): + amount_to_discount = amount_already_given + reward_dict[val]["price_unit"] + if abs(amount_to_discount) > max_amount: + reward_dict[val]["price_unit"] = - (max_amount - abs(amount_already_given)) + add_name = formatLang(self.env, max_amount, currency_obj=self.currency_id) + reward_dict[val]["name"] += "( " + _("limited to ") + add_name + ")" + amount_already_given += reward_dict[val]["price_unit"] + if reward_dict[val]["price_unit"] == 0: + del reward_dict[val] + return reward_dict.values() def _get_reward_line_values(self, program): self.ensure_one() if program.reward_type == 'discount': return self._get_reward_values_discount(program) elif program.reward_type == 'product': - return self._get_reward_values_product(program) + return [self._get_reward_values_product(program)] - def _create_reward_line(self, programs): - self.ensure_one() - for program in programs: - self.write({'order_line': [(0, False, self._get_reward_line_values(program))]}) + def _create_reward_line(self, program): + 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({ @@ -164,17 +214,21 @@ class SaleOrder(models.Model): 'discount_line_product_id': program.discount_line_product_id.id }) self.generated_coupon_ids |= coupon - 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 - }) - self.message_post(body=body, subject=subject, subtype='mail.mt_comment', partner_ids=[(4, self.partner_id.id)]) return coupon + def _send_reward_coupon_mail(self): + self.ensure_one() + for coupon in self.generated_coupon_ids: + 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 + }) + self.message_post(body=body, subject=subject, subtype='mail.mt_comment', partner_ids=[(4, self.partner_id.id)]) + def _get_applicable_programs(self): """ This method is used to return the valid applicable programs on given order. @@ -182,7 +236,6 @@ class SaleOrder(models.Model): """ self.ensure_one() programs = self.env['sale.coupon.program'].search([ - ('active', '=', True) ])._filter_programs_from_common_rules(self) if self.promo_code: programs._filter_promo_programs_with_code(self) @@ -191,12 +244,15 @@ class SaleOrder(models.Model): def _get_applicable_no_code_promo_program(self): self.ensure_one() programs = self.env['sale.coupon.program'].search([ - ('active', '=', True), ('promo_code_usage', '=', 'no_code_needed'), - # ('promo_applicability', '=', 'on_current_order') ])._filter_programs_from_common_rules(self) return programs + def _get_applied_coupon_program_coming_from_another_so(self): + self.ensure_one() + programs = self.applied_coupon_ids.mapped('program_id')._filter_programs_from_common_rules(self, True) + return programs + def _create_new_no_code_promo_reward_lines(self): '''Apply new programs that are applicable''' self.ensure_one() @@ -204,47 +260,85 @@ class SaleOrder(models.Model): programs = order._get_applicable_no_code_promo_program() for program in programs: error_status = program._check_promo_code(order, False) - if not error_status.get('error') and program.promo_applicability == 'on_next_order': - order._create_reward_coupon(program) - order.no_code_promo_program_ids |= program - elif not error_status.get('error') and program.discount_line_product_id.id not in self.order_line.mapped('product_id').ids: - self.write({'order_line': [(0, False, order._get_reward_line_values(program))]}) + if not error_status.get('error'): + if program.promo_applicability == 'on_next_order': + order._create_reward_coupon(program) + elif program.discount_line_product_id.id not in self.order_line.mapped('product_id').ids: + self.write({'order_line': [(0, False, value) for value in self._get_reward_line_values(program)]}) order.no_code_promo_program_ids |= program def _update_existing_reward_lines(self): '''Update values for already applied rewards''' self.ensure_one() order = self - applied_programs = order.no_code_promo_program_ids + \ - order.applied_coupon_ids.mapped('program_id') + \ - order.code_promo_program_id + applied_programs = order._get_applied_programs_with_rewards_on_current_order() for program in applied_programs: values = order._get_reward_line_values(program) lines = order.order_line.filtered(lambda line: line.product_id == program.discount_line_product_id) - # Remove reward line if price or qty equal to 0 - if values['product_uom_qty'] and values['price_unit']: - order.write({'order_line': [(1, line.id, values) for line in lines]}) - else: - if program.reward_type != 'free_shipping': - order.write({'order_line': [(2, line.id, False) for line in lines]}) - else: - values.update(price_unit=0.0) - order.write({'order_line': [(1, line.id, values) for line in lines]}) + lines_to_remove = lines + # Values is what discount lines should really be, lines is what we got in the SO at the moment + # 1. If values & lines match, we should update the line (or delete it if no qty or price?) + # 2. If the value is not in the lines, we should add it + # 3. if the lines contains a tax not in value, we should remove it + for value in values: + value_found = False + for line in lines: + # Case 1. + if not len(set(line.tax_id.mapped('id')).symmetric_difference(set([v[1] for v in value['tax_id']]))): + value_found = True + # Working on Case 3. + lines_to_remove -= line + # Check commit 6bb42904a03 for next if/else + # Remove reward line if price or qty equal to 0 + if value['product_uom_qty'] and value['price_unit']: + order.write({'order_line': [(1, line.id, value)]}) + else: + if program.reward_type != 'free_shipping': + lines_to_remove += line + else: + value.update(price_unit=0.0) + order.write({'order_line': [(1, line.id, value)]}) + continue + # Case 2. + if not value_found: + order.write({'order_line': [(0, False, value)]}) + # Case 3. + lines_to_remove.unlink() def _remove_invalid_reward_lines(self): - '''Unlink reward order lines that are not applicable anymore''' - invalid_lines = self.env['sale.order.line'] - for order in self: - new_applicable_programs = order._get_applicable_no_code_promo_program() + order._get_applicable_programs() - old_applicable_programs = order.no_code_promo_program_ids + order.applied_coupon_ids.mapped('program_id') + order.code_promo_program_id - programs_to_remove = old_applicable_programs - new_applicable_programs - products_to_remove = (programs_to_remove).mapped('discount_line_product_id') - self.generated_coupon_ids.filtered(lambda coupon: coupon.program_id.discount_line_product_id.id in products_to_remove.ids).write({'state': 'expired'}) - 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) - invalid_lines |= order.order_line.filtered(lambda line: line.product_id.id in products_to_remove.ids) - order.write({'order_line': [(2, line.id, False) for line in invalid_lines]}) + """ Find programs & coupons that are not applicable anymore. + It will then unlink the related reward order lines. + It will also unset the order's fields that are storing + the applied coupons & programs. + Note: It will also remove a reward line coming from an archive program. + """ + 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() + 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) + + self.generated_coupon_ids.filtered(lambda coupon: coupon.program_id.discount_line_product_id.id in products_to_remove.ids).write({'state': 'expired'}) + 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) + invalid_lines += order.order_line.filtered(lambda line: line.product_id.id in products_to_remove.ids) + order.write({'order_line': [(2, line.id, False) for line in invalid_lines]}) + + def _get_applied_programs_with_rewards_on_current_order(self): + # Need to add filter on current order. Indeed, it has always been calculating reward line even if on next order (which is useless and do calculation for nothing) + # This problem could not be noticed since it would only update or delete existing lines related to that program, it would not find the line to update since not in the order + # But now if we dont find the reward line in the order, we add it (since we can now have multiple line per program in case of discount on different vat), thus the bug + # mentionned ahead will be seen now + return self.no_code_promo_program_ids.filtered(lambda p: p.promo_applicability == 'on_current_order') + \ + self.applied_coupon_ids.mapped('program_id') + \ + self.code_promo_program_id.filtered(lambda p: p.promo_applicability == 'on_current_order') + class SaleOrderLine(models.Model): _inherit = "sale.order.line" @@ -259,10 +353,11 @@ class SaleOrderLine(models.Model): ) coupons_to_reactivate.write({'state': 'new'}) line.order_id.applied_coupon_ids -= coupons_to_reactivate - # Remove the program from the order in the case it's still valid - for line in self: + # Remove the program from the order if the deleted line is the reward line of the program + # And if there is no other lines from this program (It's the case when discount is split per different taxes) related_program = self.env['sale.coupon.program'].search([('discount_line_product_id', '=', line.product_id.id)]) - if related_program: + related_program_lines = line.order_id.order_line.filtered(lambda l: l.product_id.id == related_program.discount_line_product_id.id) - line + if related_program and not related_program_lines: line.order_id.no_code_promo_program_ids -= related_program line.order_id.code_promo_program_id -= related_program return super(SaleOrderLine, self).unlink() diff --git a/wizard/sale_coupon_apply_code.py b/wizard/sale_coupon_apply_code.py index c3099062e78..683c2fdb552 100644 --- a/wizard/sale_coupon_apply_code.py +++ b/wizard/sale_coupon_apply_code.py @@ -29,13 +29,15 @@ class SaleCouponApplyCode(models.TransientModel): error_status = program._check_promo_code(order, coupon_code) if not error_status: if program.promo_applicability == 'on_next_order': - coupon = order._create_reward_coupon(program) - return { - 'generated_coupon': { - 'reward': coupon.program_id.discount_line_product_id.name, - 'code': coupon.code, + # Avoid creating the coupon if it already exist + if program.discount_line_product_id.id not in order.generated_coupon_ids.filtered(lambda coupon: coupon.state in ['new', 'reserved']).mapped('discount_line_product_id').ids: + coupon = order._create_reward_coupon(program) + return { + 'generated_coupon': { + 'reward': coupon.program_id.discount_line_product_id.name, + 'code': coupon.code, + } } - } else: # The program is applied on this order order._create_reward_line(program) order.code_promo_program_id = program diff --git a/wizard/sale_coupon_generate.py b/wizard/sale_coupon_generate.py index 0b820e27499..786ebb45c6f 100644 --- a/wizard/sale_coupon_generate.py +++ b/wizard/sale_coupon_generate.py @@ -11,7 +11,7 @@ class SaleCouponGenerate(models.TransientModel): ('nbr_coupon', 'Number of Coupons'), ('nbr_customer', 'Number of Selected Customers') ], default='nbr_coupon') - partners_domain = fields.Char(string="Customer") + partners_domain = fields.Char(string="Customer", default='[]') partner_ids = fields.Many2many('res.partner', string="Related Partners", compute='_compute_partner_ids') @api.multi From 453d8ce703d88d535edc82fe01c0891ee5db1197 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Tue, 24 Apr 2018 13:54:25 +0200 Subject: [PATCH 2/7] [FIX] sale_coupon: add tests Previous commit fixed some bugs on `sale_coupon` (#2224). This commit add some tests to be sure these bugs are fixed and won't appear again. task-1866977 Related to #2224 --- tests/__init__.py | 1 - tests/test_program_numbers.py | 277 ++++++++++++++++++++- tests/test_program_rewards.py | 63 ----- tests/test_program_with_code_operations.py | 205 +++++++++++---- 4 files changed, 426 insertions(+), 120 deletions(-) delete mode 100644 tests/test_program_rewards.py diff --git a/tests/__init__.py b/tests/__init__.py index daab1dd2d5c..40b53931b86 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,5 +4,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/test_program_numbers.py b/tests/test_program_numbers.py index 9a698f3cf9b..6b2c29d6050 100644 --- a/tests/test_program_numbers.py +++ b/tests/test_program_numbers.py @@ -2,6 +2,8 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon +from odoo.exceptions import UserError + class TestSaleCouponProgramNumbers(TestSaleCouponCommon): @@ -9,6 +11,8 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): super(TestSaleCouponProgramNumbers, self).setUp() self.iPadMini = self.env.ref('product.product_product_6').copy() + self.iPod = self.env.ref('product.product_product_11').copy() + self.wirelessKeyboard = self.env.ref('product.product_product_9').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({ @@ -64,7 +68,7 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): 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 + 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 @@ -113,7 +117,7 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): 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({ + self.env['sale.order.line'].create({ 'product_id': self.iPadMini.id, 'name': 'iPad Mini', 'product_uom_qty': 4.0, @@ -155,11 +159,16 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): 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 + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # iPod | 1 | 320.00 | 15% excl | 320.00 | 368.00 | 48.00 + # 20% discount on ipad | 1 | -64.00 | 15% excl | -64.00 | -73.60 | -9.60 + # -------------------------------------------------------------------------------- + # TOTAL | 256.00 | 294.40 | 38.40 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'}) + 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") @@ -168,21 +177,267 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): 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({ + # check discount applied only on ipad + 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 + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # Computer Case | 10 | 25.00 | / | 250.00 | 250.00 | / + # iPad | 1 | 320.00 | 15% excl | 320.00 | 368.00 | 48.00 + # 20% discount on ipad | 1 | -64.00 | 15% excl | -64.00 | -73.60 | -9.60 + # -------------------------------------------------------------------------------- + # TOTAL | 506.00 | 544.40 | 38.40 self.assertEqual(order.amount_total, 544.4, "We should only get reduction on ipad") sol1.product_uom_qty = 10 order.recompute_coupon_lines() - # (10x25) + (10x (320 +15% tax)) - (20% of (10x (320 + 15% tax))) - 2 free iPads = 250 + 3680 - 736 - 736 = 2458 - self.assertEqual(order.amount_total, 2458, "Changing ipad quantity should change discount amount correctly") - + # Note: Since we now have 2 free ipads, we should discount only 8 of the 10 ipads in carts since we don't want to discount free ipads + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # Computer Case | 10 | 25.00 | / | 250.00 | 250.00 | / + # iPad | 10 | 320.00 | 15% excl | 3200.00 | 3680.00 | 480.00 + # Free iPad | 2 | -320.00 | 15% excl | -640.00 | -736.00 | -96.00 + # 20% discount on ipad | 1 | -512.00 | 15% excl | -512.00 | -588.80 | -78.80 + # -------------------------------------------------------------------------------- + # TOTAL | 2298.00 | 2605.20 | 305.20 + self.assertEqual(order.amount_total, 2605.20, "Changing ipad quantity should change discount amount correctly") p_specific_product.discount_max_amount = 200 order.recompute_coupon_lines() - self.assertEqual(order.amount_total, 2994, "The discount should be limited to $200") + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # Computer Case | 10 | 25.00 | / | 250.00 | 250.00 | / + # iPad | 10 | 320.00 | 15% excl | 3200.00 | 3680.00 | 480.00 + # Free iPad | 2 | -320.00 | 15% excl | -640.00 | -736.00 | -96.00 + # 20% discount on ipad | 1 | -200.00 | 15% excl | -200.00 | -230.00 | -30.00 + # limited to 200 HTVA + # -------------------------------------------------------------------------------- + # TOTAL | 2610.00 | 2964.00 | 354.00 + self.assertEqual(order.amount_total, 2964, "The discount should be limited to $200 tax excluded") + self.assertEqual(order.amount_untaxed, 2610, "The discount should be limited to $200 tax excluded (2)") + + def test_program_numbers_one_discount_line_per_tax(self): + order = self.empty_order + # Create taxes + self.tax_15pc_excl = self.env['account.tax'].create({ + 'name': "15% Tax excl", + 'amount_type': 'percent', + 'amount': 15, + }) + self.tax_50pc_excl = self.env['account.tax'].create({ + 'name': "50% Tax excl", + 'amount_type': 'percent', + 'amount': 50, + }) + self.tax_35pc_incl = self.env['account.tax'].create({ + 'name': "35% Tax incl", + 'amount_type': 'percent', + 'amount': 35, + 'price_include': True, + }) + + # Set tax and prices on products as neeed for the test + (self.product_A + self.iPadMini + self.iPod + self.wirelessKeyboard + self.computerCase).write({'list_price': 100}) + (self.iPadMini + self.computerCase).write({'taxes_id': [(4, self.tax_15pc_excl.id, False)]}) + self.iPod.taxes_id = self.tax_10pc_incl + self.wirelessKeyboard.taxes_id = None + self.product_A.taxes_id = (self.tax_35pc_incl + self.tax_50pc_excl) + + # Add products in order + self.env['sale.order.line'].create({ + 'product_id': self.iPadMini.id, + 'name': 'iPad', + 'product_uom_qty': 7.0, + 'order_id': order.id, + }) + sol2 = self.env['sale.order.line'].create({ + 'product_id': self.iPod.id, + 'name': 'iPod', + 'product_uom_qty': 5.0, + 'order_id': order.id, + }) + self.env['sale.order.line'].create({ + 'product_id': self.wirelessKeyboard.id, + 'name': 'Wireless Keyboard', + 'product_uom_qty': 10.0, + 'order_id': order.id, + }) + self.env['sale.order.line'].create({ + 'product_id': self.product_A.id, + 'name': 'product A with multiple taxes', + 'product_uom_qty': 3.0, + 'order_id': order.id, + }) + self.env['sale.order.line'].create({ + 'product_id': self.computerCase.id, + 'name': 'Computer Case', + 'product_uom_qty': 2.0, + 'order_id': order.id, + }) + + # Create needed programs + self.p2.active = False + self.p_ipad = self.env['sale.coupon.program'].create({ + 'name': 'Buy 1 ipad, get one for free', + 'promo_code_usage': 'no_code_needed', + 'reward_type': 'product', + 'program_type': 'promotion_program', + 'reward_product_id': self.iPadMini.id, + 'rule_products_domain': '[["name","ilike","ipad mini"]]', + }) + self.p_ipod = self.env['sale.coupon.program'].create({ + 'name': 'Buy 1 ipod, get one for free', + 'promo_code_usage': 'no_code_needed', + 'reward_type': 'product', + 'program_type': 'promotion_program', + 'reward_product_id': self.iPod.id, + 'rule_products_domain': '[["name","ilike","ipod"]]', + }) + self.p_keyboard = self.env['sale.coupon.program'].create({ + 'name': 'Buy 1 keyboard, get one for free', + 'promo_code_usage': 'no_code_needed', + 'reward_type': 'product', + 'program_type': 'promotion_program', + 'reward_product_id': self.wirelessKeyboard.id, + 'rule_products_domain': '[["name","ilike","keyboard"]]', + }) + + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # iPod | 5 | 100.00 | 10% incl | 454.55 | 500.00 | 45.45 + # Keyboards | 10 | 100.00 | / | 1000.00 | 1000.00 | / + # iPad | 7 | 100.00 | 15% excl | 700.00 | 805.00 | 105.00 + # Computer Case | 2 | 100.00 | 15% excl | 200.00 | 230.00 | 30.00 + # Product A | 3 | 100.00 | 35% incl | 222.22 | 450.00 | 227.78 + # 50% excl + # -------------------------------------------------------------------------------- + # TOTAL | 2576.77 | 2985.00 | 408.23 + + self.assertEqual(order.amount_total, 2985, "The order total without any programs should be 2985") + self.assertEqual(order.amount_untaxed, 2576.77, "The order untaxed total without any programs should be 2576.77") + self.assertEqual(len(order.order_line.ids), 5, "The order without any programs should have 5 lines") + + # Apply all the programs + order.recompute_coupon_lines() + + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # Free iPod | 2 | -100.00 | 10% incl | -181.82 | -200.00 | -18.18 + # Free Keyboard | 5 | -100.00 | / | -500.00 | -500.00 | / + # Free iPad | 3 | -100.00 | 15% excl | -300.00 | -345.00 | -45.00 + # -------------------------------------------------------------------------------- + # TOTAL AFTER APPLYING FREE PRODUCT PROGRAMS | 1594.95 | 1940.00 | 345.05 + + self.assertEqual(order.amount_total, 1940.00, "The order total with programs should be 1940") + self.assertEqual(order.amount_untaxed, 1594.95, "The order untaxed total with programs should be 1594.95") + self.assertEqual(len(order.order_line.ids), 8, "Order should contains 5 regular product lines and 3 free product lines") + + # Apply 10% on top of everything + self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc') + + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # 10% on tax 10% incl | 1 | -30.00 | 10% incl | -27.27 | -30.00 | -2.73 + # 10% on no tax | 1 | -50.00 | / | -50.00 | -50.00 | / + # 10% on tax 15% excl | 1 | -40.00 | 15% excl | -60.00 | -69.00 | -9.00 + # 10% on tax 35%+50% | 1 | -30.00 | 35% incl | -22.22 | -45.00 | -22.78 + # 50% excl + # -------------------------------------------------------------------------------- + # TOTAL AFTER APPLYING 10% GLOBAL PROGRAM | 1435.46 | 1746.00 | -310.55 + + self.assertEqual(order.amount_total, 1746, "The order total with programs should be 1746") + self.assertEqual(order.amount_untaxed, 1435.46, "The order untaxed total with programs should be 1435.46") + self.assertEqual(len(order.order_line.ids), 12, "Order should contains 5 regular product lines, 3 free product lines and 4 discount lines (one for every tax)") + + # Now we want to apply a 20% discount only on iPad + 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, + 'discount_apply_on': 'specific_product', + 'discount_specific_product_id': self.iPadMini.id, + }) + order.recompute_coupon_lines() + # Note: we have 7 regular ipads and 3 free ipads. We should then discount only 4 really paid iPads + + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # 20% on iPad | 1 | -80.00 | 15% excl | -80.00 | -92.00 | -12.00 + # -------------------------------------------------------------------------------- + # TOTAL AFTER APPLYING 20% ON IPAD | 1355.45 | 1654.00 | -298.55 + + self.assertEqual(order.amount_total, 1654, "The order total with programs should be 1654") + self.assertEqual(order.amount_untaxed, 1355.46, "The order untaxed total with programs should be 1435.45") + self.assertEqual(len(order.order_line.ids), 13, "Order should have a new discount line for 20% on iPad") + + # Check that if you delete one of the discount tax line and recompute, it got added back. + order.order_line.filtered(lambda l: '10%' in l.name)[0].unlink() + self.assertEqual(len(order.order_line.ids), 12, "One of the 10% discount line per tax should be removed") + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 13, "The 10% discount line that got removed should be recomputed and added back") + + # Check that if you change a product qty, his discount tax line got updated + sol2.product_uom_qty = 7 + order.recompute_coupon_lines() + # iPod | 5 | 100.00 | 10% incl | 454.55 | 500.00 | 45.45 + # Free iPod | 2 | -100.00 | 10% incl | -181.82 | -200.00 | -18.18 + # 10% on tax 10% incl | 1 | -30.00 | 10% incl | -27.27 | -30.00 | -2.73 + # -------------------------------------------------------------------------------- + # TOTAL OF IPOD LINES | 245.46 | 270.00 | 24.54 + # ==> Should become: + # iPod | 7 | 100.00 | 10% incl | 636.36 | 700.00 | 63.64 + # Free iPod | 3 | -100.00 | 10% incl | -272.73 | -300.00 | -27.27 + # 10% on tax 10% incl | 1 | -40.00 | 10% incl | -36.36 | -40.00 | -3.64 + # -------------------------------------------------------------------------------- + # TOTAL OF IPOD LINES AFTER ADDING 2 IPODS | 327.27 | 360.00 | 32.73 + # -------------------------------------------------------------------------------- + # => DIFFERENCES BEFORE/AFTER | 81.81 | 90.00 | 8.19 + self.assertEqual(order.amount_untaxed, 1355.46 + 81.81, "The order should have one more paid ipod with 10% incl tax and discounted by 10%") + + # Check that if you remove a product, his reward lines got removed, especially the discount per tax one + sol2.unlink() + order.recompute_coupon_lines() + # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA | + # -------------------------------------------------------------------------------- + # Keyboards | 10 | 100.00 | / | 1000.00 | 1000.00 | / + # iPad | 7 | 100.00 | 15% excl | 700.00 | 805.00 | 105.00 + # Computer Case | 2 | 100.00 | 15% excl | 200.00 | 230.00 | 30.00 + # Product A | 3 | 100.00 | 35% incl | 222.22 | 450.00 | 227.78 + # 50% excl + # Free Keyboard | 5 | -100.00 | / | -500.00 | -500.00 | / + # Free iPad | 3 | -100.00 | 15% excl | -300.00 | -345.00 | -45.00 + # 10% on no tax | 1 | -50.00 | / | -50.00 | -50.00 | / + # 10% on tax 15% excl | 1 | -40.00 | 15% excl | -60.00 | -69.00 | -9.00 + # 10% on tax 35%+50% | 1 | -30.00 | 35% incl | -22.22 | -45.00 | -22.78 + # 50% excl + # 20% on iPad | 1 | -80.00 | 15% excl | -80.00 | -92.00 | -12.00 + # -------------------------------------------------------------------------------- + # TOTAL | 1110.00 | 1384.00 | 274.00 + self.assertEqual(order.amount_total, 1384, "The order total with programs should be 1654") + self.assertEqual(order.amount_untaxed, 1110, "The order untaxed total with programs should be 1435.45") + self.assertEqual(len(order.order_line.ids), 10, "Order should contains 10 lines: 4 products lines, 2 free products lines, 3 10% discount lines and a 20% discount line") + + def test_program_numbers_extras(self): + # Check that you can't apply a global discount promo code if there is already an auto applied global discount + self.p1.copy({'promo_code_usage': 'no_code_needed', 'name': 'Auto applied 10% global discount'}) + order = self.empty_order + self.env['sale.order.line'].create({ + 'product_id': self.iPadMini.id, + 'name': 'iPad', + 'product_uom_qty': 1.0, + 'order_id': order.id, + }) + order.recompute_coupon_lines() + self.assertEqual(len(order.order_line.ids), 2, "We should get 1 iPad line and 1 10% auto applied global discount line") + self.assertEqual(order.amount_total, 288, "320$ - 10%") + with self.assertRaises(UserError): + # Can't apply a second global discount + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'test_10pc' + }).process_coupon() diff --git a/tests/test_program_rewards.py b/tests/test_program_rewards.py deleted file mode 100644 index 057e47ada77..00000000000 --- a/tests/test_program_rewards.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- 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 TestSaleCouponProgramRules(TestSaleCouponCommon): - # Test all the kind of reward a customer can receive. - # The product reward is already tested in the basic operations test - - def test_program_rewards(self): - # Test case: Based on the partners domain - - self.immediate_promotion_program.write({'reward_type': "discount"}) - - order = self.empty_order - order.write({'order_line': [ - (0, False, { - 'product_id': self.product_A.id, - 'name': '1 Product A', - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 1.0, - }), - (0, False, { - 'product_id': self.product_B.id, - 'name': '2 Product B', - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 1.0, - }) - ]}) - order.recompute_coupon_lines() - self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the partner is correct, the discount is not created") - - - def test_program_rewards_discount(self): - - # Test case 1: 1% discount on total order - program = self.env['sale.coupon.program'].create({ - 'name': 'Buy at least 1000 EUR, ', - 'rule_minimum_amount': 1000.0, - 'promo_code_usage': 'no_code_needed', - 'reward_type': 'discount', - 'discount_type': 'percentage', - 'discount_percentage': 1.0, - 'discount_apply_on': 'on_order', - 'active': True, - }) - order = self.env['sale.order'].create({ - 'partner_id': self.steve.id - }) - self.env['sale.order.line'].create({ - 'product_id': self.product_A.id, - 'name': '1 Product A', - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 11.0, - 'order_id': order.id, - }) - self.env['sale.order.line'].create({ - 'product_id': self.product_B.id, - 'name': '2 Product B', - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 1.0, - 'order_id': order.id, - }) diff --git a/tests/test_program_with_code_operations.py b/tests/test_program_with_code_operations.py index 50457f5c14c..276b20c2221 100644 --- a/tests/test_program_with_code_operations.py +++ b/tests/test_program_with_code_operations.py @@ -10,6 +10,19 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon): # apply the reward when the code is correct or remove the reward automatically when the reward is # not valid anymore. + def test_program_usability(self): + # After clicking "Generate coupons", there is no domain so it shows "Match all records". + # But when you click, domain is false (default field value; empty string) so it won't generate anything. + # This is even more weird because if you add something in the domain and then delete it, + # you visually come back to the initial state except the domain became '[]' instead of ''. + # In this case, it will generate the coupon for every partner. + # Thus, we should ensure that if you leave the domain untouched, it generates a coupon for each partner + # as hinted on the screen ('Match all records (X records)') + self.env['sale.coupon.generate'].with_context(active_id=self.code_promotion_program.id).create({ + 'generation_type': 'nbr_customer', + }).generate_coupon() + self.assertEqual(len(self.code_promotion_program.coupon_ids), len(self.env['res.partner'].search([])), "It should have generated a coupon for every partner") + def test_program_basic_operation_coupon_code(self): # Test case: Generate a coupon for my customer, and add a reward then remove it automatically @@ -19,6 +32,7 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon): 'generation_type': 'nbr_customer', 'partners_domain': "[('id', 'in', [%s])]" % (self.steve.id), }).generate_coupon() + coupon = self.code_promotion_program.coupon_ids # Test the valid code on a wrong sales order wrong_partner_order = self.env['sale.order'].create({ @@ -26,7 +40,7 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon): }) with self.assertRaises(UserError): self.env['sale.coupon.apply.code'].with_context(active_id=wrong_partner_order.id).create({ - 'coupon_code': self.code_promotion_program.coupon_ids.code + 'coupon_code': coupon.code }).process_coupon() # Test now on a valid sales order @@ -40,57 +54,158 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon): }) ]}) self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ - 'coupon_code': self.code_promotion_program.coupon_ids.code + 'coupon_code': coupon.code }).process_coupon() order.recompute_coupon_lines() self.assertEqual(len(order.order_line.ids), 2) + self.assertEqual(coupon.state, 'used') # Remove the product A from the sale order order.write({'order_line': [(2, order.order_line[0].id, False)]}) order.recompute_coupon_lines() self.assertEqual(len(order.order_line.ids), 0) + self.assertEqual(coupon.state, 'new') + + def test_on_next_order_reward_promotion_program(self): + # The flow: + # 1. Create a program `A` that gives a free `Product B` on next order if you buy a an `product A` + # This program should be code_needed with code `free_B_on_next_order` + # 2. Create a program `B` that gives 10% discount on next order automatically + # 3. Create a SO with a `third product` and recompute coupon, you SHOULD get a coupon (from program `B`) for your next order that will discount 10% + # 4. Try to apply `A`, it should error since we did not buy any product A. + # 5. Add a product A to the cart and try to apply `A` again, this time it should work + # 6. Verify you have 2 generated coupons and validate the SO (so the 2 generated coupons will be valid) + # 7. Create a new SO (with the same partner) and try to apply coupon generated by `A`. it SHOULD error since we don't have any `Product B` in the cart + # 8. Add a Product B in the cart + # 9. Try to apply once again coupon generated by `A`, it should give you the free product B + # 10. Try to apply coupon generated by `B`, it should give you 10% discount. + # => SO will then be 0$ until we recompute the order lines + + # 1. + self.immediate_promotion_program.write({ + 'promo_applicability': 'on_next_order', + 'promo_code_usage': 'code_needed', + 'promo_code': 'free_B_on_next_order', + }) + # 2. + self.p1 = self.env['sale.coupon.program'].create({ + 'name': 'Code for 10% on next order', + 'discount_type': 'percentage', + 'discount_percentage': 10.0, + 'program_type': 'promotion_program', + 'promo_code_usage': 'no_code_needed', + 'promo_applicability': 'on_next_order', + }) + # 3. + order = self.empty_order.copy() + self.third_product = self.env['product.product'].create({ + 'name': 'Thrid Product', + 'list_price': 5, + 'sale_ok': True + }) + order.write({'order_line': [ + (0, False, { + 'product_id': self.third_product.id, + 'name': '1 Third Product', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + order.recompute_coupon_lines() + self.assertEqual(len(self.p1.coupon_ids.ids), 1, "You should get a coupon for you next order that will offer 10% discount") + # 4. + with self.assertRaises(UserError): + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'free_B_on_next_order' + }).process_coupon() + # 5. + order.write({'order_line': [ + (0, False, { + 'product_id': self.product_A.id, + 'name': '1 Product A', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'free_B_on_next_order' + }).process_coupon() + # 6. + self.assertEqual(len(order.generated_coupon_ids), 2, "You should get a second coupon for your next order that will offer a free Product B") + order.action_confirm() + # 7. + order_bis = self.empty_order + with self.assertRaises(UserError): + self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({ + 'coupon_code': order.generated_coupon_ids[1].code + }).process_coupon() + # 8. + order_bis.write({'order_line': [ + (0, False, { + 'product_id': self.product_B.id, + 'name': '1 Product B', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + # 9. + self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({ + 'coupon_code': order.generated_coupon_ids[1].code + }).process_coupon() + self.assertEqual(len(order_bis.order_line), 2, "You should get a free Product B") + # 10. + self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({ + 'coupon_code': order.generated_coupon_ids[0].code + }).process_coupon() + self.assertEqual(len(order_bis.order_line), 3, "You should get a 10% discount line") + self.assertEqual(order_bis.amount_total, 0, "SO total should be null: (Paid product - Free product = 0) + 10% of nothing") + + def test_on_next_order_reward_promotion_program_with_requirements(self): + self.immediate_promotion_program.write({ + 'promo_applicability': 'on_next_order', + 'promo_code_usage': 'code_needed', + 'promo_code': 'free_B_on_next_order', + 'rule_minimum_amount': 700, + 'rule_minimum_amount_tax_inclusion': 'tax_excluded' + }) + order = self.empty_order.copy() + self.product_A.lst_price = 700 + order.write({'order_line': [ + (0, False, { + 'product_id': self.product_A.id, + 'name': '1 Product A', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ + 'coupon_code': 'free_B_on_next_order' + }).process_coupon() + self.assertEqual(len(self.immediate_promotion_program.coupon_ids.ids), 1, "You should get a coupon for you next order that will offer a free product B") + order_bis = self.empty_order + order_bis.write({'order_line': [ + (0, False, { + 'product_id': self.product_B.id, + 'name': '1 Product B', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + with self.assertRaises(UserError): + # It should error since we did not validated the previous SO, so the coupon is `reserved` but not `new` + self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({ + 'coupon_code': order.generated_coupon_ids[0].code + }).process_coupon() + order.action_confirm() + # It should not error even if the SO does not have the requirements (700$ and 1 product A), since these requirements where only used to generate the coupon that we are now applying + self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({ + 'coupon_code': order.generated_coupon_ids[0].code + }).process_coupon() + self.assertEqual(len(order_bis.order_line), 2, "You should get 1 regular product_B and 1 free product_B") + order_bis.recompute_coupon_lines() + self.assertEqual(len(order_bis.order_line), 2, "Free product from a coupon generated from a promotion program on next order should not dissapear") def test_on_next_order_reward_promo_program(self): - # Test case: Apply a program that generates a coupon, which is used afterward - - self.immediate_promotion_program.write({ - 'promo_applicability': 'on_next_order', - 'promo_code_usage': 'code_needed', - 'promo_code': 'bleurk', - }) - - order = self.empty_order - order.write({'order_line': [ - (0, False, { - 'product_id': self.product_A.id, - 'name': '1 Product A', - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 1.0, - }) - ]}) - - self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ - 'coupon_code': 'bleurk' - }).process_coupon() - # Ensure that the coupon is correctly generated - order.recompute_coupon_lines() - self.assertEqual(len(self.immediate_promotion_program.coupon_ids.ids), 1) - - generated_coupon = self.immediate_promotion_program.coupon_ids[0] - order = self.env['sale.order'].create({ - 'partner_id': self.steve.id - }) - order.write({'order_line': [ - (0, False, { - 'product_id': self.product_B.id, - 'name': '1 Product B', - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 1.0, - }) - ]}) - self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ - 'coupon_code': generated_coupon.code - }) - # Ensure that the generated coupon is valid - order.recompute_coupon_lines() - self.assertEqual(len(order.order_line.ids), 2) + # TODO: remove me in master, this was never executed due to bad indentation (the method is not indented under the class) + # note that this flow did not worked and was not implemented.. now tested with `test_on_next_order_reward_promotion_program()` + pass From 5ff0b842dfdc9ed1eefcb2638fb638084e1b1577 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Wed, 16 May 2018 15:19:14 +0200 Subject: [PATCH 3/7] [FIX] sale_coupon: delete 'sibling' lines when deleting a reward line A discount on products with different taxes will result in as many discount lines as there is different taxes. Before this commit, removing one of these lines would not do anything else. Then, recomputing the coupon would regenerate/add back this line. On e-commerce, it means you would never be able to remove the program by deleting its reward lines if there are multiple taxes (and so multiple lines) since it would `recompute_coupon_lines` right away, adding it back directly. Now, this commit will ensure that removing one of these lines will also remove the other ones coming from this program on other taxes. It means removing a regular product will remove it's reward line that will also now remove the other program's discount line on other taxes and ultimately will remove the program from the SO since all the program reward lines got removed. (It won't affect auto applied program since it will be regenerated afterward) task-1866977 Related to #2224 --- models/sale_order.py | 9 +++++---- tests/test_program_numbers.py | 23 ++++++++++++----------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/models/sale_order.py b/models/sale_order.py index 689920f9905..7152dd33d17 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -346,6 +346,7 @@ class SaleOrderLine(models.Model): is_reward_line = fields.Boolean('Is a program reward line') def unlink(self): + related_program_lines = self.env['sale.order.line'] # Reactivate coupons related to unlinked reward line for line in self.filtered(lambda line: line.is_reward_line): coupons_to_reactivate = line.order_id.applied_coupon_ids.filtered( @@ -354,10 +355,10 @@ class SaleOrderLine(models.Model): coupons_to_reactivate.write({'state': 'new'}) line.order_id.applied_coupon_ids -= coupons_to_reactivate # Remove the program from the order if the deleted line is the reward line of the program - # And if there is no other lines from this program (It's the case when discount is split per different taxes) + # And delete the other lines from this program (It's the case when discount is split per different taxes) related_program = self.env['sale.coupon.program'].search([('discount_line_product_id', '=', line.product_id.id)]) - related_program_lines = line.order_id.order_line.filtered(lambda l: l.product_id.id == related_program.discount_line_product_id.id) - line - if related_program and not related_program_lines: + if related_program: line.order_id.no_code_promo_program_ids -= related_program line.order_id.code_promo_program_id -= related_program - return super(SaleOrderLine, self).unlink() + related_program_lines |= line.order_id.order_line.filtered(lambda l: l.product_id.id == related_program.discount_line_product_id.id) - line + return super(SaleOrderLine, self | related_program_lines).unlink() diff --git a/tests/test_program_numbers.py b/tests/test_program_numbers.py index 6b2c29d6050..9d4c34f8070 100644 --- a/tests/test_program_numbers.py +++ b/tests/test_program_numbers.py @@ -376,11 +376,16 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): self.assertEqual(order.amount_untaxed, 1355.46, "The order untaxed total with programs should be 1435.45") self.assertEqual(len(order.order_line.ids), 13, "Order should have a new discount line for 20% on iPad") - # Check that if you delete one of the discount tax line and recompute, it got added back. + # Check that if you delete one of the discount tax line, the others tax lines from the same promotion got deleted as well. order.order_line.filtered(lambda l: '10%' in l.name)[0].unlink() - self.assertEqual(len(order.order_line.ids), 12, "One of the 10% discount line per tax should be removed") + self.assertEqual(len(order.order_line.ids), 9, "All of the 10% discount line per tax should be removed") + # At this point, removing the iPod's discount line (split per tax) removed also the others discount lines + # linked to the same program (eg: other taxes lines). So the coupon got removed from the SO since there were no discount lines left + + # Add back the coupon to continue the test flow + self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc') order.recompute_coupon_lines() - self.assertEqual(len(order.order_line.ids), 13, "The 10% discount line that got removed should be recomputed and added back") + self.assertEqual(len(order.order_line.ids), 13, "The 10% discount line should be back") # Check that if you change a product qty, his discount tax line got updated sol2.product_uom_qty = 7 @@ -412,16 +417,12 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): # 50% excl # Free Keyboard | 5 | -100.00 | / | -500.00 | -500.00 | / # Free iPad | 3 | -100.00 | 15% excl | -300.00 | -345.00 | -45.00 - # 10% on no tax | 1 | -50.00 | / | -50.00 | -50.00 | / - # 10% on tax 15% excl | 1 | -40.00 | 15% excl | -60.00 | -69.00 | -9.00 - # 10% on tax 35%+50% | 1 | -30.00 | 35% incl | -22.22 | -45.00 | -22.78 - # 50% excl # 20% on iPad | 1 | -80.00 | 15% excl | -80.00 | -92.00 | -12.00 # -------------------------------------------------------------------------------- - # TOTAL | 1110.00 | 1384.00 | 274.00 - self.assertEqual(order.amount_total, 1384, "The order total with programs should be 1654") - self.assertEqual(order.amount_untaxed, 1110, "The order untaxed total with programs should be 1435.45") - self.assertEqual(len(order.order_line.ids), 10, "Order should contains 10 lines: 4 products lines, 2 free products lines, 3 10% discount lines and a 20% discount line") + # TOTAL | 1242.22 | 1548.00 | 305.78 + self.assertEqual(order.amount_total, 1548, "The order total with programs should be 1548") + self.assertEqual(order.amount_untaxed, 1242.22, "The order untaxed total with programs should be 1242.22") + self.assertEqual(len(order.order_line.ids), 7, "Order should contains 7 lines: 4 products lines, 2 free products lines and a 20% discount line") def test_program_numbers_extras(self): # Check that you can't apply a global discount promo code if there is already an auto applied global discount From 452e90639668288665818a8767258a68a4098dc6 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Mon, 10 Sep 2018 18:18:46 +0200 Subject: [PATCH 4/7] [FIX] (website_)sale_coupon: `_compute_tax_id` with tax on reward line It should get tax from the SOL and not the SO's product as the tax is applied on the SOL only (the reward product has no tax by default). --- models/sale_order.py | 13 +++++++++++++ tests/test_program_numbers.py | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/models/sale_order.py b/models/sale_order.py index 7152dd33d17..386bb036c31 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -362,3 +362,16 @@ class SaleOrderLine(models.Model): line.order_id.code_promo_program_id -= related_program related_program_lines |= line.order_id.order_line.filtered(lambda l: l.product_id.id == related_program.discount_line_product_id.id) - line return super(SaleOrderLine, self | related_program_lines).unlink() + + @api.multi + def _compute_tax_id(self): + reward_lines = self.filtered('is_reward_line') + super(SaleOrderLine, self - reward_lines)._compute_tax_id() + # Discount reward line is split per tax, the discount is set on the line but not on the product + # as the product is the generic discount line. + # In case of a free product, retrieving the tax on the line instead of the product won't affect the behavior. + for line in reward_lines: + fpos = line.order_id.fiscal_position_id or line.order_id.partner_id.property_account_position_id + # If company_id is set, always filter taxes by the company + taxes = line.tax_id.filtered(lambda r: not line.company_id or r.company_id == line.company_id) + line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id) if fpos else taxes diff --git a/tests/test_program_numbers.py b/tests/test_program_numbers.py index 9d4c34f8070..39f15fffb0e 100644 --- a/tests/test_program_numbers.py +++ b/tests/test_program_numbers.py @@ -352,6 +352,17 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): self.assertEqual(order.amount_untaxed, 1435.46, "The order untaxed total with programs should be 1435.46") self.assertEqual(len(order.order_line.ids), 12, "Order should contains 5 regular product lines, 3 free product lines and 4 discount lines (one for every tax)") + # -- This is a test inside the test + order.order_line._compute_tax_id() + self.assertEqual(order.amount_total, 1746, "Recomputing tax on sale order lines should not change total amount") + self.assertEqual(order.amount_untaxed, 1435.46, "Recomputing tax on sale order lines should not change untaxed amount") + self.assertEqual(len(order.order_line.ids), 12, "Recomputing tax on sale order lines should not change number of order line") + order.recompute_coupon_lines() + self.assertEqual(order.amount_total, 1746, "Recomputing tax on sale order lines should not change total amount") + self.assertEqual(order.amount_untaxed, 1435.46, "Recomputing tax on sale order lines should not change untaxed amount") + self.assertEqual(len(order.order_line.ids), 12, "Recomputing tax on sale order lines should not change number of order line") + # -- End test inside the test + # Now we want to apply a 20% discount only on iPad self.env['sale.coupon.program'].create({ 'name': '20% reduction on ipad in cart', From 85c89c300aba1936cb032e9c89a6a58f6d9577ef Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Tue, 11 Sep 2018 17:22:54 +0200 Subject: [PATCH 5/7] [FIX] sale_coupon: dont show strikethrough if pricelist show discount Using a pricelist set as `without_discount` will show a strikethrough price on reward line (discount and free product). This is happening because the program reward line (discount_line_product_id) is created with the default 1$ price as this price is not usefull when computing coupons. Only the line price is checked. Now, we create a reward line with a -1 000 000$ price to prevent this UI behavior. Step to reproduce: - Activate pricelist (Sales>Config>Settings>Pricelist formulas) - Activate discounts in Sales Configuration (Manual discount on order lines) - Set pricelist to 'Show public price & discount to the customer' - Add a discount in your cart - There in a strikethrough price of $1 above the discount line price task-1878245 --- models/sale_coupon_program.py | 1 + 1 file changed, 1 insertion(+) diff --git a/models/sale_coupon_program.py b/models/sale_coupon_program.py index e96b3a8bd32..df3687597a8 100644 --- a/models/sale_coupon_program.py +++ b/models/sale_coupon_program.py @@ -90,6 +90,7 @@ class SaleCouponProgram(models.Model): 'sale_ok': False, 'purchase_ok': False, 'invoice_policy': 'order', + 'lst_price': -1000000, # Prevent pricelist strikethrough as negative value will always be lower then default 1$ }) program.write({'discount_line_product_id': discount_line_product_id.id}) return program From 47ef5cb2c0ac4fbc6a5698626b4daabfcbe3b2d6 Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Fri, 28 Sep 2018 13:43:36 +0200 Subject: [PATCH 6/7] [FIX] sale_coupon: correct sending of coupon via email --- models/sale_order.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/models/sale_order.py b/models/sale_order.py index e2dfc81bd5e..43ca158ca6f 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -218,11 +218,13 @@ class SaleOrder(models.Model): return coupon def _send_reward_coupon_mail(self): - self.ensure_one() + self.ensure_one() template = self.env.ref('sale_coupon.mail_template_sale_coupon', raise_if_not_found=False) if template: - # XXX one per self.generated_coupon_ids? - self.message_post_with_template(template.id, composition_mode='comment') + for coupon in self.generated_coupon_ids: + self.message_post_with_template( + template.id, composition_mode='comment', model='sale.coupon', res_id=coupon.id + ) def _get_applicable_programs(self): """ From af0e47f42f89d624b4c0a74fc2ebb7eb1889da54 Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Fri, 28 Sep 2018 14:07:55 +0200 Subject: [PATCH 7/7] [FIX] sale_coupon: compute fixed amount as such Commit 092200073 broke the discount computation for discount of type fixed_amount. Indeed the appropriate method was not called anymore and such discount type would be computed as percentage discount with default value of 10% (field default for percentage). opw-1888484 --- models/sale_order.py | 79 ++++++++++++++++++++++------------- tests/test_program_numbers.py | 27 ++++++++++++ 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/models/sale_order.py b/models/sale_order.py index 386bb036c31..48ea4f101fd 100644 --- a/models/sale_order.py +++ b/models/sale_order.py @@ -110,7 +110,7 @@ class SaleOrder(models.Model): return 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')) + total_amount = sum(self._get_paid_order_lines().mapped('price_total')) fixed_amount = program._compute_program_amount('discount_fixed_amount', self.currency_id) if total_amount < fixed_amount: return total_amount @@ -126,6 +126,15 @@ class SaleOrder(models.Model): return discount_amount def _get_reward_values_discount(self, program): + if program.discount_type == 'fixed_amount': + return [{ + 'name': _("Discount: ") + program.name, + 'product_id': program.discount_line_product_id.id, + 'price_unit': - self._get_reward_values_discount_fixed_amount(program), + 'product_uom_qty': 1.0, + 'product_uom': program.discount_line_product_id.uom_id.id, + 'is_reward_line': True, + }] reward_dict = {} lines = self._get_paid_order_lines() if program.discount_apply_on == 'cheapest_product': @@ -269,41 +278,51 @@ class SaleOrder(models.Model): def _update_existing_reward_lines(self): '''Update values for already applied rewards''' + def update_line(order, lines, values): + '''Update the lines and return them if they should be deleted''' + lines_to_remove = self.env['sale.order.line'] + # Check commit 6bb42904a03 for next if/else + # Remove reward line if price or qty equal to 0 + if values['product_uom_qty'] and values['price_unit']: + order.write({'order_line': [(1, line.id, values) for line in lines]}) + else: + if program.reward_type != 'free_shipping': + # Can't remove the lines directly as we might be in a recordset loop + lines_to_remove += lines + else: + value.update(price_unit=0.0) + order.write({'order_line': [(1, line.id, values) for line in lines]}) + return lines_to_remove + self.ensure_one() order = self applied_programs = order._get_applied_programs_with_rewards_on_current_order() for program in applied_programs: values = order._get_reward_line_values(program) lines = order.order_line.filtered(lambda line: line.product_id == program.discount_line_product_id) - lines_to_remove = lines - # Values is what discount lines should really be, lines is what we got in the SO at the moment - # 1. If values & lines match, we should update the line (or delete it if no qty or price?) - # 2. If the value is not in the lines, we should add it - # 3. if the lines contains a tax not in value, we should remove it - for value in values: - value_found = False - for line in lines: - # Case 1. - if not len(set(line.tax_id.mapped('id')).symmetric_difference(set([v[1] for v in value['tax_id']]))): - value_found = True - # Working on Case 3. - lines_to_remove -= line - # Check commit 6bb42904a03 for next if/else - # Remove reward line if price or qty equal to 0 - if value['product_uom_qty'] and value['price_unit']: - order.write({'order_line': [(1, line.id, value)]}) - else: - if program.reward_type != 'free_shipping': - lines_to_remove += line - else: - value.update(price_unit=0.0) - order.write({'order_line': [(1, line.id, value)]}) - continue - # Case 2. - if not value_found: - order.write({'order_line': [(0, False, value)]}) - # Case 3. - lines_to_remove.unlink() + if program.reward_type == 'discount' and program.discount_type == 'percentage': + lines_to_remove = lines + # Values is what discount lines should really be, lines is what we got in the SO at the moment + # 1. If values & lines match, we should update the line (or delete it if no qty or price?) + # 2. If the value is not in the lines, we should add it + # 3. if the lines contains a tax not in value, we should remove it + for value in values: + value_found = False + for line in lines: + # Case 1. + if not len(set(line.tax_id.mapped('id')).symmetric_difference(set([v[1] for v in value['tax_id']]))): + value_found = True + # Working on Case 3. + lines_to_remove -= line + lines_to_remove += update_line(order, line, value) + continue + # Case 2. + if not value_found: + order.write({'order_line': [(0, False, value)]}) + # Case 3. + lines_to_remove.unlink() + else: + update_line(order, lines, values[0]).unlink() def _remove_invalid_reward_lines(self): """ Find programs & coupons that are not applicable anymore. diff --git a/tests/test_program_numbers.py b/tests/test_program_numbers.py index 39f15fffb0e..01da22c1de4 100644 --- a/tests/test_program_numbers.py +++ b/tests/test_program_numbers.py @@ -453,3 +453,30 @@ class TestSaleCouponProgramNumbers(TestSaleCouponCommon): self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({ 'coupon_code': 'test_10pc' }).process_coupon() + + def test_program_fixed_price(self): + # Check fixed amount discount + order = self.empty_order + fixed_amount_program = self.env['sale.coupon.program'].create({ + 'name': '$249 discount', + 'promo_code_usage': 'no_code_needed', + 'program_type': 'promotion_program', + 'discount_type': 'fixed_amount', + 'discount_fixed_amount': 249.0, + }) + sol1 = self.env['sale.order.line'].create({ + 'product_id': self.computerCase.id, + 'name': 'Computer Case', + 'product_uom_qty': 1.0, + 'order_id': order.id, + }) + order.recompute_coupon_lines() + self.assertEqual(order.amount_total, 0, "Total should be null. The fixed amount discount is higher than the SO total, it should be reduced to the SO total") + self.assertEqual(len(order.order_line.ids), 2, "There should be the product line and the reward line") + sol1.product_uom_qty = 17 + order.recompute_coupon_lines() + self.assertEqual(order.amount_total, 176, "Fixed amount discount should be totally deduced") + self.assertEqual(len(order.order_line.ids), 2, "Number of lines should be unchanged as we just recompute the reward line") + 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")