From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- addons/sale_coupon/models/__init__.py | 6 + addons/sale_coupon/models/coupon.py | 51 +++ addons/sale_coupon/models/coupon_program.py | 202 +++++++++++ addons/sale_coupon/models/sale_order.py | 538 ++++++++++++++++++++++++++++ 4 files changed, 797 insertions(+) create mode 100644 addons/sale_coupon/models/__init__.py create mode 100644 addons/sale_coupon/models/coupon.py create mode 100644 addons/sale_coupon/models/coupon_program.py create mode 100644 addons/sale_coupon/models/sale_order.py (limited to 'addons/sale_coupon/models') diff --git a/addons/sale_coupon/models/__init__.py b/addons/sale_coupon/models/__init__.py new file mode 100644 index 00000000..96ba4f08 --- /dev/null +++ b/addons/sale_coupon/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import coupon +from . import coupon_program +from . import sale_order diff --git a/addons/sale_coupon/models/coupon.py b/addons/sale_coupon/models/coupon.py new file mode 100644 index 00000000..bc92f436 --- /dev/null +++ b/addons/sale_coupon/models/coupon.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class Coupon(models.Model): + _inherit = 'coupon.coupon' + + order_id = fields.Many2one('sale.order', 'Order Reference', readonly=True, + help="The sales order from which coupon is generated") + sales_order_id = fields.Many2one('sale.order', 'Used in', readonly=True, + help="The sales order on which the coupon is applied") + + def _check_coupon_code(self, order): + message = {} + applicable_programs = order._get_applicable_programs() + if self.state == 'used': + message = {'error': _('This coupon has already been used (%s).') % (self.code)} + elif self.state == 'reserved': + message = {'error': _('This coupon %s exists but the origin sales order is not validated yet.') % (self.code)} + elif self.state == 'cancel': + message = {'error': _('This coupon has been cancelled (%s).') % (self.code)} + elif self.state == 'expired' or (self.expiration_date and self.expiration_date < order.date_order.date()): + message = {'error': _('This coupon is expired (%s).') % (self.code)} + # 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 %(amount)s %(currency)s should be purchased to get the reward', + amount=self.program_id.rule_minimum_amount, + currency=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)} + elif self.partner_id and self.partner_id != order.partner_id: + message = {'error': _('Invalid partner.')} + elif self.program_id in order.applied_coupon_ids.mapped('program_id'): + message = {'error': _('A Coupon is already applied for the same reward')} + elif self.program_id._is_global_discount_program() and order._is_global_discount_already_applied(): + message = {'error': _('Global discounts are not cumulable.')} + elif self.program_id.reward_type == 'product' and not order._is_reward_in_order_lines(self.program_id): + 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.")} + # 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': + message = {'error': _('At least one of the required conditions is not met to get the reward!')} + return message diff --git a/addons/sale_coupon/models/coupon_program.py b/addons/sale_coupon/models/coupon_program.py new file mode 100644 index 00000000..30eb476c --- /dev/null +++ b/addons/sale_coupon/models/coupon_program.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + + +class CouponProgram(models.Model): + _inherit = 'coupon.program' + + order_count = fields.Integer(compute='_compute_order_count') + + # The api.depends is handled in `def modified` of `sale_coupon/models/sale_order.py` + def _compute_order_count(self): + product_data = self.env['sale.order.line'].read_group([('product_id', 'in', self.mapped('discount_line_product_id').ids)], ['product_id'], ['product_id']) + mapped_data = dict([(m['product_id'][0], m['product_id_count']) for m in product_data]) + for program in self: + program.order_count = mapped_data.get(program.discount_line_product_id.id, 0) + + def action_view_sales_orders(self): + self.ensure_one() + orders = self.env['sale.order.line'].search([('product_id', '=', self.discount_line_product_id.id)]).mapped('order_id') + return { + 'name': _('Sales Orders'), + 'view_mode': 'tree,form', + 'res_model': 'sale.order', + 'search_view_id': [self.env.ref('sale.sale_order_view_search_inherit_quotation').id], + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', orders.ids)], + 'context': dict(self._context, create=False), + } + + def _check_promo_code(self, order, coupon_code): + message = {} + if self.maximum_use_number != 0 and self.order_count >= self.maximum_use_number: + message = {'error': _('Promo code %s has been expired.') % (coupon_code)} + elif not self._filter_on_mimimum_amount(order): + message = {'error': _( + 'A minimum of %(amount)s %(currency)s should be purchased to get the reward', + amount=self.rule_minimum_amount, + currency=self.currency_id.name + )} + elif self.promo_code and self.promo_code == order.promo_code: + message = {'error': _('The promo code is already applied on this order')} + elif self in order.no_code_promo_program_ids: + message = {'error': _('The promotional offer is already applied on this order')} + elif not self.active: + message = {'error': _('Promo code is invalid')} + elif self.rule_date_from and self.rule_date_from > order.date_order or self.rule_date_to and order.date_order > self.rule_date_to: + message = {'error': _('Promo code is expired')} + elif order.promo_code and self.promo_code_usage == 'code_needed': + message = {'error': _('Promotionals codes are not cumulative.')} + elif self._is_global_discount_program() and order._is_global_discount_already_applied(): + message = {'error': _('Global discounts are not cumulative.')} + 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.')} + elif not self._is_valid_partner(order.partner_id): + message = {'error': _("The customer doesn't have access to this reward.")} + 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'.")} + elif self.promo_applicability == 'on_current_order' and not self.env.context.get('applicable_coupon'): + applicable_programs = order._get_applicable_programs() + if self not in applicable_programs: + message = {'error': _('At least one of the required conditions is not met to get the reward!')} + return message + + @api.model + def _filter_on_mimimum_amount(self, order): + no_effect_lines = order._get_no_effect_on_threshold_lines() + order_amount = { + 'amount_untaxed' : order.amount_untaxed - sum(line.price_subtotal for line in no_effect_lines), + 'amount_tax' : order.amount_tax - sum(line.price_tax for line in no_effect_lines) + } + program_ids = list() + for program in self: + if program.reward_type != 'discount': + # avoid the filtered + lines = self.env['sale.order.line'] + else: + lines = order.order_line.filtered(lambda line: + line.product_id == program.discount_line_product_id or + line.product_id == program.reward_id.discount_line_product_id or + (program.program_type == 'promotion_program' and line.is_reward_line) + ) + untaxed_amount = order_amount['amount_untaxed'] - sum(line.price_subtotal for line in lines) + tax_amount = order_amount['amount_tax'] - sum(line.price_tax for line in lines) + program_amount = program._compute_program_amount('rule_minimum_amount', order.currency_id) + if program.rule_minimum_amount_tax_inclusion == 'tax_included' and program_amount <= (untaxed_amount + tax_amount) or program_amount <= untaxed_amount: + program_ids.append(program.id) + + return self.browse(program_ids) + + @api.model + def _filter_on_validity_dates(self, order): + return self.filtered(lambda program: + (not program.rule_date_from or program.rule_date_from <= order.date_order) + and + (not program.rule_date_to or program.rule_date_to >= order.date_order) + ) + + @api.model + def _filter_promo_programs_with_code(self, order): + '''Filter Promo program with code with a different promo_code if a promo_code is already ordered''' + return self.filtered(lambda program: program.promo_code_usage == 'code_needed' and program.promo_code != order.promo_code) + + def _filter_unexpired_programs(self, order): + return self.filtered(lambda program: program.maximum_use_number == 0 or program.order_count <= program.maximum_use_number) + + def _filter_programs_on_partners(self, order): + return self.filtered(lambda program: program._is_valid_partner(order.partner_id)) + + def _filter_programs_on_products(self, order): + """ + To get valid programs according to product list. + i.e Buy 1 imac + get 1 ipad mini free then check 1 imac is on cart or not + or Buy 1 coke + get 1 coke free then check 2 cokes are on cart or not + """ + order_lines = order.order_line.filtered(lambda line: line.product_id) - order._get_reward_lines() + products = order_lines.mapped('product_id') + products_qties = dict.fromkeys(products, 0) + for line in order_lines: + products_qties[line.product_id] += line.product_uom_qty + valid_program_ids = list() + for program in self: + if not program.rule_products_domain: + valid_program_ids.append(program.id) + continue + valid_products = program._get_valid_products(products) + if not valid_products: + # The program can be directly discarded + continue + ordered_rule_products_qty = sum(products_qties[product] for product in valid_products) + # Avoid program if 1 ordered foo on a program '1 foo, 1 free foo' + if program.promo_applicability == 'on_current_order' and \ + program.reward_type == 'product' and program._get_valid_products(program.reward_product_id): + ordered_rule_products_qty -= program.reward_product_quantity + if ordered_rule_products_qty >= program.rule_min_quantity: + valid_program_ids.append(program.id) + return self.browse(valid_program_ids) + + def _filter_not_ordered_reward_programs(self, order): + """ + Returns the programs when the reward is actually in the order lines + """ + programs = self.env['coupon.program'] + for program in self: + if program.reward_type == 'product' and \ + not order.order_line.filtered(lambda line: line.product_id == program.reward_product_id): + continue + elif program.reward_type == 'discount' and program.discount_apply_on == 'specific_products' and \ + not order.order_line.filtered(lambda line: line.product_id in program.discount_specific_product_ids): + continue + programs |= program + return programs + + @api.model + 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) + if not self.env.context.get("no_outdated_coupons"): + 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) + # 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_curr_order = programs.filtered(lambda p: p.promo_applicability == 'on_current_order') + programs = programs.filtered(lambda p: p.promo_applicability == 'on_next_order') + if programs_curr_order: + # Checking if rewards are in the SO should not be performed for rewards on_next_order + programs += programs_curr_order._filter_not_ordered_reward_programs(order) + return programs + + def _get_discount_product_values(self): + res = super()._get_discount_product_values() + res['invoice_policy'] = 'order' + return res + + def _is_global_discount_program(self): + self.ensure_one() + return self.promo_applicability == 'on_current_order' and \ + self.reward_type == 'discount' and \ + self.discount_type == 'percentage' and \ + self.discount_apply_on == 'on_order' + + def _keep_only_most_interesting_auto_applied_global_discount_program(self): + '''Given a record set of programs, remove the less interesting auto + applied global discount to keep only the most interesting one. + We should not take promo code programs into account as a 10% auto + applied is considered better than a 50% promo code, as the user might + not know about the promo code. + ''' + programs = self.filtered(lambda p: p._is_global_discount_program() and p.promo_code_usage == 'no_code_needed') + if not programs: return self + most_interesting_program = max(programs, key=lambda p: p.discount_percentage) + # remove least interesting programs + return self - (programs - most_interesting_program) diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py new file mode 100644 index 00000000..a6e904ec --- /dev/null +++ b/addons/sale_coupon/models/sale_order.py @@ -0,0 +1,538 @@ +# -*- coding: utf-8 -*- +# 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): + _inherit = "sale.order" + + applied_coupon_ids = fields.One2many('coupon.coupon', 'sales_order_id', string="Applied Coupons", copy=False) + generated_coupon_ids = fields.One2many('coupon.coupon', 'order_id', string="Offered Coupons", copy=False) + reward_amount = fields.Float(compute='_compute_reward_total') + no_code_promo_program_ids = fields.Many2many('coupon.program', string="Applied Immediate Promo Programs", + domain="[('promo_code_usage', '=', 'no_code_needed'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", copy=False) + code_promo_program_id = fields.Many2one('coupon.program', string="Applied Promo Program", + domain="[('promo_code_usage', '=', 'code_needed'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", copy=False) + promo_code = fields.Char(related='code_promo_program_id.promo_code', help="Applied program code", readonly=False) + + @api.depends('order_line') + def _compute_reward_total(self): + for order in self: + order.reward_amount = sum([line.price_subtotal for line in order._get_reward_lines()]) + + def _get_no_effect_on_threshold_lines(self): + self.ensure_one() + lines = self.env['sale.order.line'] + return lines + + def recompute_coupon_lines(self): + for order in self: + order._remove_invalid_reward_lines() + order._create_new_no_code_promo_reward_lines() + order._update_existing_reward_lines() + + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + order = super(SaleOrder, self).copy(default) + reward_line = order._get_reward_lines() + if reward_line: + reward_line.unlink() + order._create_new_no_code_promo_reward_lines() + return order + + def action_confirm(self): + 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): + res = super(SaleOrder, self).action_cancel() + self.generated_coupon_ids.write({'state': 'expired'}) + self.applied_coupon_ids.write({'state': 'new'}) + self.applied_coupon_ids.sales_order_id = False + self.recompute_coupon_lines() + 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) + + def _is_reward_in_order_lines(self, program): + self.ensure_one() + order_quantity = sum(self.order_line.filtered(lambda line: + line.product_id == program.reward_product_id).mapped('product_uom_qty')) + return order_quantity >= program.reward_product_quantity + + def _is_global_discount_already_applied(self): + applied_programs = self.no_code_promo_program_ids + \ + self.code_promo_program_id + \ + self.applied_coupon_ids.mapped('program_id') + return applied_programs.filtered(lambda program: program._is_global_discount_program()) + + def _get_reward_values_product(self, program): + price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_reduce + + order_lines = (self.order_line - self._get_reward_lines()).filtered(lambda x: program._get_valid_products(x.product_id)) + max_product_qty = sum(order_lines.mapped('product_uom_qty')) or 1 + total_qty = sum(self.order_line.filtered(lambda x: x.product_id == program.reward_product_id).mapped('product_uom_qty')) + # Remove needed quantity from reward quantity if same reward and rule product + if program._get_valid_products(program.reward_product_id): + # number of times the program should be applied + program_in_order = max_product_qty // (program.rule_min_quantity + program.reward_product_quantity) + # multipled by the reward qty + reward_product_qty = program.reward_product_quantity * program_in_order + # do not give more free reward than products + reward_product_qty = min(reward_product_qty, total_qty) + if program.rule_minimum_amount: + order_total = sum(order_lines.mapped('price_total')) - (program.reward_product_quantity * program.reward_product_id.lst_price) + reward_product_qty = min(reward_product_qty, order_total // program.rule_minimum_amount) + else: + program_in_order = max_product_qty // program.rule_min_quantity + reward_product_qty = min(program.reward_product_quantity * program_in_order, total_qty) + + reward_qty = min(int(int(max_product_qty / program.rule_min_quantity) * program.reward_product_quantity), reward_product_qty) + # Take the default taxes on the reward product, mapped with the fiscal position + taxes = program.reward_product_id.taxes_id.filtered(lambda t: t.company_id.id == self.company_id.id) + taxes = self.fiscal_position_id.map_tax(taxes) + return { + 'product_id': program.discount_line_product_id.id, + 'price_unit': - price_unit, + 'product_uom_qty': reward_qty, + 'is_reward_line': True, + 'name': _("Free Product") + " - " + program.reward_product_id.name, + 'product_uom': program.reward_product_id.uom_id.id, + 'tax_id': [(4, tax.id, False) for tax in taxes], + } + + def _get_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['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_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 + else: + return fixed_amount + + def _get_cheapest_line(self): + # Unit prices tax included + return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_reduce > 0), key=lambda x: x['price_reduce']) + + def _get_reward_values_discount_percentage_per_line(self, program, line): + discount_amount = line.product_uom_qty * line.price_reduce * (program.discount_percentage / 100) + return discount_amount + + def _get_reward_values_discount(self, program): + if program.discount_type == 'fixed_amount': + taxes = program.discount_line_product_id.taxes_id + if self.fiscal_position_id: + taxes = self.fiscal_position_id.map_tax(taxes) + return [{ + 'name': _("Discount: %s", 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, + 'tax_id': [(4, tax.id, False) for tax in taxes], + }] + 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 = 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 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], + } + elif program.discount_apply_on in ['specific_products', 'on_order']: + if program.discount_apply_on == 'specific_products': + # 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['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 = min(self._get_reward_values_discount_percentage_per_line(program, line), amount_total - currently_discounted_amount) + + if discount_line_amount: + + if line.tax_id in reward_dict: + reward_dict[line.tax_id]['price_unit'] -= discount_line_amount + else: + taxes = self.fiscal_position_id.map_tax(line.tax_id) + + reward_dict[line.tax_id] = { + 'name': _( + "Discount: %(program)s - On product with following taxes: %(taxes)s", + program=program.name, + taxes=", ".join(taxes.mapped('name')), + ), + 'product_id': program.discount_line_product_id.id, + '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) + 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() + self = self.with_context(lang=self.partner_id.lang) + program = program.with_context(lang=self.partner_id.lang) + if program.reward_type == 'discount': + return self._get_reward_values_discount(program) + elif program.reward_type == 'product': + return [self._get_reward_values_product(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): + # if there is already a coupon that was set as expired, reactivate that one instead of creating a new one + coupon = self.env['coupon.coupon'].search([ + ('program_id', '=', program.id), + ('state', '=', 'expired'), + ('partner_id', '=', self.partner_id.id), + ('order_id', '=', self.id), + ('discount_line_product_id', '=', program.discount_line_product_id.id), + ], limit=1) + if coupon: + coupon.write({'state': 'reserved'}) + else: + coupon = self.env['coupon.coupon'].sudo().create({ + 'program_id': program.id, + 'state': 'reserved', + 'partner_id': self.partner_id.id, + 'order_id': self.id, + 'discount_line_product_id': program.discount_line_product_id.id + }) + self.generated_coupon_ids |= coupon + return coupon + + def _send_reward_coupon_mail(self): + template = self.env.ref('coupon.mail_template_sale_coupon', raise_if_not_found=False) + if template: + for order in self: + for coupon in order.generated_coupon_ids: + order.message_post_with_template( + template.id, composition_mode='comment', + model='coupon.coupon', res_id=coupon.id, + email_layout_xmlid='mail.mail_notification_light', + ) + + def _get_applicable_programs(self): + """ + This method is used to return the valid applicable programs on given order. + """ + self.ensure_one() + programs = self.env['coupon.program'].with_context( + no_outdated_coupons=True, + ).search([ + ('company_id', 'in', [self.company_id.id, False]), + '|', ('rule_date_from', '=', False), ('rule_date_from', '<=', self.date_order), + '|', ('rule_date_to', '=', False), ('rule_date_to', '>=', self.date_order), + ], order="id")._filter_programs_from_common_rules(self) + # no impact code... + # should be programs = programs.filtered if we really want to filter... + # if self.promo_code: + # programs._filter_promo_programs_with_code(self) + return programs + + def _get_applicable_no_code_promo_program(self): + self.ensure_one() + programs = self.env['coupon.program'].with_context( + no_outdated_coupons=True, + applicable_coupon=True, + ).search([ + ('promo_code_usage', '=', 'no_code_needed'), + '|', ('rule_date_from', '=', False), ('rule_date_from', '<=', self.date_order), + '|', ('rule_date_to', '=', False), ('rule_date_to', '>=', self.date_order), + '|', ('company_id', '=', self.company_id.id), ('company_id', '=', False), + ])._filter_programs_from_common_rules(self) + return programs + + def _get_valid_applied_coupon_program(self): + self.ensure_one() + # applied_coupon_ids's coupons might be coming from: + # * a coupon generated from a previous order that benefited from a promotion_program that rewarded the next sale order. + # In that case requirements to benefit from the program (Quantity and price) should not be checked anymore + # * a coupon_program, in that case the promo_applicability is always for the current order and everything should be checked (filtered) + programs = self.applied_coupon_ids.mapped('program_id').filtered(lambda p: p.promo_applicability == 'on_next_order')._filter_programs_from_common_rules(self, True) + programs += self.applied_coupon_ids.mapped('program_id').filtered(lambda p: p.promo_applicability == 'on_current_order')._filter_programs_from_common_rules(self) + return programs + + def _create_new_no_code_promo_reward_lines(self): + '''Apply new programs that are applicable''' + self.ensure_one() + order = self + programs = order._get_applicable_no_code_promo_program() + programs = programs._keep_only_most_interesting_auto_applied_global_discount_program() + for program in programs: + # VFE REF in master _get_applicable_no_code_programs already filters programs + # why do we need to reapply this bunch of checks in _check_promo_code ???? + # We should only apply a little part of the checks in _check_promo_code... + error_status = program._check_promo_code(order, False) + if not error_status.get('error'): + if program.promo_applicability == 'on_next_order': + order.state != 'cancel' and 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''' + 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']: + lines.write(values) + 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: + values.update(price_unit=0.0) + lines.write(values) + 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) + 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. + 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 + + applied_programs = order._get_applied_programs() + applicable_programs = self.env['coupon.program'] + if applied_programs: + applicable_programs = order._get_applicable_programs() + order._get_valid_applied_coupon_program() + applicable_programs = applicable_programs._keep_only_most_interesting_auto_applied_global_discount_program() + programs_to_remove = applied_programs - applicable_programs + + reward_product_ids = applied_programs.discount_line_product_id.ids + # 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 reward_product_ids) + + if programs_to_remove: + product_ids_to_remove = programs_to_remove.discount_line_product_id.ids + + if product_ids_to_remove: + # Invalid generated coupon for which we are not eligible anymore ('expired' since it is specific to this SO and we may again met the requirements) + self.generated_coupon_ids.filtered(lambda coupon: coupon.program_id.discount_line_product_id.id in product_ids_to_remove).write({'state': 'expired'}) + + # Reset applied coupons for which we are not eligible anymore ('valid' so it can be use on another ) + coupons_to_remove = order.applied_coupon_ids.filtered(lambda coupon: coupon.program_id in programs_to_remove) + coupons_to_remove.write({'state': 'new'}) + + # Unbind promotion and coupon programs which requirements are not met anymore + order.no_code_promo_program_ids -= programs_to_remove + order.code_promo_program_id -= programs_to_remove + + if coupons_to_remove: + order.applied_coupon_ids -= coupons_to_remove + + # Remove their reward lines + if product_ids_to_remove: + invalid_lines |= order.order_line.filtered(lambda line: line.product_id.id in product_ids_to_remove) + + invalid_lines.unlink() + + 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') + + def _get_applied_programs_with_rewards_on_next_order(self): + return self.no_code_promo_program_ids.filtered(lambda p: p.promo_applicability == 'on_next_order') + \ + self.code_promo_program_id.filtered(lambda p: p.promo_applicability == 'on_next_order') + + def _get_applied_programs(self): + """Returns all applied programs on current order: + + Expected to return same result than: + + self._get_applied_programs_with_rewards_on_current_order() + + + self._get_applied_programs_with_rewards_on_next_order() + """ + return self.code_promo_program_id + self.no_code_promo_program_ids + self.applied_coupon_ids.mapped('program_id') + + def _get_invoice_status(self): + # Handling of a specific situation: an order contains + # a product invoiced on delivery and a promo line invoiced + # on order. We would avoid having the invoice status 'to_invoice' + # if the created invoice will only contain the promotion line + super()._get_invoice_status() + for order in self.filtered(lambda order: order.invoice_status == 'to invoice'): + paid_lines = order._get_paid_order_lines() + if not any(line.invoice_status == 'to invoice' for line in paid_lines): + order.invoice_status = 'no' + + def _get_invoiceable_lines(self, final=False): + """ Ensures we cannot invoice only reward lines. + + Since promotion lines are specified with service products, + those lines are directly invoiceable when the order is confirmed + which can result in invoices containing only promotion lines. + + To avoid those cases, we allow the invoicing of promotion lines + iff at least another 'basic' lines is also invoiceable. + """ + invoiceable_lines = super()._get_invoiceable_lines(final) + reward_lines = self._get_reward_lines() + if invoiceable_lines <= reward_lines: + return self.env['sale.order.line'].browse() + return invoiceable_lines + + def update_prices(self): + """Recompute coupons/promotions after pricelist prices reset.""" + super().update_prices() + if any(line.is_reward_line for line in self.order_line): + self.recompute_coupon_lines() + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + 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( + lambda coupon: coupon.program_id.discount_line_product_id == line.product_id + ) + 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 delete the other lines from this program (It's the case when discount is split per different taxes) + related_program = self.env['coupon.program'].search([('discount_line_product_id', '=', line.product_id.id)]) + if related_program: + line.order_id.no_code_promo_program_ids -= related_program + 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() + + 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: + line = line.with_company(line.company_id) + fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id.get_fiscal_position(line.order_partner_id.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) + + def _get_display_price(self, product): + # A product created from a promotion does not have a list_price. + # The price_unit of a reward order line is computed by the promotion, so it can be used directly + if self.is_reward_line: + return self.price_unit + return super()._get_display_price(product) + + # Invalidation of `coupon.program.order_count` + # `test_program_rules_validity_dates_and_uses`, + # Overriding modified is quite hardcore as you need to know how works the cache and the invalidation system, + # but at least the below works and should be efficient. + # Another possibility is to add on product.product a one2many to sale.order.line 'order_line_ids', + # and then add the depends @api.depends('discount_line_product_id.order_line_ids'), + # but I am not sure this will as efficient as the below. + def modified(self, fnames, *args, **kwargs): + super(SaleOrderLine, self).modified(fnames, *args, **kwargs) + if 'product_id' in fnames: + Program = self.env['coupon.program'].sudo() + field_order_count = Program._fields['order_count'] + programs = self.env.cache.get_records(Program, field_order_count) + if programs: + products = self.filtered('is_reward_line').mapped('product_id') + for program in programs: + if program.discount_line_product_id in products: + self.env.cache.invalidate([(field_order_count, program.ids)]) -- cgit v1.2.3