[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.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import models
|
||||
import wizard
|
||||
@@ -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',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="sale_coupon_created_coupon_email_template">
|
||||
<div>
|
||||
<h2>Congratulations!</h2>
|
||||
Enter the following coupon code (<i><t t-esc="code"/></i>) during your next purchase to receive this discount: <b><t t-esc="reward_description"/></b>.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">sale settings</field>
|
||||
<field name="model">sale.config.settings</field>
|
||||
<field name="inherit_id" ref="sales_team.view_sale_config_settings"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='quotations_sales']" position="inside">
|
||||
<label for="id" string="Coupons"/>
|
||||
<div>
|
||||
<field name="module_sale_coupon" class="oe_inline" widget="upgrade_boolean"/>
|
||||
<label for="module_sale_coupon"/>
|
||||
<a href="https://www.odoo.com/fr_FR/" target="_blank"> More Info</a>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,284 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Common form view between the coupon programs and the promotion programs -->
|
||||
<record id="sale_coupon_program_view_form_common" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.program.common.form</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Coupon Program">
|
||||
<sheet>
|
||||
<div class="oe_button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options='{"terminology": "active"}'/>
|
||||
</button>
|
||||
<button class="oe_stat_button" type="object" icon="fa-usd" name="action_view_sales_orders">
|
||||
<field name="order_count" string="Sales" widget="statinfo"/>
|
||||
</button>
|
||||
</div>
|
||||
<div name="title" class="oe_left">
|
||||
<label class="oe_edit_only" for="name" string="Program Name"/>
|
||||
</div>
|
||||
<group>
|
||||
<group name="conditions" string="Conditions">
|
||||
<field name="program_type" invisible="1"/>
|
||||
<field name="rule_products_domain" placeholder="Select product" widget="char_domain" options="{'model': 'product.product'}"/>
|
||||
<field name="rule_product_ids" widget="many2many_tags" invisible="1"/>
|
||||
<label string="Quantity" for="rule_min_quantity" attrs="{'invisible': [('rule_products_domain', '=', False)]}"/>
|
||||
<div attrs="{'invisible': [('rule_products_domain', '=',False)]}">
|
||||
<field name="rule_min_quantity" class="oe_inline"/>
|
||||
</div>
|
||||
<label string="Minimum Purchase Of" for="rule_minimum_amount" />
|
||||
<div name="rule_minimum_amount">
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="rule_minimum_amount" class="oe_inline" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="rule_minimum_amount_tax_inclusion" class="oe_inline"/>
|
||||
</div>
|
||||
<field name="company_id" placeholder="Select company" groups="base.group_multi_company"></field>
|
||||
</group>
|
||||
<group name="validity" string="Validity"/>
|
||||
</group>
|
||||
<group string="Rewards">
|
||||
<group name='reward'>
|
||||
<field name="reward_type" widget="radio"/>
|
||||
<field name="discount_line_product_id" groups="base.group_no_one"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="reward_product_id" attrs="{'invisible': [('reward_type', 'in', ('discount', 'free_shipping'))], 'required': [('reward_type', '=', 'product')]}" placeholder="Select reward product"/>
|
||||
<label string="Quantity" for="reward_product_quantity" attrs="{'invisible': ['|', ('reward_type', 'in', ('discount', 'free_shipping')), ('reward_product_id', '=',False)]}"/>
|
||||
<div attrs="{'invisible': ['|', ('reward_type', 'in', ('discount', 'free_shipping')),('reward_product_id', '=',False)]}">
|
||||
<field name="reward_product_quantity" class="oe_inline"/>
|
||||
<field name="reward_product_uom_id" class="oe_inline"/>
|
||||
</div>
|
||||
<label string="Apply Discount" for="discount_type" attrs="{'invisible': [('reward_type', 'in', ('product', 'free_shipping'))]}"/>
|
||||
<div attrs="{'invisible': [('reward_type', 'in', ('product', 'free_shipping'))]}">
|
||||
<field name="discount_type" class="oe_inline" attrs="{'required': [('reward_type','=','discount')]}"/>
|
||||
<field name="discount_percentage" attrs="{'invisible': [('discount_type', '!=', 'percentage')],'required': [('discount_type', '=', 'percentage')]}" class="oe_inline"/>
|
||||
<span attrs="{'invisible': [('discount_type', '!=', 'percentage')],'required': [('discount_type', '=', 'percentage')]}" class="oe_inline">%</span>
|
||||
</div>
|
||||
<label string="Fixed Amount" for="discount_fixed_amount" attrs="{'invisible': ['|',('discount_type', '!=', 'fixed_amount'), ('reward_type', '!=', 'discount')]}" />
|
||||
<div attrs="{'invisible': ['|',('discount_type', '!=', 'fixed_amount'), ('reward_type', '!=', 'discount')]}">
|
||||
<field name="discount_fixed_amount" class="oe_inline" attrs="{'required':[('discount_type', '=', 'fixed_amount')]}" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
</div>
|
||||
<field name="discount_apply_on" attrs="{'invisible':
|
||||
['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}" widget="radio"/>
|
||||
<field name="discount_specific_product_id" attrs="{'invisible': ['|', '|', ('discount_apply_on', '!=', 'specific_product'),('discount_type', '!=', 'percentage'), ('reward_type', 'in', ('product', 'free_shipping'))], 'required': [('reward_type', '=', 'discount'),('discount_apply_on', '=', 'specific_product'),('discount_type', '=', 'percentage')]}" placeholder="Select product"/>
|
||||
<field name="discount_max_amount" attrs="{'invisible': ['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
<label for="discount_max_amount" string="Max Discount Amount" attrs="{'invisible': ['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}"/>
|
||||
<div attrs="{'invisible': ['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}">
|
||||
<field name="discount_max_amount" class="oe_inline" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
<span class="oe_grey">if 0, no limit</span>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Coupon Program -->
|
||||
<record id="sale_coupon_program_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.program.form</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="inherit_id" ref="sale_coupon_program_view_form_common"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="before">
|
||||
<header>
|
||||
<button type="action" name="%(sale_coupon.sale_coupon_generate_action)d"
|
||||
string="Generate Coupon" attrs="{'invisible': [('active', '=', False)]}"/>
|
||||
<button type="action" name="%(sale_coupon.sale_coupon_generate_action)d"
|
||||
string="Generate Coupon" attrs="{'invisible': [('active', '=', True)]}" class="oe_highlight"/>
|
||||
</header>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='order_count']/.." position="before">
|
||||
<button class="oe_stat_button" type="action" icon="fa-xing" name="%(sale_coupon.sale_coupon_action)d">
|
||||
<field name="coupon_count" string="Coupons" widget="statinfo"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='title']" position="inside">
|
||||
<h1><field name="name" class="oe_title" placeholder="Coupon Program Name..." height="20px"/></h1>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='validity']" position="inside">
|
||||
<label for="validity_duration" string="Validity Duration"/>
|
||||
<div>
|
||||
<field name="validity_duration" class="oe_inline"/>
|
||||
<label string=" Days" class="oe_inline"/><span class="oe_grey">if 0, infinite use</span>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_program_view_tree" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.program.tree</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_program_view_search" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.program.search</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<filter string="Active" domain="[('active', '=', True)]" help="Opened Programs"/>
|
||||
<filter string="Closed" domain="[('active', '=', False)]" help="Closed Programs"/>
|
||||
<filter string="Expired" name="expired" domain="[('rule_date_to', '<', datetime.datetime.now())]" help="Expired Programs"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_program_action_coupon_program" model="ir.actions.act_window">
|
||||
<field name="name">Coupon Program</field>
|
||||
<field name="res_model">sale.coupon.program</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="sale_coupon_program_view_search"/>
|
||||
<field name="view_ids" eval="[
|
||||
(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'tree'}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('sale_coupon_program_view_form')})]"/>
|
||||
<field name="domain">[('program_type','=', 'coupon_program')]</field>
|
||||
<field name="context">{
|
||||
'default_program_type': 'coupon_program',
|
||||
'promo_code_usage': 'code_needed',
|
||||
'search_default_opened': 1
|
||||
}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="oe_view_nocontent_create">
|
||||
Click here to create a new coupon program.
|
||||
</p>
|
||||
<p>
|
||||
Using coupon programs allows you to propose to your customers:<br/>
|
||||
<ul>
|
||||
<li>Free products or discounts if some conditions on products or purchased amount are met.</li>
|
||||
<li>Promotional offers limited in time.</li>
|
||||
<li>Generate coupons for a customers group.</li>
|
||||
<li>Specific offers based on a customers group or a product set.</li>
|
||||
<li>A combination of the preceeding points.</li>
|
||||
</ul><br/>
|
||||
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.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_coupon_type_config" action="sale_coupon_program_action_coupon_program" parent="sale.menu_sales_config" groups="sales_team.group_sale_manager" sequence="2"/>
|
||||
|
||||
|
||||
<!-- Promotion Program -->
|
||||
|
||||
<record id="sale_coupon_program_view_promo_program_form" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.promotion.program.form</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="inherit_id" ref="sale_coupon_program_view_form_common"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='title']" position="inside">
|
||||
<h1><field name="name" class="oe_title" placeholder="Promotion Program Name..." height="20px"/></h1>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='order_count']/.." position="before">
|
||||
<button class="oe_stat_button" type="action" icon="fa-xing" name="%(sale_coupon.sale_coupon_action)d" attrs="{'invisible': [('promo_applicability', '=', 'on_current_order')]}">
|
||||
<field name="coupon_count" string="Coupons" widget="statinfo"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='reward']" position="before">
|
||||
<field name="sequence" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='program_type']" position="after">
|
||||
<field name="rule_partners_domain" placeholder="Select customer" widget="char_domain" options="{'model': 'res.partner'}"/>
|
||||
<field name="rule_partner_ids" widget="many2many_tags" invisible="1" domain="[('is_customer', '=', True)]"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='rule_minimum_amount']" position="after">
|
||||
<field name="promo_code_usage" widget="radio"/>
|
||||
<field name="promo_code" attrs="{'required': [('promo_code_usage', '=', 'code_needed')], 'invisible': [('promo_code_usage', '=', 'no_code_needed')]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='validity']" position="inside">
|
||||
<label string="Apply on First" for="maximum_use_number" class="oe_inline"/>
|
||||
<div>
|
||||
<field name="maximum_use_number" class="oe_inline"/>
|
||||
<span> Orders</span>
|
||||
<span class="oe_grey"> if 0, infinite use</span>
|
||||
</div>
|
||||
<field name="rule_date_from" class="oe_inline"/>
|
||||
<field name="rule_date_to" class="oe_inline"/>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='reward']" position="before">
|
||||
<group>
|
||||
<field name="promo_applicability" widget="radio"/>
|
||||
</group>
|
||||
<group>
|
||||
<span class="oe_grey">
|
||||
<b>Apply on Current Order -</b> Reward will be applied on current order.<br/>
|
||||
<b>Apply on Next Order -</b> Generate a coupon for a next order.
|
||||
</span>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_program_view_promo_program_tree" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.promotion.program.tree</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="promo_code_usage"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_program_view_promo_program_search" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.promotion.program.search</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<filter string="Active" domain="[('active', '=', True)]" help="Opened Programs"/>
|
||||
<filter string="Closed" domain="[('active', '=', False)]" help="Closed Programs"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_program_action_promo_program" model="ir.actions.act_window">
|
||||
<field name="name">Promotion Program</field>
|
||||
<field name="res_model">sale.coupon.program</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_ids" eval="[
|
||||
(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'tree'}),
|
||||
(0, 0, {'view_mode': 'form', 'view_id': ref('sale_coupon_program_view_promo_program_form')})]"/>
|
||||
<field name="search_view_id" ref="sale_coupon_program_view_promo_program_search"/>
|
||||
<field name="domain">[('program_type', '=', 'promotion_program')]</field>
|
||||
<field name="context">{
|
||||
'default_program_type': 'promotion_program',
|
||||
'default_promo_code_usage':
|
||||
'no_code_needed',
|
||||
'search_default_opened': 1
|
||||
}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="oe_view_nocontent_create">
|
||||
Click here to create a new promotion program.
|
||||
</p>
|
||||
<p>
|
||||
Using promotion programs allows you to propose to your customers:<br/>
|
||||
<ul>
|
||||
<li>Promotional offers limited in time or a limited order number.</li>
|
||||
<li>Public code printed in advertisements, magasines, etc.</li>
|
||||
<li>Specific offers based on a customers group or a product set.</li>
|
||||
<li>A combination of the preceeding points.</li>
|
||||
</ul><br/>
|
||||
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.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem action="sale_coupon_program_action_promo_program" id="menu_promotion_type_config" parent="sale.menu_sales_config" groups="sales_team.group_sale_manager" sequence="1"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_coupon_view_tree" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.tree</field>
|
||||
<field name="model">sale.coupon</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Coupons" create="false" edit="false" delete="false">
|
||||
<field name="code"/>
|
||||
<field name="expiration_date"/>
|
||||
<field name="program_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="order_id"/>
|
||||
<field name="state"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_action" model="ir.actions.act_window">
|
||||
<field name="name">Coupons</field>
|
||||
<field name="res_model">sale.coupon</field>
|
||||
<field name="view_id" ref="sale_coupon_view_tree"/>
|
||||
<field name="domain">[('program_id', '=', active_id)]</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="sale_coupon_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.form</field>
|
||||
<field name="model">sale.coupon</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Coupons" create="false" edit="false" delete="false">
|
||||
<header>
|
||||
<field name="state" widget="statusbar" statusbar_visible="new,used,expired" context="{'state': state}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="expiration_date"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="order_id"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_order_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.inherit.coupon</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header/button[@name='action_cancel']" position="before">
|
||||
<button name="%(sale_coupon.sale_coupon_apply_code_action)d" string="Apply Coupon" type="action" groups="base.group_user" states="draft"/>
|
||||
</xpath>
|
||||
<xpath expr="//page[@string='Other Information']">
|
||||
<group groups="base.group_no_one">
|
||||
<group string="Promotion Program">
|
||||
<field name="promo_code" groups="base.group_no_one"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Offered Coupons">
|
||||
<div>
|
||||
<field name="generated_coupon_ids" groups="base.group_no_one" readonly="1"/>
|
||||
</div>
|
||||
</group>
|
||||
<group string="Applied Coupons">
|
||||
<div>
|
||||
<field name="applied_coupon_ids" groups="base.group_no_one" options="{'no_create': True}"/>
|
||||
</div>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_order_action" model="ir.actions.act_window">
|
||||
<field name="name">Sales Order</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
<field name="view_id" ref="sale.view_order_tree"/>
|
||||
<field name="domain">[('state', '!=', 'cancel')]</field>
|
||||
<field name="context">{}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_coupon_apply_code_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.apply.code.form</field>
|
||||
<field name="model">sale.coupon.apply.code</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Apply coupon">
|
||||
<group>
|
||||
<field name="coupon_code"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="process_coupon" string="Apply Coupon" type="object" class="oe_highlight"/>
|
||||
<button special="cancel" string="Cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_apply_code_action" model="ir.actions.act_window">
|
||||
<field name="name">Enter Coupon Code</field>
|
||||
<field name="res_model">sale.coupon.apply.code</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="view_id" ref="sale_coupon_apply_code_view_form"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -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))
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_coupon_generate_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.generate.form</field>
|
||||
<field name="model">sale.coupon.generate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Generate Coupons">
|
||||
<group>
|
||||
<field name="generation_type" widget="radio"/>
|
||||
<field name="partners_domain" attrs="{'invisible': [('generation_type', '!=', 'nbr_customer')]}" widget="char_domain" options="{'model': 'res.partner'}"/>
|
||||
<field name="partner_ids" groups="base.group_no_one"/>
|
||||
<field name="nbr_coupons" attrs="{'invisible': [('generation_type', '!=', 'nbr_coupon')]}"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="generate_coupon" type="object" string="Generate" class="oe_highlight"/>
|
||||
<button special="cancel" string="Cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_coupon_generate_action" model="ir.actions.act_window">
|
||||
<field name="name">Number of Coupons To Generate</field>
|
||||
<field name="res_model">sale.coupon.generate</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="view_id" ref="sale_coupon_generate_view_form"/>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user