commit 5260d22c4d60cf73d22fd179bea142a4c2d00b36 Author: pgu-odoo Date: Tue Dec 20 14:33:35 2016 +0100 [ADD] sale_coupon: introduce coupon mechanism on sales order Introduces 2 new concepts: Promotion programs: =================== Using promotion programs allows you to propose to your customers: - Promotional offers limited in time or a limited order number. - Public code printed in advertisements, magasines, etc. - Specific offers based on a customers group or a product set. - A combination of the preceeding points. You may choose to apply the program reward immediately on the sales order or to propose the reward for a next order. In the second case a coupon will be generated at the order confirmation for the customer to use it later. Coupon programs: ================ Using coupon programs allows you to propose to your customers: - Free products or discounts if some conditions on products or purchased amount are met. - Promotional offers limited in time. - Generate coupons for a customers group. - Specific offers based on a customers group or a product set. - A combination of the preceeding points. A coupon is applied by entering a valid code if the program rules are met and if the reward is already in the order lines. diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000000..11529f98733 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import models +import wizard diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 00000000000..157cbcb6dbc --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': "Sale Coupon", + 'summary': """Allows to use discount coupons in sales orders""", + 'description': """Integrate coupon mechanism in sales orders.""", + 'category': 'Sales', + 'version': '1.0', + 'depends': ['sale'], + 'data': [ + 'security/ir.model.access.csv', + 'wizard/sale_coupon_apply_code_views.xml', + 'wizard/sale_coupon_generate_views.xml', + 'views/sale_order_views.xml', + 'views/sale_coupon_views.xml', + 'views/sale_coupon_program_views.xml', + 'views/sale_config_settings_views.xml', + 'data/sale_coupon_data.xml', + ], +} diff --git a/data/sale_coupon_data.xml b/data/sale_coupon_data.xml new file mode 100644 index 00000000000..3ddf205d1c6 --- /dev/null +++ b/data/sale_coupon_data.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 00000000000..08d5baa0073 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import sale_config_settings +import sale_coupon +import sale_coupon_reward +import sale_coupon_rules +import sale_coupon_program +import sale_order diff --git a/models/sale_config_settings.py b/models/sale_config_settings.py new file mode 100644 index 00000000000..14b5b3a0ff1 --- /dev/null +++ b/models/sale_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class SaleConfiguration(models.TransientModel): + _inherit = 'sale.config.settings' + + module_sale_coupon = fields.Boolean("Manage coupons and promotional offers") diff --git a/models/sale_coupon.py b/models/sale_coupon.py new file mode 100644 index 00000000000..b2306ef96c3 --- /dev/null +++ b/models/sale_coupon.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import uuid +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ + + +class SaleCoupon(models.Model): + _name = 'sale.coupon' + _description = "Sales Coupon" + _rec_name = 'code' + + @api.model + def _generate_code(self): + """Generate a 20 char long pseudo-random string of digits + Used for barcode generation, UUID4 makes the chance of a collision + (unicity constraint) highly unlikely. + Using the int version is a longer string than hex but generates a more + compact barcode when using digits only (Code128C instead of Code128A). + Keep only the first 8 bytes as a 16 bytes barcode is not readable by all + barcode scanners. + """ + return str(int(uuid.uuid4().bytes[:8].encode('hex'), 16)) + + code = fields.Char(default=_generate_code, required=True, readonly=True) + expiration_date = fields.Date('Expiration Date', compute='_compute_expiration_date') + state = fields.Selection([ + ('reserved', 'Reserved'), + ('new', 'Valid'), + ('used', 'Consumed'), + ('expired', 'Expired') + ], required=True, default='new') + partner_id = fields.Many2one('res.partner', "For Customer") + program_id = fields.Many2one('sale.coupon.program', "Program") + order_id = fields.Many2one('sale.order', 'Order Reference', readonly=True, + help="The sales order from which coupon is generated") + discount_line_product_id = fields.Many2one('product.product', related='program_id.discount_line_product_id', + help='Product used in the sales order to apply the discount.') + + _sql_constraints = [ + ('unique_coupon_code', 'unique(code)', 'The coupon code must be unique!'), + ] + + def _compute_expiration_date(self): + for coupon in self.filtered(lambda x: x.program_id.validity_duration > 0): + coupon.expiration_date = fields.Date.from_string(coupon.create_date) + relativedelta(days=coupon.program_id.validity_duration) + + def _check_coupon_code(self, order): + message = {} + applicable_programs = order._get_applicable_programs() + amount_total = order.amount_untaxed + order.reward_amount + if self.program_id.rule_minimum_amount_tax_inclusion == 'tax_included': + amount_total += order.amount_tax + elif self.state in ('used', 'expired') or \ + (self.expiration_date and self.expiration_date < order.date_order): + message = {'error': _('This coupon %s has been used or is expired.') % (self.code)} + elif self.state == 'reserved': + message = {'error': _('This coupon %s exists but the origin sales order is not validated yet.') % (self.code)} + elif self.program_id._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total: + 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)} + 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.')} + 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/models/sale_coupon_program.py b/models/sale_coupon_program.py new file mode 100644 index 00000000000..cff5ef2fd1e --- /dev/null +++ b/models/sale_coupon_program.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class SaleCouponProgram(models.Model): + _name = 'sale.coupon.program' + _description = "Sales Coupon Program" + _inherits = {'sale.coupon.rule': 'rule_id', 'sale.coupon.reward': 'reward_id'} + _order = "sequence" + + name = fields.Char(required=True, translate=True) + active = fields.Boolean('Active', default=True, help="A program is available for the customers when active") + rule_id = fields.Many2one('sale.coupon.rule', string="Applicability", ondelete='restrict', required=True) + reward_id = fields.Many2one('sale.coupon.reward', string="Reward", ondelete='restrict', required=True, copy=False) + sequence = fields.Integer(copy=False, + help="Coupon program will be applied based on given sequence if multiple programs are " + + "defined on same condition(For minimum amount)") + maximum_use_number = fields.Integer(help="Maximum number of sales orders in which reward can be provided") + program_type = fields.Selection([ + ('promotion_program', 'Promotional Program'), + ('coupon_program', 'Coupon Program'), + ], + help="""A promotional program can be either a limited promotional offer without code (applied automatically) + or with a code (displayed on a magazine for example) that may generate a discount on the current + order or create a coupon for a next order. + + A coupon program generates coupons with a code that can be used to generate a discount on the current + order or create a coupon for a next order.""") + promo_code_usage = fields.Selection([ + ('no_code_needed', 'Automatically Applied'), + ('code_needed', 'Use a code')], + help="Automatically Applied - No code is required, if the program rules are met, the reward is applied (Except the global discount or the free shipping rewards which are not cumulable)\n" + + "Use a code - If the program rules are met, a valid code is mandatory for the reward to be applied\n") + promo_code = fields.Char('Promotion Code', copy=False, + help="A promotion code is a code that is associated with a marketing discount. For example, a retailer might tell frequent customers to enter the promotion code 'THX001' to receive a 10%% discount on their whole order.") + promo_applicability = fields.Selection([ + ('on_current_order', 'Apply On Current Order'), + ('on_next_order', 'Apply On Next Order')], + default='on_current_order', string="Applicability") + coupon_ids = fields.One2many('sale.coupon', 'program_id', string="Generated Coupons", copy=False) + coupon_count = fields.Integer(compute='_compute_coupon_count') + order_count = fields.Integer(compute='_compute_order_count') + company_id = fields.Many2one('res.company', string="Company", default=lambda self: self.env.user.company_id) + currency_id = fields.Many2one(string="Currency", related='company_id.currency_id') + validity_duration = fields.Integer(default=1, + help="Validity duration for a coupon after its generation") + + _sql_constraints = [ + ('unique_promo_code', 'unique(promo_code)', 'The program code must be unique!'), + ] + + 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 _compute_coupon_count(self): + coupon_data = self.env['sale.coupon'].read_group([('program_id', 'in', self.ids)], ['program_id'], ['program_id']) + mapped_data = dict([(m['program_id'][0], m['program_id_count']) for m in coupon_data]) + for program in self: + program.coupon_count = mapped_data.get(program.id, 0) + + @api.model + def create(self, vals): + program = super(SaleCouponProgram, self).create(vals) + if 'discount_line_product_id' not in vals: + discount_line_product_id = self.env['product.product'].create({ + 'name': program.reward_id.name_get()[0][1], + 'type': 'service', + 'taxes_id': False, + 'supplier_taxes_id': False, + 'sale_ok': False, + 'purchase_ok': False + }) + program.write({'discount_line_product_id': discount_line_product_id.id}) + return program + + def write(self, vals): + res = super(SaleCouponProgram, self).write(vals) + reward_fields = [ + 'reward_type', 'reward_product_id', 'discount_type', 'discount_percentage', + 'discount_apply_on', 'discount_specific_product_id', 'discount_fixed_amount' + ] + if any(field in reward_fields for field in vals): + self.mapped('discount_line_product_id').write({'name': self[0].reward_id.name_get()[0][1]}) + return res + + def unlink(self): + for program in self.filtered(lambda x: x.active): + raise UserError(_('You can not delete a program in active state')) + return super(SaleCouponProgram, self).unlink() + + def toggle_active(self): + super(SaleCouponProgram, self).toggle_active() + 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}) + + 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', + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', orders.ids)] + } + + 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 _check_promo_code(self, order, coupon_code): + message = {} + applicable_programs = order._get_applicable_programs() + amount_total = order.amount_untaxed + order.reward_amount + if self.rule_minimum_amount_tax_inclusion == 'tax_included': + amount_total += order.amount_tax + if self.maximum_use_number != 0 and self.order_count >= self.maximum_use_number: + message = {'error': _('Promo code %s has been expired.') % (coupon_code)} + elif self._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total: + message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.rule_minimum_amount, self.currency_id.name)} + elif self.promo_code and self.promo_code == order.promo_code: + message = {'error': _('The promo code is already applied on this order')} + elif not self.promo_code and 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: + 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()): + 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.')} + else: + if self not in applicable_programs: + message = {'error': _('At least one of the required conditions is not met to get the reward!')} + return message + + def _compute_program_amount(self, field, currency_to): + self.ensure_one() + return self.currency_id.compute(getattr(self, field), currency_to) + + @api.model + def _filter_on_mimimum_amount(self, order): + # To get actual total amount of SO without any reward or discount + discounted_amount = sum(order.order_line.filtered(lambda line: line.is_reward_line).mapped('price_total')) + untaxed_amount = order.amount_untaxed - discounted_amount + return self.filtered(lambda program: + program.rule_minimum_amount_tax_inclusion == 'tax_included' and + program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount + order.amount_tax or + program.rule_minimum_amount_tax_inclusion == 'tax_excluded' and + program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount) + + @api.model + def _filter_on_validity_dates(self, order): + return self.filtered(lambda program: + program.rule_date_from and program.rule_date_to and + program.rule_date_from <= order.date_order and program.rule_date_to >= order.date_order or + not program.rule_date_from or not program.rule_date_to) + + @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.rule_partner_ids and order.partner_id.id in program.rule_partner_ids.ids or not program.rule_partner_ids) + + 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 - order._get_reward_lines() + products_qties = dict.fromkeys(order_lines.mapped('product_id').ids, 0) + for line in order_lines: + products_qties[line.product_id.id] += line.product_uom_qty + valid_programs = self.filtered(lambda program: not program.rule_product_ids) + for program in self - valid_programs: + ordered_rule_products_qty = sum(map(lambda product_id, qty: product_id in program.rule_product_ids.ids and qty, products_qties, products_qties.values())) + # Avoid program if 1 ordered foo on a program '1 foo, 1 free foo' + if program.reward_product_id in program.rule_product_ids and program.reward_type == 'product': + line = order.order_line.filtered(lambda line: line.product_id == program.reward_product_id) + ordered_rule_products_qty -= program.reward_product_quantity + # needed_quantity = program.rule_min_quantity if self. + if ordered_rule_products_qty >= program.rule_min_quantity: + valid_programs |= program + return valid_programs + + def _filter_not_ordered_reward_programs(self, order): + """ + Returns the programs when the reward is actually in the order lines + """ + programs = self.env['sale.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_product' and \ + not order.order_line.filtered(lambda line: line.product_id == program.discount_specific_product_id): + continue + programs |= program + return programs + + @api.model + def _filter_programs_from_common_rules(self, order): + programs = self._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) + programs = programs and programs._filter_not_ordered_reward_programs(order) + return programs diff --git a/models/sale_coupon_reward.py b/models/sale_coupon_reward.py new file mode 100644 index 00000000000..23cbb8ea2df --- /dev/null +++ b/models/sale_coupon_reward.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class SaleCouponReward(models.Model): + _name = 'sale.coupon.reward' + _description = "Sales Coupon Reward" + _rec_name = 'reward_description' + + reward_description = fields.Char('Reward Description') + reward_type = fields.Selection([ + ('discount', 'Discount'), + ('product', 'Free Product'), + ], string='Reward', default='discount', + help="Discount - Reward will be provided as discount.\n" + + "Free Product - Free product will be provide as reward \n" + + "Free Shipping - Free shipping will be provided as reward (Need delivery module)") + # Product Reward + reward_product_id = fields.Many2one('product.product', string="Free Product", + help="Reward Product") + reward_product_quantity = fields.Integer(string="Quantity", default=1, help="Reward product quantity") + # Discount Reward + discount_type = fields.Selection([ + ('percentage', 'Percentage'), + ('fixed_amount', 'Fixed Amount')], default="percentage", + help="Percentage - Entered percentage discount will be provided\n" + + "Amount - Entered fixed amount discount will be provided") + discount_percentage = fields.Float(string="Discount", default=1, + help='The discount in percentage, between 1 to 100') + discount_apply_on = fields.Selection([ + ('on_order', 'On Order'), + ('cheapest_product', 'On Cheapest Product'), + ('specific_product', 'On Specific Product')], default="on_order", + help="On Order - Discount on whole order\n" + + "Cheapest product - Discount on cheapest product of the order\n" + + "Specific product - Discount on selected specific product") + discount_specific_product_id = fields.Many2one('product.product', string="Product", + help="Product that will be discounted if the discount is applied on a specific product") + discount_max_amount = fields.Float(default=0, + help="Maximum amount of discount that can be provided") + discount_fixed_amount = fields.Float(string="Fixed Amount", help='The discount in fixed amount') + reward_product_uom_id = fields.Many2one(related='reward_product_id.product_tmpl_id.uom_id', string='Unit of Measure', readonly=True) + discount_line_product_id = fields.Many2one('product.product', string='Reward Line Product', copy=False, + help="Product used in the sales order to apply the discount. Each coupon program has its own reward product for reporting purpose") + + @api.constrains('discount_percentage') + def _check_discount_percentage(self): + if self.filtered(lambda reward: reward.discount_type == 'percentage' and (reward.discount_percentage < 0 or reward.discount_percentage > 100)): + raise ValidationError(_('Discount percentage should be between 1-100')) + + @api.multi + def name_get(self): + """ + Returns a complete description of the reward + """ + result = [] + for reward in self: + reward_string = "" + if reward.reward_type == 'product': + reward_string = _("Free Product - %s" % (reward.reward_product_id.name)) + elif reward.reward_type == 'discount': + if reward.discount_type == 'percentage': + reward_percentage = str(reward.discount_percentage) + if reward.discount_apply_on == 'on_order': + reward_string = _("%s %% discount on total amount" % (reward_percentage)) + elif reward.discount_apply_on == 'specific_product': + reward_string = _("%s %% discount on %s" % (reward_percentage, reward.discount_specific_product_id.name)) + elif reward.discount_apply_on == 'cheapest_product': + reward_string = _("%s %% discount on cheapest product" % (reward_percentage)) + elif reward.discount_type == 'fixed_amount': + program = self.env['sale.coupon.program'].search([('reward_id', '=', reward.id)]) + reward_string = _("%s %s discount on total amount" % (str(reward.discount_fixed_amount), program.currency_id.name)) + result.append((reward.id, reward_string)) + return result diff --git a/models/sale_coupon_rules.py b/models/sale_coupon_rules.py new file mode 100644 index 00000000000..7b55f6c6ec1 --- /dev/null +++ b/models/sale_coupon_rules.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + + +class SaleCouponRule(models.Model): + _name = 'sale.coupon.rule' + _description = "Sales Coupon Rule" + + rule_date_from = fields.Datetime(string="Start Date", help="Coupon program start date") + rule_date_to = fields.Datetime(string="End Date", help="Coupon program end date") + rule_partners_domain = fields.Char(string="Based on Customers", help="Coupon program will work for selected customers only") + rule_partner_ids = fields.Many2many('res.partner', 'rule_partner_rel', 'rule_id', 'partner_id', + string="Related Partners", compute='_compute_rule_partner_ids', store=True) + rule_products_domain = fields.Char(string="Based on Products", help="On Purchase of selected product, reward will be given") + rule_product_ids = fields.Many2many('product.product', 'rule_product_rel', 'rule_id', 'product_id', + string="Related Products", compute='_compute_rule_product_ids', store=True) + rule_min_quantity = fields.Integer(string="Minimum Quantity", default=1, + help="Minimum required product quantity to get the reward") + rule_minimum_amount = fields.Float(default=0.0, help="Minimum required amount to get the reward") + rule_minimum_amount_tax_inclusion = fields.Selection([ + ('tax_included', 'Tax Included'), + ('tax_excluded', 'Tax Excluded')], default="tax_excluded") + + @api.constrains('rule_date_to', 'rule_date_from') + def _check_rule_date_from(self): + if self.filtered(lambda applicability: applicability.rule_date_to < applicability.rule_date_from): + raise ValidationError(_('The start date must be before the end date')) + + @api.constrains('rule_minimum_amount') + def _check_rule_minimum_amount(self): + if self.filtered(lambda applicability: applicability.rule_minimum_amount < 0): + raise ValidationError(_('Minimum purchased amount should be greater than 0')) + + @api.depends('rule_partners_domain') + def _compute_rule_partner_ids(self): + for applicability in self.filtered(lambda x: x.rule_partners_domain): + domain = safe_eval(applicability.rule_partners_domain) + applicability.rule_partner_ids = domain and self.env['res.partner'].search(domain) + + @api.depends('rule_products_domain') + def _compute_rule_product_ids(self): + for applicability in self.filtered(lambda x: x.rule_products_domain): + domain = safe_eval(applicability.rule_products_domain) + applicability.rule_product_ids = domain and self.env['product.product'].search(domain) diff --git a/models/sale_order.py b/models/sale_order.py new file mode 100644 index 00000000000..6ceb5863aca --- /dev/null +++ b/models/sale_order.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + applied_coupon_ids = fields.One2many('sale.coupon', 'order_id', string="Applied Coupons", copy=False) + generated_coupon_ids = fields.One2many('sale.coupon', 'order_id', string="Offered Coupons", copy=False) + reward_amount = fields.Float(compute='_compute_reward_total') + no_code_promo_program_ids = fields.Many2many('sale.coupon.program', string="Applied Immediate Promo Programs", + domain=[('promo_code_usage', '=', 'no_code_needed')], copy=False) + code_promo_program_id = fields.Many2one('sale.coupon.program', string="Applied Promo Program", + domain=[('promo_code_usage', '=', 'code_needed')], copy=False) + promo_code = fields.Char(related='code_promo_program_id.promo_code', help="Applied program code") + + @api.depends('order_line') + def _compute_reward_total(self): + for order in self: + order.reward_amount = sum([line.price_subtotal for line in order.order_line.filtered(lambda line: line.is_reward_line)]) + + def copy(self, default=None): + order = super(SaleOrder, self).copy(dict(default or {}, order_line=False)) + for line in self.order_line.filtered(lambda line: not line.is_reward_line): + line.copy({'order_id': order.id}) + return order + + @api.model + def create(self, vals): + res = super(SaleOrder, self).create(vals) + res._create_new_no_code_promo_reward_lines() + res._update_existing_reward_lines() + return res + + def write(self, vals): + res = super(SaleOrder, self).write(vals) + if 'order_line' in vals and self.env.context.get('sale_coupon_no_loop', True): + for order in self.with_context(sale_coupon_no_loop=False): + order._remove_invalid_reward_lines() + order._create_new_no_code_promo_reward_lines() + order._update_existing_reward_lines() + return res + + + def action_confirm(self): + res = super(SaleOrder, self).action_confirm() + self.generated_coupon_ids.write({'state': 'new'}) + return res + + def action_cancel(self): + res = super(SaleOrder, self).action_cancel() + self.generated_coupon_ids.write({'state': 'expired'}) + self.applied_coupon_ids.write({'state': 'new'}) + return res + + def _get_reward_lines(self): + self.ensure_one() + reward_products = self.applied_coupon_ids.mapped('program_id').mapped('discount_line_product_id') + return self.order_line.filtered(lambda line: line.product_id in reward_products) + + def _is_reward_in_order_lines(self, program): + self.ensure_one() + return self.order_line.filtered(lambda line: + line.product_id == program.reward_product_id and + line.product_uom_qty >= 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_unit + + order_lines = (self.order_line - self._get_reward_lines()).filtered(lambda x: x.product_id in program.rule_product_ids) + max_product_qty = sum(order_lines.mapped('product_uom_qty')) or 1 + line = self.order_line.filtered(lambda line: line.product_id == program.reward_product_id)[0] + # Remove needed quantity from reward quantity if same reward and rule product + if program.reward_product_id in program.rule_product_ids: + reward_product_qty = line.product_uom_qty // (program.rule_min_quantity + program.reward_product_quantity) + else: + reward_product_qty = line.product_uom_qty + + reward_qty = min(int(int(max_product_qty / program.rule_min_quantity) * program.reward_product_quantity), reward_product_qty) + + vals = { + 'product_id': program.discount_line_product_id.id, + 'price_unit': - price_unit, + 'product_uom_qty': reward_qty, + 'is_reward_line': True + } + if not program.reward_product_id: + vals.update({ + 'name': "Discount: %s" % (program.name), + 'product_uom': program.discount_line_product_id.uom_id.id, + }) + else: + vals.update({ + 'name': "Free Product - " + program.reward_product_id.name, + 'product_uom': program.reward_product_id.uom_id.id, + }) + return vals + + def _get_order_lines_untaxed_amount(self): + """ Returns the untaxed sale order total amount without the rewards amount""" + return sum([x.price_subtotal for x in self.order_line.filtered(lambda x: not x.is_reward_line)]) + + def _get_reward_values_discount_fixed_amount(self, program): + total_amount = sum(self.order_line.filtered(lambda line: not line.is_reward_line).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_lines_unit_prices(self): + return [x.price_unit for x in self.order_line.filtered(lambda x: not x.is_reward_line)] + + def _get_reward_values_discount_percentage(self, program): + discount_amount = 0 + amount_untaxed = self._get_order_lines_untaxed_amount() + max_amount = program._compute_program_amount('discount_max_amount', self.currency_id) + if program.discount_apply_on == 'on_order': + discount_amount = amount_untaxed * (program.discount_percentage / 100) + if program.discount_apply_on == 'cheapest_product': + unit_prices = self._get_lines_unit_prices() + discount_amount = (min(unit_prices) * (program.discount_percentage) / 100) + if program.discount_apply_on == 'specific_product': + discount_amount = sum([(x.price_unit * x.product_uom_qty) * (program.discount_percentage / 100) for x in self.order_line.filtered( + lambda x: x.product_id == program.discount_specific_product_id)]) + if program.discount_max_amount and discount_amount > max_amount: + discount_amount = max_amount + 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: %s" % (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 + } + + 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) + + 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_coupon(self, program): + coupon = self.env['sale.coupon'].create({ + 'program_id': program.id, + 'state': 'reserved', + 'order_id': self.id, + 'discount_line_product_id': program.discount_line_product_id.id + }) + self.generated_coupon_ids |= coupon + subject = '%s, a coupon has been generated from your order %s' % (self.partner_id.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 _get_applicable_programs(self): + """ + This method is used to return the valid applicable programs on given order. + param: order - The sale order for which method will get applicable programs. + """ + 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) + return programs + + 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.sorted('sequence') + + 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() + 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))]}) + 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 + for program in applied_programs: + values = order._get_reward_line_values(program) + line_id = order.order_line.filtered(lambda line: line.product_id == program.discount_line_product_id).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)]}) + else: + order.write({'order_line': [(2, line_id, False)]}) + + 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]}) + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + is_reward_line = fields.Boolean('Is a program reward line') + + @api.model + def create(self, vals): + """ + Override the sale order line creation to transform the line creation into a ORM request + This horrible hack is due to the fact that with the coupons mechanism, a line creation may + lead to the creation, the modification or the deletion of another line. If we override the + create, write and unlink methods on the sale.order.line model, we may have issues like + cache mismatch on computed fields and so on. Due to this reason, we override the write + method on the sale.order and do the actions according to the situation. A write is done on + the sales order if we create, write or unlink a sales order line with an ORM command. To avoid + having places where creating a sales order doens't activate the coupon mechanism, (It is the + case in website_sale or website_quote for example) we do this hack here where we create the line + on the order with an ORM command and return the newly created order line. I hope that you'll + find in your heart enough faith to forgive me. + PS: RCO told me it was ok + """ + order = self.env['sale.order'].browse(vals['order_id']) + if self.env.context.get('sale_coupon_no_loop', False): + order.with_context(sale_coupon_no_loop=True).write({'order_line': [(0, False, vals)]}) + return order.order_line.sorted('creation_date', reverse=True)[0] + return super(SaleOrderLine, self).create(vals) + + def unlink(self): + # 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 in the case it's still valid + related_program = self.env['sale.coupon.program'].search([('discount_line_product_id', '=', self.product_id.id)]) + if related_program: + self.order_id.no_code_promo_program_ids -= related_program + self.order_id.code_promo_program_id -= related_program + res = super(SaleOrderLine, self.with_context(sale_coupon_no_loop=False)).unlink() + return res diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 00000000000..18ec5bda7ae --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink +access_program_salesman,salesman,model_sale_coupon_program,sales_team.group_sale_salesman,1,0,0,0 +access_program_manager,program manager,model_sale_coupon_program,sales_team.group_sale_manager,1,1,1,1 +access_applicability_salesman,salesman,model_sale_coupon_rule,sales_team.group_sale_salesman,1,0,0,0 +access_applicability_manager,program manager,model_sale_coupon_rule,sales_team.group_sale_manager,1,1,1,0 +access_coupon_salesman,salesman,model_sale_coupon,sales_team.group_sale_salesman,1,0,0,0 +access_coupon_manager,program manager,model_sale_coupon,sales_team.group_sale_manager,1,1,1,0 +access_reward_salesman,salesman,model_sale_coupon_reward,sales_team.group_sale_salesman,1,0,0,0 +access_reward_manager,program manager,model_sale_coupon_reward,sales_team.group_sale_manager,1,1,1,0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000000..5392f5c4c27 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import test_program_without_code_operations +import test_program_with_code_operations +import test_program_rules +import test_program_rewards diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 00000000000..d1186b0d2c9 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from openerp.tests import common + + +class TestSaleCouponCommon(common.TransactionCase): + + def setUp(self): + super(TestSaleCouponCommon, self).setUp() + + # create partner for sale order. + self.steve = self.env['res.partner'].create({ + 'name': 'Steve Bucknor', + 'email': 'steve.bucknor@example.com', + }) + + self.empty_order = self.env['sale.order'].create({ + 'partner_id': self.steve.id + }) + + self.uom_unit = self.env.ref('product.product_uom_unit') + + #products + self.product_A = self.env['product.product'].create({ + 'name': 'Product A', + 'list_price': 100, + }) + + self.product_B = self.env['product.product'].create({ + 'name': 'Product B', + 'list_price': 5, + }) + + # Immediate Program By A + B: get B free + # No Conditions + self.immediate_promotion_program = self.env['sale.coupon.program'].create({ + 'name': 'Buy A + 1 B, 1 B are free', + 'promo_code_usage': 'no_code_needed', + 'reward_type': 'product', + 'reward_product_id': self.product_B.id, + 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id), + 'active': True, + }) + + self.code_promotion_program = self.env['sale.coupon.program'].create({ + 'name': 'Buy 1 A + Enter code, 1 A is free', + 'promo_code_usage': 'code_needed', + 'reward_type': 'product', + 'reward_product_id': self.product_A.id, + 'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id), + 'active': True, + }) diff --git a/tests/test_program_rewards.py b/tests/test_program_rewards.py new file mode 100644 index 00000000000..bb324810715 --- /dev/null +++ b/tests/test_program_rewards.py @@ -0,0 +1,62 @@ +# -*- 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, + }) + ]}) + 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_rules.py b/tests/test_program_rules.py new file mode 100644 index 00000000000..43e6310a52c --- /dev/null +++ b/tests/test_program_rules.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta + +from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon +from odoo.fields import Date + +class TestProgramRules(TestSaleCouponCommon): + # Test all the validity rules to allow a customer to have a reward. + # The check based on the products is already done in the basic operations test + + def test_program_rules_partner_based(self): + # Test case: Based on the partners domain + + self.immediate_promotion_program.write({'rule_partners_domain': "[('id', 'in', [%s])]" % (self.steve.id)}) + + 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, + }) + ]}) + 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") + + order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id}) + 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, + }) + ]}) + self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied, the discount is created") + + def test_program_rules_minimum_purchased_amount(self): + # Test case: Based on the minimum purchased + + self.immediate_promotion_program.write({ + 'rule_minimum_amount': 1000.0, + 'rule_minimum_amount_tax_inclusion': 'tax_excluded' + }) + + 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, + }) + ]}) + self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied as the purchased amount is not enough") + + order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id}) + 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': 10.0, + }), + (0, False, { + 'product_id': self.product_B.id, + 'name': '2 Product B', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the purchased amount is enough") + + def test_program_rules_validity_dates_and_uses(self): + # Test case: Based on the validity dates and the number of allowed uses + + self.immediate_promotion_program.write({ + 'rule_date_from': Date.to_string((datetime.now() - timedelta(days=7))), + 'rule_date_to': Date.to_string((datetime.now() - timedelta(days=2))), + 'maximum_use_number': 1, + }) + + 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, + }) + ]}) + self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied we're not between the validity dates") + + self.immediate_promotion_program.write({ + 'rule_date_from': Date.to_string((datetime.now() - timedelta(days=7))), + 'rule_date_to': Date.to_string((datetime.now() + timedelta(days=2))), + }) + order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id}) + 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': 10.0, + }), + (0, False, { + 'product_id': self.product_B.id, + 'name': '2 Product B', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as we're between the validity dates") + order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id}) + 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': 10.0, + }), + (0, False, { + 'product_id': self.product_B.id, + 'name': '2 Product B', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied as the number of uses is exceeded") diff --git a/tests/test_program_with_code_operations.py b/tests/test_program_with_code_operations.py new file mode 100644 index 00000000000..b39e02b98c7 --- /dev/null +++ b/tests/test_program_with_code_operations.py @@ -0,0 +1,92 @@ +# -*- coding: utf-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 TestProgramWithCodeOperations(TestSaleCouponCommon): + # Test the basic operation (apply_coupon) on an coupon program on which we should + # apply the reward when the code is correct or remove the reward automatically when the reward is + # not valid anymore. + + def test_program_basic_operation_coupon_code(self): + # Test case: Generate a coupon for my customer, and add a reward then remove it automatically + + self.code_promotion_program.reward_type = 'discount' + + self.env['sale.coupon.generate'].with_context(active_id=self.code_promotion_program.id).create({ + 'generation_type': 'nbr_customer', + 'partners_domain': "[('id', 'in', [%s])]" % (self.steve.id), + }).generate_coupon() + + # Test the valid code on a wrong sales order + wrong_partner_order = self.env['sale.order'].create({ + 'partner_id': self.env.ref('base.res_partner_1').id, + }) + 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 + }).process_coupon() + + # Test now on a valid sales order + 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': self.code_promotion_program.coupon_ids.code + }).process_coupon() + self.assertEqual(len(order.order_line.ids), 2) + + # Remove the product A from the sale order + order.write({'order_line': [(2, order.order_line[0].id, False)]}) + self.assertEqual(len(order.order_line.ids), 0) + +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 + 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 + self.assertEqual(len(order.order_line.ids), 2) diff --git a/tests/test_program_without_code_operations.py b/tests/test_program_without_code_operations.py new file mode 100644 index 00000000000..32f2edf6a93 --- /dev/null +++ b/tests/test_program_without_code_operations.py @@ -0,0 +1,54 @@ +# -*- 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 TestProgramWithoutCodeOperations(TestSaleCouponCommon): + # Test some basic operation (create, write, unlink) on an immediate coupon program on which we should + # apply or remove the reward automatically, as there's no program code. + + def test_immediate_program_basic_operation(self): + + # 2 products A are needed + self.immediate_promotion_program.write({'rule_min_quantity': 2.0}) + order = self.empty_order + # Test case 1 (1 A): Assert that no reward is given, as the product B is missing + 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.assertEqual(len(order.order_line.ids), 1, "The promo offer shouldn't have been applied as the product B isn't in the order") + + # Test case 2 (1 A 1 B): Assert that no reward is given, as the product B is not present in the correct quantity + order.write({'order_line': [ + (0, False, { + 'product_id': self.product_B.id, + 'name': '2 Product B', + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + }) + ]}) + self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied as 2 product A aren't in the order") + + # Test case 3 (2 A 1 B): Assert that the reward is given as the product B is now in the order + order.write({'order_line': [(1, order.order_line[0].id, {'product_uom_qty': 2.0})]}) + self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied, the discount is not created") + + # Test case 4 (1 A 1 B): Assert that the reward is removed as we don't buy 2 products B anymore + order.write({'order_line': [(1, order.order_line[0].id, {'product_uom_qty': 1.0})]}) + self.assertEqual(len(order.order_line.ids), 2, "The promo reward should have been removed as the rules are not matched anymore") + self.assertEqual(order.order_line[0].product_id.id, self.product_A.id, "The wrong line has been removed") + self.assertEqual(order.order_line[1].product_id.id, self.product_B.id, "The wrong line has been removed") + + # Test case 5 (1 B): Assert that the reward is removed when the order is modified and doesn't match the rules anymore + order.write({'order_line': [ + (1, order.order_line[0].id, {'product_uom_qty': 2.0}), + (2, order.order_line[0].id, False) + ]}) + self.assertEqual(len(order.order_line.ids), 1, "The promo reward should have been removed as the rules are not matched anymore") + self.assertEqual(order.order_line.product_id.id, self.product_B.id, "The wrong line has been removed") diff --git a/views/sale_config_settings_views.xml b/views/sale_config_settings_views.xml new file mode 100644 index 00000000000..b6153499414 --- /dev/null +++ b/views/sale_config_settings_views.xml @@ -0,0 +1,18 @@ + + + + sale settings + sale.config.settings + + + + + + + diff --git a/views/sale_coupon_program_views.xml b/views/sale_coupon_program_views.xml new file mode 100644 index 00000000000..24c50966797 --- /dev/null +++ b/views/sale_coupon_program_views.xml @@ -0,0 +1,284 @@ + + + + + + sale.coupon.program.common.form + sale.coupon.program + +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + + + sale.coupon.program.form + sale.coupon.program + + primary + + +
+
+
+ + + + +

