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 @@
+
+
+
+
+
+
Congratulations!
+ Enter the following coupon code () during your next purchase to receive this discount: .
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sales Order
+ sale.order
+
+ [('state', '!=', 'cancel')]
+ {}
+
+
diff --git a/wizard/__init__.py b/wizard/__init__.py
new file mode 100644
index 00000000000..41789332003
--- /dev/null
+++ b/wizard/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import sale_coupon_apply_code
+import sale_coupon_generate
diff --git a/wizard/sale_coupon_apply_code.py b/wizard/sale_coupon_apply_code.py
new file mode 100644
index 00000000000..fa109b7511b
--- /dev/null
+++ b/wizard/sale_coupon_apply_code.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class SaleCouponApplyCode(models.TransientModel):
+ _name = 'sale.coupon.apply.code'
+ _rec_name = 'coupon_code'
+
+ coupon_code = fields.Char(string="Coupon", required=True)
+
+ @api.multi
+ def process_coupon(self):
+ """
+ Apply the entered coupon code if valid, raise an UserError otherwise.
+ """
+ sales_order = self.env['sale.order'].browse(self.env.context.get('active_id'))
+ error_status = self.apply_coupon(sales_order, self.coupon_code)
+ if error_status.get('error', False):
+ raise UserError(error_status.get('error', False))
+
+ def apply_coupon(self, order, coupon_code):
+ error_status = {}
+ program = self.env['sale.coupon.program'].search([('promo_code', '=', coupon_code)])
+ if program:
+ error_status = program._check_promo_code(order, coupon_code)
+ if not error_status:
+ if program.promo_applicability == 'on_next_order':
+ coupon = order._create_reward_coupon(program)
+ return {
+ 'generated_coupon': {
+ 'reward': coupon.program_id.discount_line_product_id.name,
+ 'code': coupon.code,
+ }
+ }
+ else: # The program is applied on this order
+ order._create_reward_line(program)
+ order.code_promo_program_id = program
+ else:
+ coupon = self.env['sale.coupon'].search([('code', '=', coupon_code)], limit=1)
+ if coupon:
+ error_status = coupon._check_coupon_code(order)
+ if not error_status:
+ order._create_reward_line(coupon.program_id)
+ order.applied_coupon_ids += coupon
+ coupon.write({'state': 'used'})
+ else:
+ error_status = {'error': _('The code %s is invalid') % (coupon_code)}
+ return error_status
diff --git a/wizard/sale_coupon_apply_code_views.xml b/wizard/sale_coupon_apply_code_views.xml
new file mode 100644
index 00000000000..41977019bd6
--- /dev/null
+++ b/wizard/sale_coupon_apply_code_views.xml
@@ -0,0 +1,26 @@
+
+
+
+ sale.coupon.apply.code.form
+ sale.coupon.apply.code
+
+
+
+
+
+
+ Enter Coupon Code
+ sale.coupon.apply.code
+ form
+ new
+
+
+
diff --git a/wizard/sale_coupon_generate.py b/wizard/sale_coupon_generate.py
new file mode 100644
index 00000000000..0b820e27499
--- /dev/null
+++ b/wizard/sale_coupon_generate.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+from odoo.tools.safe_eval import safe_eval
+
+class SaleCouponGenerate(models.TransientModel):
+ _name = 'sale.coupon.generate'
+
+ nbr_coupons = fields.Integer(string="Number of Coupons", help="Number of coupons", default=1)
+ generation_type = fields.Selection([
+ ('nbr_coupon', 'Number of Coupons'),
+ ('nbr_customer', 'Number of Selected Customers')
+ ], default='nbr_coupon')
+ partners_domain = fields.Char(string="Customer")
+ partner_ids = fields.Many2many('res.partner', string="Related Partners", compute='_compute_partner_ids')
+
+ @api.multi
+ def generate_coupon(self):
+ """Generates the number of coupons entered in wizard field nbr_coupons
+ """
+ program = self.env['sale.coupon.program'].browse(self.env.context.get('active_id'))
+
+ vals = {'program_id': program.id}
+
+ if self.generation_type == 'nbr_coupon' and self.nbr_coupons > 0:
+ for count in range(0, self.nbr_coupons):
+ self.env['sale.coupon'].create(vals)
+
+ if self.generation_type == 'nbr_customer' and self.partners_domain:
+ for partner in self.partner_ids:
+ vals.update({'partner_id': partner.id})
+ coupon = self.env['sale.coupon'].create(vals)
+ subject = '%s, a coupon has been generated for you' % (partner.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.env['mail.mail'].create({
+ 'subject': subject,
+ 'body': body,
+ 'email_from': self.env.user.email or '',
+ 'email_to': partner.email,
+ })
+
+ @api.depends('partners_domain')
+ def _compute_partner_ids(self):
+ self.partner_ids = self.partners_domain and self.env['res.partner'].search(safe_eval(self.partners_domain))
diff --git a/wizard/sale_coupon_generate_views.xml b/wizard/sale_coupon_generate_views.xml
new file mode 100644
index 00000000000..c2401d60eee
--- /dev/null
+++ b/wizard/sale_coupon_generate_views.xml
@@ -0,0 +1,29 @@
+
+
+
+ sale.coupon.generate.form
+ sale.coupon.generate
+
+
+
+
+
+
+ Number of Coupons To Generate
+ sale.coupon.generate
+ form
+ new
+
+
+