+
+ + +
+
+ + + sale.coupon.program.tree + sale.coupon.program + + + + + + + + + + sale.coupon.program.search + sale.coupon.program + + + + + + + + + + + Coupon Program + sale.coupon.program + form + tree,form + + + [('program_type','=', 'coupon_program')] + { + 'default_program_type': 'coupon_program', + 'promo_code_usage': 'code_needed', + 'search_default_opened': 1 + } + +

+ Click here to create a new coupon program. +

+

+ Using coupon programs allows you to propose to your customers:
+

    +
  • Free products or discounts if some conditions on products or purchased amount are met.
  • +
  • Promotional offers limited in time.
  • +
  • Generate coupons for a customers group.
  • +
  • Specific offers based on a customers group or a product set.
  • +
  • A combination of the preceeding points.
  • +

+ A coupon is applied by entering a valid code if the program rules are met and if the + reward is already in the order lines. +

+
+
+ + + + + + + + sale.coupon.promotion.program.form + sale.coupon.program + + primary + + +

+
+ + + + + + + + + + + + + + + + + + + + + + + Apply on Current Order - Reward will be applied on current order.
+ Apply on Next Order - Generate a coupon for a next order. +
+
+
+
+
+ + + sale.coupon.promotion.program.tree + sale.coupon.program + + + + + + + + + + + + sale.coupon.promotion.program.search + sale.coupon.program + + + + + + + + + + Promotion Program + sale.coupon.program + form + tree,form + + + [('program_type', '=', 'promotion_program')] + { + 'default_program_type': 'promotion_program', + 'default_promo_code_usage': + 'no_code_needed', + 'search_default_opened': 1 + } + +

+ Click here to create a new promotion program. +

+

+ Using promotion programs allows you to propose to your customers:
+

    +
  • Promotional offers limited in time or a limited order number.
  • +
  • Public code printed in advertisements, magasines, etc.
  • +
  • Specific offers based on a customers group or a product set.
  • +
  • A combination of the preceeding points.
  • +

+ You may choose to apply the program reward immediatly on the sales order or to propose the reward for a next order. In the second case a coupon will be generated at the order confirmation for the customer to use it later. +

+
+
+ + + +
diff --git a/views/sale_coupon_views.xml b/views/sale_coupon_views.xml new file mode 100644 index 00000000000..5ef1d81d7f7 --- /dev/null +++ b/views/sale_coupon_views.xml @@ -0,0 +1,47 @@ + + + + sale.coupon.tree + sale.coupon + + + + + + + + + + + + + + Coupons + sale.coupon + + [('program_id', '=', active_id)] + {} + + + + + sale.coupon.form + sale.coupon + +
+
+ +
+ + + + + + + + +
+
+
+ +
diff --git a/views/sale_order_views.xml b/views/sale_order_views.xml new file mode 100644 index 00000000000..ed1902d54f2 --- /dev/null +++ b/views/sale_order_views.xml @@ -0,0 +1,38 @@ + + + + sale.order.form.inherit.coupon + sale.order + + + +