[MRG] sale_coupon: move to community
+ add i18n from website_sale_coupon
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import report
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- 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_management'],
|
||||
'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/res_config_settings_views.xml',
|
||||
'report/sale_coupon_report.xml',
|
||||
'report/sale_coupon_report_templates.xml',
|
||||
'data/sale_coupon_email_data.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/sale_coupon_demo.xml',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="product_product_10_percent_discount" model="product.product">
|
||||
<field name="name">10.0% discount on total amount</field>
|
||||
<field name="type">service</field>
|
||||
<field name="taxes_id" eval="False"/>
|
||||
<field name="supplier_taxes_id" eval="False"/>
|
||||
<field name="sale_ok" eval="False"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
<field name="invoice_policy">order</field>
|
||||
<field name="default_code">10PERCENTDISC</field>
|
||||
<field name="categ_id" ref="product.product_category_3"/>
|
||||
</record>
|
||||
|
||||
<record id="10_percent_auto_applied" model="sale.coupon.program">
|
||||
<field name="name">Code for 10% on orders</field>
|
||||
<field name="promo_code_usage">code_needed</field>
|
||||
<field name="promo_code">10pc</field>
|
||||
<field name="discount_apply_on">on_order</field>
|
||||
<field name="discount_type">percentage</field>
|
||||
<field name="discount_percentage">10.0</field>
|
||||
<field name="program_type">promotion_program</field>
|
||||
<field name="discount_line_product_id" ref="sale_coupon.product_product_10_percent_discount"/>
|
||||
</record>
|
||||
|
||||
<record id="product_product_free_large_cabinet" model="product.product">
|
||||
<field name="name">Free Product - Large Cabinet</field>
|
||||
<field name="type">service</field>
|
||||
<field name="taxes_id" eval="False"/>
|
||||
<field name="supplier_taxes_id" eval="False"/>
|
||||
<field name="sale_ok" eval="False"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
<field name="invoice_policy">order</field>
|
||||
<field name="default_code">FREELARGECABINET</field>
|
||||
<field name="categ_id" ref="product.product_category_3"/>
|
||||
</record>
|
||||
|
||||
<record id="3_ipad_plus_1_free" model="sale.coupon.program">
|
||||
<field name="name">Buy 3 large cabinets, get one for free</field>
|
||||
<field name="promo_code_usage">no_code_needed</field>
|
||||
<field name="discount_apply_on">on_order</field>
|
||||
<field name="reward_type">product</field>
|
||||
<field name="program_type">promotion_program</field>
|
||||
<field name="reward_product_id" ref="product.product_product_6"></field>
|
||||
<field name="rule_min_quantity">3</field>
|
||||
<field name="rule_products_domain">[["name","ilike","large cabinet"]]</field>
|
||||
<field name="discount_line_product_id" ref="sale_coupon.product_product_free_large_cabinet"/>
|
||||
</record>
|
||||
|
||||
<record id="10_percent_coupon" model="sale.coupon.program">
|
||||
<field name="name">10% Discount</field>
|
||||
<field name="promo_code_usage">code_needed</field>
|
||||
<field name="discount_apply_on">on_order</field>
|
||||
<field name="discount_type">percentage</field>
|
||||
<field name="discount_percentage">10.0</field>
|
||||
<field name="program_type">coupon_program</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" ?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="sale_coupon.mail_template_sale_coupon" model="mail.template">
|
||||
<field name="name">Coupon: Send by Email</field>
|
||||
<field name="model_id" ref="sale_coupon.model_sale_coupon"/>
|
||||
<field name="subject">Your reward coupon from ${object.program_id.company_id.name} </field>
|
||||
<field name="email_from">${object.program_id.company_id.email|safe}</field>
|
||||
<field name="partner_to">${object.partner_id.id}</field>
|
||||
<field name="body_html" type="html">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; margin:0px auto;"><tbody>
|
||||
<tr><td valign="top" style="text-align: center; font-size: 14px;">
|
||||
% if object.partner_id.name:
|
||||
Congratulations ${object.partner_id.name},<br />
|
||||
% endif
|
||||
|
||||
Here is your reward from ${object.program_id.company_id.name}.<br />
|
||||
|
||||
% if object.program_id.reward_type == 'discount':
|
||||
% if object.program_id.discount_type == 'fixed_amount':
|
||||
<span style="font-size: 50px; color: #875A7B; font-weight: bold;">
|
||||
${'%s' % format_amount(object.program_id.discount_fixed_amount, object.program_id.currency_id)}
|
||||
</span><br />
|
||||
<strong style="font-size: 24px;">off on your next order</strong><br />
|
||||
%else
|
||||
<span style="font-size: 50px; color: #875A7B; font-weight: bold;">
|
||||
${object.program_id.discount_percentage} %
|
||||
</span>
|
||||
% if object.program_id.discount_apply_on == 'specific_product':
|
||||
<br /><strong style="font-size: 24px;">
|
||||
${'on %s' % object.program_id.discount_specific_product_id.name}
|
||||
</strong>
|
||||
% elif object.program_id.discount_apply_on == 'cheapest_product':
|
||||
<br /><strong style="font-size: 24px;">
|
||||
off on the cheapest product
|
||||
</strong>
|
||||
% else
|
||||
<br /><strong style="font-size: 24px;">
|
||||
off on your next order
|
||||
</strong>
|
||||
% endif
|
||||
<br />
|
||||
% endif
|
||||
% elif object.program_id.reward_type == 'product':
|
||||
<span style="font-size: 36px; color: #875A7B; font-weight: bold;">
|
||||
${'get %s free %s' % (object.program_id.reward_product_quantity, object.program_id.reward_product_id.name)}
|
||||
</span><br />
|
||||
<strong style="font-size: 24px;">on your next order</strong><br />
|
||||
% elif object.program_id.reward_type == 'free_shipping':
|
||||
<span style="font-size: 36px; color: #875A7B; font-weight: bold;">
|
||||
get free shipping
|
||||
</span><br />
|
||||
<strong style="font-size: 24px;">on your next order</strong><br />
|
||||
% endif
|
||||
</td></tr>
|
||||
<tr style="margin-top: 16px"><td valign="top" style="text-align: center; font-size: 14px;">
|
||||
Use this promo code
|
||||
% if object.expiration_date:
|
||||
before ${object.expiration_date}
|
||||
% endif
|
||||
<p style="margin-top: 16px;">
|
||||
<strong style="padding: 16px 8px 16px 8px; border-radius: 3px; background-color: #F1F1F1;">
|
||||
${object.code}
|
||||
</strong>
|
||||
</p>
|
||||
% if object.program_id.rule_min_quantity not in [0, 1]
|
||||
<span style="font-size: 14px;">
|
||||
Minimum purchase of ${object.program_id.rule_min_quantity} products
|
||||
</span><br />
|
||||
% endif
|
||||
% if object.program_id.rule_minimum_amount != 0.00
|
||||
<span style="font-size: 14px;">
|
||||
Valid for purchase above ${object.program_id.company_id.currency_id.symbol}${'%0.2f' % object.program_id.rule_minimum_amount |float}
|
||||
</span><br />
|
||||
% endif
|
||||
<br/>
|
||||
Thank you,
|
||||
<br/>
|
||||
% if object.order_id.user_id:
|
||||
${object.order_id.user_id.signature | safe}
|
||||
% endif
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</field>
|
||||
<field name="report_template" ref="report_coupon_code"/>
|
||||
<field name="report_name">Your Coupon Code</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="user_signature" eval="False"/>
|
||||
<field name="lang">${object.partner_id.lang}</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import sale_coupon
|
||||
from . import sale_coupon_reward
|
||||
from . import sale_coupon_rules
|
||||
from . import sale_coupon_program
|
||||
from . import sale_order
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import random
|
||||
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 for barcode
|
||||
generation.
|
||||
|
||||
A decimal serialisation is longer than a hexadecimal one *but* it
|
||||
generates a more compact barcode (Code128C rather than Code128A).
|
||||
|
||||
Generate 8 bytes (64 bits) barcodes as 16 bytes barcodes are not
|
||||
compatible with all scanners.
|
||||
"""
|
||||
return str(random.getrandbits(64))
|
||||
|
||||
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")
|
||||
sales_order_id = fields.Many2one('sale.order', 'Applied on order', readonly=True,
|
||||
help="The sales order on which the coupon is applied")
|
||||
discount_line_product_id = fields.Many2one('product.product', related='program_id.discount_line_product_id', readonly=False,
|
||||
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 = (coupon.create_date + relativedelta(days=coupon.program_id.validity_duration)).date()
|
||||
|
||||
def _check_coupon_code(self, order):
|
||||
message = {}
|
||||
applicable_programs = order._get_applicable_programs()
|
||||
if self.state in ('used', 'expired') or \
|
||||
(self.expiration_date and self.expiration_date < order.date_order.date()):
|
||||
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)}
|
||||
# Minimum requirement should not be checked if the coupon got generated by a promotion program (the requirement should have only be checked to generate the coupon)
|
||||
elif self.program_id.program_type == 'coupon_program' and not self.program_id._filter_on_mimimum_amount(order):
|
||||
message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.program_id.rule_minimum_amount, self.program_id.currency_id.name)}
|
||||
elif not self.program_id.active:
|
||||
message = {'error': _('The coupon program for %s is in draft or closed state') % (self.code)}
|
||||
elif self.partner_id and self.partner_id != order.partner_id:
|
||||
message = {'error': _('Invalid partner.')}
|
||||
elif self.program_id in order.applied_coupon_ids.mapped('program_id'):
|
||||
message = {'error': _('A Coupon is already applied for the same reward')}
|
||||
elif self.program_id._is_global_discount_program() and order._is_global_discount_already_applied():
|
||||
message = {'error': _('Global discounts are not cumulable.')}
|
||||
elif self.program_id.reward_type == 'product' and not order._is_reward_in_order_lines(self.program_id):
|
||||
message = {'error': _('The reward products should be in the sales order lines to apply the discount.')}
|
||||
elif not self.program_id._is_valid_partner(order.partner_id):
|
||||
message = {'error': _("The customer doesn't have access to this reward.")}
|
||||
# Product requirement should not be checked if the coupon got generated by a promotion program (the requirement should have only be checked to generate the coupon)
|
||||
elif self.program_id.program_type == 'coupon_program' and not self.program_id._filter_programs_on_products(order):
|
||||
message = {'error': _("You don't have the required product quantities on your sales order. All the products should be recorded on the sales order. (Example: You need to have 3 T-shirts on your sales order if the promotion is 'Buy 2, Get 1 Free').")}
|
||||
else:
|
||||
if self.program_id not in applicable_programs and self.program_id.promo_applicability == 'on_current_order':
|
||||
message = {'error': _('At least one of the required conditions is not met to get the reward!')}
|
||||
return message
|
||||
|
||||
@api.multi
|
||||
def action_coupon_sent(self):
|
||||
""" Open a window to compose an email, with the edi invoice template
|
||||
message loaded by default
|
||||
"""
|
||||
self.ensure_one()
|
||||
template = self.env.ref('sale_coupon.mail_template_sale_coupon', False)
|
||||
compose_form = self.env.ref('mail.email_compose_message_wizard_form', False)
|
||||
ctx = dict(
|
||||
default_model='sale.coupon',
|
||||
default_res_id=self.id,
|
||||
default_use_template=bool(template),
|
||||
default_template_id=template.id,
|
||||
default_composition_mode='comment',
|
||||
custom_layout='mail.mail_notification_light',
|
||||
)
|
||||
return {
|
||||
'name': _('Compose Email'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'mail.compose.message',
|
||||
'views': [(compose_form.id, 'form')],
|
||||
'view_id': compose_form.id,
|
||||
'target': 'new',
|
||||
'context': ctx,
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
# -*- 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, ValidationError
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
|
||||
class SaleCouponProgram(models.Model):
|
||||
_name = 'sale.coupon.program'
|
||||
_description = "Sales Coupon Program"
|
||||
_inherits = {'sale.coupon.rule': 'rule_id', 'sale.coupon.reward': 'reward_id'}
|
||||
# We should apply 'discount' promotion first to avoid offering free product when we should not.
|
||||
# Eg: If the discount lower the SO total below the required threshold
|
||||
# Note: This is only revelant when programs have the same sequence (which they have by default)
|
||||
_order = "sequence, reward_type"
|
||||
|
||||
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="Coupon Rule", 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 cumulative)\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')
|
||||
order_line_ids = fields.Many2many('sale.order.line', store=False, search='_search_order_line_ids')
|
||||
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', readonly=True)
|
||||
validity_duration = fields.Integer(default=1,
|
||||
help="Validity duration for a coupon after its generation")
|
||||
|
||||
@api.constrains('promo_code')
|
||||
def _check_promo_code_constraint(self):
|
||||
""" Program code must be unique """
|
||||
for program in self.filtered(lambda p: p.promo_code):
|
||||
domain = [('id', '!=', program.id), ('promo_code', '=', program.promo_code)]
|
||||
if self.search(domain):
|
||||
raise ValidationError(_('The program code must be unique!'))
|
||||
|
||||
def _search_order_line_ids(self, operator, arg):
|
||||
# just a hack to enable the invalidation of 'order_count'
|
||||
return []
|
||||
|
||||
@api.depends('order_line_ids.product_id')
|
||||
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)
|
||||
|
||||
@api.depends('coupon_ids')
|
||||
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.onchange('promo_code_usage')
|
||||
def _onchange_promo_code_usage(self):
|
||||
if self.promo_code_usage == 'no_code_needed':
|
||||
self.promo_code = False
|
||||
|
||||
@api.onchange('reward_product_id')
|
||||
def _onchange_reward_product_id(self):
|
||||
if self.reward_product_id:
|
||||
self.reward_product_uom_id = self.reward_product_id.uom_id
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
program = super(SaleCouponProgram, self).create(vals)
|
||||
if not vals.get('discount_line_product_id', False):
|
||||
discount_line_product_id = self.env['product.product'].create({
|
||||
'name': program.reward_id.display_name,
|
||||
'type': 'service',
|
||||
'taxes_id': False,
|
||||
'supplier_taxes_id': False,
|
||||
'sale_ok': False,
|
||||
'purchase_ok': False,
|
||||
'invoice_policy': 'order',
|
||||
'lst_price': -1000000, # Prevent pricelist strikethrough as negative value will always be lower then default 1$
|
||||
})
|
||||
program.write({'discount_line_product_id': discount_line_product_id.id})
|
||||
return program
|
||||
|
||||
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.display_name})
|
||||
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': self.active})
|
||||
|
||||
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()
|
||||
if self.maximum_use_number != 0 and self.order_count >= self.maximum_use_number:
|
||||
message = {'error': _('Promo code %s has been expired.') % (coupon_code)}
|
||||
elif not self._filter_on_mimimum_amount(order):
|
||||
message = {'error': _('A minimum of %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 and self.promo_code_usage == 'code_needed':
|
||||
message = {'error': _('Promotionals codes are not cumulative.')}
|
||||
elif self._is_global_discount_program() and order._is_global_discount_already_applied():
|
||||
message = {'error': _('Global discounts are not cumulative.')}
|
||||
elif self.promo_applicability == 'on_current_order' and self.reward_type == 'product' and not order._is_reward_in_order_lines(self):
|
||||
message = {'error': _('The reward products should be in the sales order lines to apply the discount.')}
|
||||
elif not self._is_valid_partner(order.partner_id):
|
||||
message = {'error': _("The customer doesn't have access to this reward.")}
|
||||
elif not self._filter_programs_on_products(order):
|
||||
message = {'error': _("You don't have the required product quantities on your sales order. If the reward is same product quantity, please make sure that all the products are recorded on the sales order (Example: You need to have 3 T-shirts on your sales order if the promotion is 'Buy 2, Get 1 Free'.")}
|
||||
else:
|
||||
if self not in applicable_programs and self.promo_applicability == 'on_current_order':
|
||||
message = {'error': _('At least one of the required conditions is not met to get the reward!')}
|
||||
return message
|
||||
|
||||
def _compute_program_amount(self, field, currency_to):
|
||||
self.ensure_one()
|
||||
return self.currency_id._convert(getattr(self, field), currency_to, self.company_id, fields.Date.today())
|
||||
|
||||
@api.model
|
||||
def _filter_on_mimimum_amount(self, order):
|
||||
untaxed_amount = order.amount_untaxed
|
||||
tax_amount = order.amount_tax
|
||||
|
||||
# Some lines should not be considered when checking if threshold is met like delivery
|
||||
untaxed_amount -= sum([line.price_subtotal for line in order._get_no_effect_on_threshold_lines()])
|
||||
tax_amount -= sum([line.price_tax for line in order._get_no_effect_on_threshold_lines()])
|
||||
|
||||
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 + tax_amount 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._is_valid_partner(order.partner_id))
|
||||
|
||||
def _filter_programs_on_products(self, order):
|
||||
"""
|
||||
To get valid programs according to product list.
|
||||
i.e Buy 1 imac + get 1 ipad mini free then check 1 imac is on cart or not
|
||||
or Buy 1 coke + get 1 coke free then check 2 cokes are on cart or not
|
||||
"""
|
||||
order_lines = order.order_line.filtered(lambda line: line.product_id) - order._get_reward_lines()
|
||||
products = order_lines.mapped('product_id')
|
||||
products_qties = dict.fromkeys(products, 0)
|
||||
for line in order_lines:
|
||||
products_qties[line.product_id] += line.product_uom_qty
|
||||
valid_programs = self.filtered(lambda program: not program.rule_products_domain)
|
||||
for program in self - valid_programs:
|
||||
valid_products = program._get_valid_products(products)
|
||||
ordered_rule_products_qty = sum(products_qties[product] for product in valid_products)
|
||||
# Avoid program if 1 ordered foo on a program '1 foo, 1 free foo'
|
||||
if program.promo_applicability == 'on_current_order' and \
|
||||
program._is_valid_product(program.reward_product_id) and program.reward_type == 'product':
|
||||
ordered_rule_products_qty -= program.reward_product_quantity
|
||||
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, next_order=False):
|
||||
""" Return the programs if every conditions is met
|
||||
:param bool next_order: is the reward given from a previous order
|
||||
"""
|
||||
programs = self
|
||||
# Minimum requirement should not be checked if the coupon got generated by a promotion program (the requirement should have only be checked to generate the coupon)
|
||||
if not next_order:
|
||||
programs = programs and programs._filter_on_mimimum_amount(order)
|
||||
programs = programs and programs._filter_on_validity_dates(order)
|
||||
programs = programs and programs._filter_unexpired_programs(order)
|
||||
programs = programs and programs._filter_programs_on_partners(order)
|
||||
# Product requirement should not be checked if the coupon got generated by a promotion program (the requirement should have only be checked to generate the coupon)
|
||||
if not next_order:
|
||||
programs = programs and programs._filter_programs_on_products(order)
|
||||
|
||||
programs_curr_order = programs.filtered(lambda p: p.promo_applicability == 'on_current_order')
|
||||
programs = programs.filtered(lambda p: p.promo_applicability == 'on_next_order')
|
||||
if programs_curr_order:
|
||||
# Checking if rewards are in the SO should not be performed for rewards on_next_order
|
||||
programs += programs_curr_order._filter_not_ordered_reward_programs(order)
|
||||
return programs
|
||||
|
||||
def _is_valid_partner(self, partner):
|
||||
if self.rule_partners_domain:
|
||||
domain = safe_eval(self.rule_partners_domain) + [('id', '=', partner.id)]
|
||||
return bool(self.env['res.partner'].search_count(domain))
|
||||
else:
|
||||
return True
|
||||
|
||||
def _is_valid_product(self, product):
|
||||
# NOTE: if you override this method, think of also overriding _get_valid_products
|
||||
if self.rule_products_domain:
|
||||
domain = safe_eval(self.rule_products_domain) + [('id', '=', product.id)]
|
||||
return bool(self.env['product.product'].search_count(domain))
|
||||
else:
|
||||
return True
|
||||
|
||||
def _get_valid_products(self, products):
|
||||
if self.rule_products_domain:
|
||||
domain = safe_eval(self.rule_products_domain) + [('id', 'in', products.ids)]
|
||||
return self.env['product.product'].search(domain)
|
||||
return products
|
||||
@@ -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 Type', 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=10,
|
||||
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,39 @@
|
||||
# -*- 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_products_domain = fields.Char(string="Based on Products", default=[['sale_ok', '=', True]], help="On Purchase of selected product, reward will be given")
|
||||
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 any(applicability for applicability in self
|
||||
if applicability.rule_date_to and applicability.rule_date_from
|
||||
and 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.constrains('rule_min_quantity')
|
||||
def _check_rule_min_quantity(self):
|
||||
if not self.rule_min_quantity > 0:
|
||||
raise ValidationError(_('Minimum quantity should be greater than 0'))
|
||||
@@ -0,0 +1,428 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools.misc import formatLang
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
applied_coupon_ids = fields.One2many('sale.coupon', 'sales_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", readonly=False)
|
||||
|
||||
@api.depends('order_line')
|
||||
def _compute_reward_total(self):
|
||||
for order in self:
|
||||
order.reward_amount = sum([line.price_subtotal for line in order._get_reward_lines()])
|
||||
|
||||
def _get_no_effect_on_threshold_lines(self):
|
||||
self.ensure_one()
|
||||
lines = self.env['sale.order.line']
|
||||
# Do not count already applied promo_code discount; Do not substract itself
|
||||
if self.code_promo_program_id and self.code_promo_program_id.reward_type == 'discount':
|
||||
lines = self.order_line.filtered(lambda l: l.product_id == self.code_promo_program_id.discount_line_product_id)
|
||||
return lines
|
||||
|
||||
@api.multi
|
||||
def recompute_coupon_lines(self):
|
||||
for order in self:
|
||||
order._remove_invalid_reward_lines()
|
||||
order._create_new_no_code_promo_reward_lines()
|
||||
order._update_existing_reward_lines()
|
||||
|
||||
@api.multi
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
order = super(SaleOrder, self).copy(default)
|
||||
order._get_reward_lines().unlink()
|
||||
order._create_new_no_code_promo_reward_lines()
|
||||
return order
|
||||
|
||||
def action_confirm(self):
|
||||
self.generated_coupon_ids.write({'state': 'new'})
|
||||
self.applied_coupon_ids.write({'state': 'used'})
|
||||
self._send_reward_coupon_mail()
|
||||
return super(SaleOrder, self).action_confirm()
|
||||
|
||||
def action_cancel(self):
|
||||
res = super(SaleOrder, self).action_cancel()
|
||||
self.generated_coupon_ids.write({'state': 'expired'})
|
||||
self.applied_coupon_ids.write({'state': 'new'})
|
||||
return res
|
||||
|
||||
def action_draft(self):
|
||||
res = super(SaleOrder, self).action_draft()
|
||||
self.generated_coupon_ids.write({'state': 'reserved'})
|
||||
return res
|
||||
|
||||
def _get_reward_lines(self):
|
||||
self.ensure_one()
|
||||
return self.order_line.filtered(lambda line: line.is_reward_line)
|
||||
|
||||
def _is_reward_in_order_lines(self, program):
|
||||
self.ensure_one()
|
||||
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: program._is_valid_product(x.product_id))
|
||||
max_product_qty = sum(order_lines.mapped('product_uom_qty')) or 1
|
||||
# Remove needed quantity from reward quantity if same reward and rule product
|
||||
if program._is_valid_product(program.reward_product_id):
|
||||
reward_product_qty = max_product_qty // (program.rule_min_quantity + program.reward_product_quantity)
|
||||
else:
|
||||
reward_product_qty = min(max_product_qty, self.order_line.filtered(lambda x: x.product_id == program.reward_product_id).product_uom_qty)
|
||||
|
||||
reward_qty = min(int(int(max_product_qty / program.rule_min_quantity) * program.reward_product_quantity), reward_product_qty)
|
||||
# Take the default taxes on the reward product, mapped with the fiscal position
|
||||
taxes = program.reward_product_id.taxes_id
|
||||
if self.fiscal_position_id:
|
||||
taxes = self.fiscal_position_id.map_tax(taxes)
|
||||
return {
|
||||
'product_id': program.discount_line_product_id.id,
|
||||
'price_unit': - price_unit,
|
||||
'product_uom_qty': reward_qty,
|
||||
'is_reward_line': True,
|
||||
'name': _("Free Product") + " - " + program.reward_product_id.name,
|
||||
'product_uom': program.reward_product_id.uom_id.id,
|
||||
'tax_id': [(4, tax.id, False) for tax in taxes],
|
||||
}
|
||||
|
||||
def _get_paid_order_lines(self):
|
||||
""" Returns the sale order lines that are not reward lines.
|
||||
It will also return reward lines being free product lines. """
|
||||
free_reward_product = self.env['sale.coupon.program'].search([('reward_type', '=', 'product')]).mapped('discount_line_product_id')
|
||||
return self.order_line.filtered(lambda x: not x.is_reward_line or x.product_id in free_reward_product)
|
||||
|
||||
def _get_reward_values_discount_fixed_amount(self, program):
|
||||
total_amount = sum(self._get_paid_order_lines().mapped('price_total'))
|
||||
fixed_amount = program._compute_program_amount('discount_fixed_amount', self.currency_id)
|
||||
if total_amount < fixed_amount:
|
||||
return total_amount
|
||||
else:
|
||||
return fixed_amount
|
||||
|
||||
def _get_cheapest_line(self):
|
||||
# Unit prices tax included
|
||||
return min(self.order_line.filtered(lambda x: not x.is_reward_line and x.price_unit > 0), key=lambda x: x['price_unit'])
|
||||
|
||||
def _get_reward_values_discount_percentage_per_line(self, program, line):
|
||||
discount_amount = line.product_uom_qty * line.price_unit * (program.discount_percentage / 100)
|
||||
return discount_amount
|
||||
|
||||
def _get_reward_values_discount(self, program):
|
||||
if program.discount_type == 'fixed_amount':
|
||||
return [{
|
||||
'name': _("Discount: ") + program.name,
|
||||
'product_id': program.discount_line_product_id.id,
|
||||
'price_unit': - self._get_reward_values_discount_fixed_amount(program),
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': program.discount_line_product_id.uom_id.id,
|
||||
'is_reward_line': True,
|
||||
'tax_id': [(4, tax.id, False) for tax in program.discount_line_product_id.taxes_id],
|
||||
}]
|
||||
reward_dict = {}
|
||||
lines = self._get_paid_order_lines()
|
||||
if program.discount_apply_on == 'cheapest_product':
|
||||
line = self._get_cheapest_line()
|
||||
if line:
|
||||
discount_line_amount = line.price_unit * (program.discount_percentage / 100)
|
||||
if discount_line_amount:
|
||||
taxes = line.tax_id
|
||||
if self.fiscal_position_id:
|
||||
taxes = self.fiscal_position_id.map_tax(taxes)
|
||||
|
||||
reward_dict[line.tax_id] = {
|
||||
'name': _("Discount: ") + program.name,
|
||||
'product_id': program.discount_line_product_id.id,
|
||||
'price_unit': - discount_line_amount,
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': program.discount_line_product_id.uom_id.id,
|
||||
'is_reward_line': True,
|
||||
'tax_id': [(4, tax.id, False) for tax in taxes],
|
||||
}
|
||||
elif program.discount_apply_on in ['specific_product', 'on_order']:
|
||||
if program.discount_apply_on == 'specific_product':
|
||||
# We should not exclude reward line that offer this product since we need to offer only the discount on the real paid product (regular product - free product)
|
||||
free_product_lines = self.env['sale.coupon.program'].search([('reward_type', '=', 'product'), ('reward_product_id', '=', program.discount_specific_product_id.id)]).mapped('discount_line_product_id')
|
||||
lines = lines.filtered(lambda x: x.product_id == program.discount_specific_product_id or x.product_id in free_product_lines)
|
||||
|
||||
for line in lines:
|
||||
discount_line_amount = self._get_reward_values_discount_percentage_per_line(program, line)
|
||||
|
||||
if discount_line_amount:
|
||||
|
||||
if line.tax_id in reward_dict:
|
||||
reward_dict[line.tax_id]['price_unit'] -= discount_line_amount
|
||||
else:
|
||||
taxes = line.tax_id
|
||||
if self.fiscal_position_id:
|
||||
taxes = self.fiscal_position_id.map_tax(taxes)
|
||||
|
||||
tax_name = ""
|
||||
if len(taxes) == 1:
|
||||
tax_name = " - " + _("On product with following tax: ") + ', '.join(taxes.mapped('name'))
|
||||
elif len(taxes) > 1:
|
||||
tax_name = " - " + _("On product with following taxes: ") + ', '.join(taxes.mapped('name'))
|
||||
|
||||
reward_dict[line.tax_id] = {
|
||||
'name': _("Discount: ") + program.name + tax_name,
|
||||
'product_id': program.discount_line_product_id.id,
|
||||
'price_unit': - discount_line_amount,
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': program.discount_line_product_id.uom_id.id,
|
||||
'is_reward_line': True,
|
||||
'tax_id': [(4, tax.id, False) for tax in taxes],
|
||||
}
|
||||
|
||||
# If there is a max amount for discount, we might have to limit some discount lines or completely remove some lines
|
||||
max_amount = program._compute_program_amount('discount_max_amount', self.currency_id)
|
||||
if max_amount > 0:
|
||||
amount_already_given = 0
|
||||
for val in list(reward_dict):
|
||||
amount_to_discount = amount_already_given + reward_dict[val]["price_unit"]
|
||||
if abs(amount_to_discount) > max_amount:
|
||||
reward_dict[val]["price_unit"] = - (max_amount - abs(amount_already_given))
|
||||
add_name = formatLang(self.env, max_amount, currency_obj=self.currency_id)
|
||||
reward_dict[val]["name"] += "( " + _("limited to ") + add_name + ")"
|
||||
amount_already_given += reward_dict[val]["price_unit"]
|
||||
if reward_dict[val]["price_unit"] == 0:
|
||||
del reward_dict[val]
|
||||
return reward_dict.values()
|
||||
|
||||
def _get_reward_line_values(self, program):
|
||||
self.ensure_one()
|
||||
if program.reward_type == 'discount':
|
||||
return self._get_reward_values_discount(program)
|
||||
elif program.reward_type == 'product':
|
||||
return [self._get_reward_values_product(program)]
|
||||
|
||||
def _create_reward_line(self, program):
|
||||
self.write({'order_line': [(0, False, value) for value in self._get_reward_line_values(program)]})
|
||||
|
||||
def _create_reward_coupon(self, program):
|
||||
# if there is already a coupon that was set as expired, reactivate that one instead of creating a new one
|
||||
coupon = self.env['sale.coupon'].search([
|
||||
('program_id', '=', program.id),
|
||||
('state', '=', 'expired'),
|
||||
('partner_id', '=', self.partner_id.id),
|
||||
('order_id', '=', self.id),
|
||||
('discount_line_product_id', '=', program.discount_line_product_id.id),
|
||||
], limit=1)
|
||||
if coupon:
|
||||
coupon.write({'state': 'reserved'})
|
||||
else:
|
||||
coupon = self.env['sale.coupon'].create({
|
||||
'program_id': program.id,
|
||||
'state': 'reserved',
|
||||
'partner_id': self.partner_id.id,
|
||||
'order_id': self.id,
|
||||
'discount_line_product_id': program.discount_line_product_id.id
|
||||
})
|
||||
self.generated_coupon_ids |= coupon
|
||||
return coupon
|
||||
|
||||
def _send_reward_coupon_mail(self):
|
||||
self.ensure_one()
|
||||
template = self.env.ref('sale_coupon.mail_template_sale_coupon', raise_if_not_found=False)
|
||||
if template:
|
||||
for coupon in self.generated_coupon_ids:
|
||||
self.message_post_with_template(
|
||||
template.id, composition_mode='comment',
|
||||
model='sale.coupon', res_id=coupon.id,
|
||||
notif_layout='mail.mail_notification_light',
|
||||
)
|
||||
|
||||
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([
|
||||
])._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([
|
||||
('promo_code_usage', '=', 'no_code_needed'),
|
||||
])._filter_programs_from_common_rules(self)
|
||||
return programs
|
||||
|
||||
def _get_applied_coupon_program_coming_from_another_so(self):
|
||||
# TODO: Remove me in master as no more used
|
||||
pass
|
||||
|
||||
def _get_valid_applied_coupon_program(self):
|
||||
self.ensure_one()
|
||||
# applied_coupon_ids's coupons might be coming from:
|
||||
# * a coupon generated from a previous order that benefited from a promotion_program that rewarded the next sale order.
|
||||
# In that case requirements to benefit from the program (Quantity and price) should not be checked anymore
|
||||
# * a coupon_program, in that case the promo_applicability is always for the current order and everything should be checked (filtered)
|
||||
programs = self.applied_coupon_ids.mapped('program_id').filtered(lambda p: p.promo_applicability == 'on_next_order')._filter_programs_from_common_rules(self, True)
|
||||
programs += self.applied_coupon_ids.mapped('program_id').filtered(lambda p: p.promo_applicability == 'on_current_order')._filter_programs_from_common_rules(self)
|
||||
return programs
|
||||
|
||||
def _create_new_no_code_promo_reward_lines(self):
|
||||
'''Apply new programs that are applicable'''
|
||||
self.ensure_one()
|
||||
order = self
|
||||
programs = order._get_applicable_no_code_promo_program()
|
||||
for program in programs:
|
||||
error_status = program._check_promo_code(order, False)
|
||||
if not error_status.get('error'):
|
||||
if program.promo_applicability == 'on_next_order':
|
||||
order._create_reward_coupon(program)
|
||||
elif program.discount_line_product_id.id not in self.order_line.mapped('product_id').ids:
|
||||
self.write({'order_line': [(0, False, value) for value in self._get_reward_line_values(program)]})
|
||||
order.no_code_promo_program_ids |= program
|
||||
|
||||
def _update_existing_reward_lines(self):
|
||||
'''Update values for already applied rewards'''
|
||||
def update_line(order, lines, values):
|
||||
'''Update the lines and return them if they should be deleted'''
|
||||
lines_to_remove = self.env['sale.order.line']
|
||||
# Check commit 6bb42904a03 for next if/else
|
||||
# Remove reward line if price or qty equal to 0
|
||||
if values['product_uom_qty'] and values['price_unit']:
|
||||
order.write({'order_line': [(1, line.id, values) for line in lines]})
|
||||
else:
|
||||
if program.reward_type != 'free_shipping':
|
||||
# Can't remove the lines directly as we might be in a recordset loop
|
||||
lines_to_remove += lines
|
||||
else:
|
||||
values.update(price_unit=0.0)
|
||||
order.write({'order_line': [(1, line.id, values) for line in lines]})
|
||||
return lines_to_remove
|
||||
|
||||
self.ensure_one()
|
||||
order = self
|
||||
applied_programs = order._get_applied_programs_with_rewards_on_current_order()
|
||||
for program in applied_programs:
|
||||
values = order._get_reward_line_values(program)
|
||||
lines = order.order_line.filtered(lambda line: line.product_id == program.discount_line_product_id)
|
||||
if program.reward_type == 'discount' and program.discount_type == 'percentage':
|
||||
lines_to_remove = lines
|
||||
# Values is what discount lines should really be, lines is what we got in the SO at the moment
|
||||
# 1. If values & lines match, we should update the line (or delete it if no qty or price?)
|
||||
# 2. If the value is not in the lines, we should add it
|
||||
# 3. if the lines contains a tax not in value, we should remove it
|
||||
for value in values:
|
||||
value_found = False
|
||||
for line in lines:
|
||||
# Case 1.
|
||||
if not len(set(line.tax_id.mapped('id')).symmetric_difference(set([v[1] for v in value['tax_id']]))):
|
||||
value_found = True
|
||||
# Working on Case 3.
|
||||
lines_to_remove -= line
|
||||
lines_to_remove += update_line(order, line, value)
|
||||
continue
|
||||
# Case 2.
|
||||
if not value_found:
|
||||
order.write({'order_line': [(0, False, value)]})
|
||||
# Case 3.
|
||||
lines_to_remove.unlink()
|
||||
else:
|
||||
update_line(order, lines, values[0]).unlink()
|
||||
|
||||
def _remove_invalid_reward_lines(self):
|
||||
""" Find programs & coupons that are not applicable anymore.
|
||||
It will then unlink the related reward order lines.
|
||||
It will also unset the order's fields that are storing
|
||||
the applied coupons & programs.
|
||||
Note: It will also remove a reward line coming from an archive program.
|
||||
"""
|
||||
self.ensure_one()
|
||||
order = self
|
||||
|
||||
applicable_programs = order._get_applicable_no_code_promo_program() + order._get_applicable_programs() + order._get_valid_applied_coupon_program()
|
||||
applied_programs = order._get_applied_programs_with_rewards_on_current_order() + order._get_applied_programs_with_rewards_on_next_order()
|
||||
programs_to_remove = applied_programs - applicable_programs
|
||||
products_to_remove = programs_to_remove.mapped('discount_line_product_id')
|
||||
|
||||
# delete reward line coming from an archived coupon (it will never be updated/removed when recomputing the order)
|
||||
invalid_lines = order.order_line.filtered(lambda line: line.is_reward_line and line.product_id.id not in (applied_programs).mapped('discount_line_product_id').ids)
|
||||
|
||||
# Invalid generated coupon for which we are not eligible anymore ('expired' since it is specific to this SO and we may again met the requirements)
|
||||
self.generated_coupon_ids.filtered(lambda coupon: coupon.program_id.discount_line_product_id.id in products_to_remove.ids).write({'state': 'expired'})
|
||||
# Reset applied coupons for which we are not eligible anymore ('valid' so it can be use on another )
|
||||
coupons_to_remove = order.applied_coupon_ids.filtered(lambda coupon: coupon.program_id in programs_to_remove)
|
||||
coupons_to_remove.write({'state': 'new'})
|
||||
|
||||
# Unbind promotion and coupon programs which requirements are not met anymore
|
||||
order.no_code_promo_program_ids -= programs_to_remove
|
||||
order.code_promo_program_id -= programs_to_remove
|
||||
order.applied_coupon_ids -= coupons_to_remove
|
||||
|
||||
# Remove their reward lines
|
||||
invalid_lines |= order.order_line.filtered(lambda line: line.product_id.id in products_to_remove.ids)
|
||||
invalid_lines.unlink()
|
||||
|
||||
def _get_applied_programs_with_rewards_on_current_order(self):
|
||||
# Need to add filter on current order. Indeed, it has always been calculating reward line even if on next order (which is useless and do calculation for nothing)
|
||||
# This problem could not be noticed since it would only update or delete existing lines related to that program, it would not find the line to update since not in the order
|
||||
# But now if we dont find the reward line in the order, we add it (since we can now have multiple line per program in case of discount on different vat), thus the bug
|
||||
# mentionned ahead will be seen now
|
||||
return self.no_code_promo_program_ids.filtered(lambda p: p.promo_applicability == 'on_current_order') + \
|
||||
self.applied_coupon_ids.mapped('program_id') + \
|
||||
self.code_promo_program_id.filtered(lambda p: p.promo_applicability == 'on_current_order')
|
||||
|
||||
def _get_applied_programs_with_rewards_on_next_order(self):
|
||||
return self.no_code_promo_program_ids.filtered(lambda p: p.promo_applicability == 'on_next_order') + \
|
||||
self.code_promo_program_id.filtered(lambda p: p.promo_applicability == 'on_next_order')
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
is_reward_line = fields.Boolean('Is a program reward line')
|
||||
|
||||
def unlink(self):
|
||||
related_program_lines = self.env['sale.order.line']
|
||||
# Reactivate coupons related to unlinked reward line
|
||||
for line in self.filtered(lambda line: line.is_reward_line):
|
||||
coupons_to_reactivate = line.order_id.applied_coupon_ids.filtered(
|
||||
lambda coupon: coupon.program_id.discount_line_product_id == line.product_id
|
||||
)
|
||||
coupons_to_reactivate.write({'state': 'new'})
|
||||
line.order_id.applied_coupon_ids -= coupons_to_reactivate
|
||||
# Remove the program from the order if the deleted line is the reward line of the program
|
||||
# And delete the other lines from this program (It's the case when discount is split per different taxes)
|
||||
related_program = self.env['sale.coupon.program'].search([('discount_line_product_id', '=', line.product_id.id)])
|
||||
if related_program:
|
||||
line.order_id.no_code_promo_program_ids -= related_program
|
||||
line.order_id.code_promo_program_id -= related_program
|
||||
related_program_lines |= line.order_id.order_line.filtered(lambda l: l.product_id.id == related_program.discount_line_product_id.id) - line
|
||||
return super(SaleOrderLine, self | related_program_lines).unlink()
|
||||
|
||||
@api.multi
|
||||
def _compute_tax_id(self):
|
||||
reward_lines = self.filtered('is_reward_line')
|
||||
super(SaleOrderLine, self - reward_lines)._compute_tax_id()
|
||||
# Discount reward line is split per tax, the discount is set on the line but not on the product
|
||||
# as the product is the generic discount line.
|
||||
# In case of a free product, retrieving the tax on the line instead of the product won't affect the behavior.
|
||||
for line in reward_lines:
|
||||
fpos = line.order_id.fiscal_position_id or line.order_id.partner_id.property_account_position_id
|
||||
# If company_id is set, always filter taxes by the company
|
||||
taxes = line.tax_id.filtered(lambda r: not line.company_id or r.company_id == line.company_id)
|
||||
line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id) if fpos else taxes
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import sale_coupon_report
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class CouponReport(models.AbstractModel):
|
||||
_name = 'report.sale_coupon.report_coupon'
|
||||
_description = 'Sales Coupon Report'
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
docs = self.env['sale.coupon'].browse(docids)
|
||||
return {
|
||||
'doc_ids': docs.ids,
|
||||
'doc_model': 'sale.coupon',
|
||||
'data': data,
|
||||
'docs': docs,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<report
|
||||
id="report_coupon_code"
|
||||
model="sale.coupon"
|
||||
name="sale_coupon.report_coupon"
|
||||
file="sale_coupon.report_coupon"
|
||||
report_type="qweb-pdf"
|
||||
string="Coupon Code"/>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="report_coupon">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="o">
|
||||
<t t-call="web.internal_layout">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="page">
|
||||
<div class="row text-center">
|
||||
<div class="o_offer col-lg-12">
|
||||
<h4 t-if="o.partner_id.name">
|
||||
Congratulations
|
||||
<t t-esc="o.partner_id.name"/>,
|
||||
</h4>
|
||||
<t t-set="text" t-value="'OFF ON YOUR NEXT ORDER !'"/>
|
||||
<t t-if="o.program_id.reward_type == 'discount'">
|
||||
<h1 t-if="o.program_id.discount_type == 'fixed_amount'" class="text-success">
|
||||
<strong><span t-field="o.program_id.discount_fixed_amount" t-options='{"widget": "monetary", "display_currency": o.program_id.currency_id}'/></strong>
|
||||
</h1>
|
||||
<h1 t-if="o.program_id.discount_type == 'percentage'" class="text-success">
|
||||
<strong><span t-field="o.program_id.discount_percentage"/>%</strong>
|
||||
</h1>
|
||||
<t t-if="o.program_id.discount_apply_on == 'specific_product'">
|
||||
<t t-set="text" t-value="'OFF ON %s' % (o.program_id.discount_specific_product_id.name.upper())"/>
|
||||
</t>
|
||||
<t t-if="o.program_id.discount_apply_on == 'cheapest_product'">
|
||||
<t t-set="text" t-value="'%s' % ('OFF ON THE CHEAPEST PRODUCT')"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="o.program_id.reward_type == 'product'">
|
||||
<t t-set="text" t-value="'GET %s FREE %s ON YOUR NEXT ORDER !' % (o.program_id.reward_product_quantity, o.program_id.reward_product_id.name.upper())"/>
|
||||
</t>
|
||||
<t t-if="o.program_id.reward_type == 'free_shipping'">
|
||||
<t t-set="text" t-value="'GET FREE SHIPPING ON YOUR NEXT ORDER !'"/>
|
||||
</t>
|
||||
<h3 t-esc="text"/>
|
||||
<h3 t-if="o.expiration_date">
|
||||
Use this promo code before
|
||||
<strong t-field="o.expiration_date" t-options='{"format": "d MMMM y"}'/>
|
||||
</h3>
|
||||
<h3>
|
||||
<em>CODE :</em>
|
||||
<strong class="bg-success" t-esc="o.code"></strong>
|
||||
</h3>
|
||||
<h4 t-if="o.program_id.rule_min_quantity > 1">
|
||||
Minimum purchase of
|
||||
<strong t-esc="o.program_id.rule_min_quantity"/> products
|
||||
</h4>
|
||||
<h4 t-if="o.program_id.rule_minimum_amount">
|
||||
Valid for purchase above
|
||||
<strong t-esc="o.program_id.rule_minimum_amount" t-options="{'widget': 'monetary', 'display_currency': o.program_id.currency_id}"/>
|
||||
</h4>
|
||||
<br/>
|
||||
<img alt="Barcode" t-att-src="'/report/barcode/Code128/%s' % o.code"/>
|
||||
<br/>
|
||||
<div class="mt64">
|
||||
<div class="col-6 text-right">
|
||||
<img alt="Logo" t-att-src="'/logo?company=%d' % (o.program_id.company_id)" t-att-alt="'%s' % (o.program_id.company_id.name)" style="border:0px solid transparent; height: 50; width: 200px;" height="50"/>
|
||||
</div>
|
||||
<div class="col-6 text-left">
|
||||
<span t-field="o.program_id.company_id.partner_id"
|
||||
t-options='{"widget": "contact", "fields": ["address", "email"], "no_marker": True}'/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -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.
|
||||
|
||||
from . import test_program_without_code_operations
|
||||
from . import test_program_with_code_operations
|
||||
from . import test_program_rules
|
||||
from . import test_program_numbers
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- 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()
|
||||
|
||||
# Set all the existing programs to active=False to avoid interference
|
||||
self.env['sale.coupon.program'].search([]).write({'active': False})
|
||||
|
||||
# create partner for sale order.
|
||||
self.steve = self.env['res.partner'].create({
|
||||
'name': 'Steve Bucknor',
|
||||
'customer': True,
|
||||
'email': 'steve.bucknor@example.com',
|
||||
})
|
||||
|
||||
self.empty_order = self.env['sale.order'].create({
|
||||
'partner_id': self.steve.id
|
||||
})
|
||||
|
||||
self.uom_unit = self.env.ref('uom.product_uom_unit')
|
||||
|
||||
# Taxes
|
||||
self.tax_15pc_excl = self.env['account.tax'].create({
|
||||
'name': "Tax 15%",
|
||||
'amount_type': 'percent',
|
||||
'amount': 15,
|
||||
'type_tax_use': 'sale',
|
||||
})
|
||||
|
||||
self.tax_10pc_incl = self.env['account.tax'].create({
|
||||
'name': "10% Tax incl",
|
||||
'amount_type': 'percent',
|
||||
'amount': 10,
|
||||
'price_include': True,
|
||||
})
|
||||
|
||||
#products
|
||||
self.product_A = self.env['product.product'].create({
|
||||
'name': 'Product A',
|
||||
'list_price': 100,
|
||||
'sale_ok': True,
|
||||
'taxes_id': [(6, 0, [self.tax_15pc_excl.id])],
|
||||
})
|
||||
|
||||
self.product_B = self.env['product.product'].create({
|
||||
'name': 'Product B',
|
||||
'list_price': 5,
|
||||
'sale_ok': True,
|
||||
'taxes_id': [(6, 0, [self.tax_15pc_excl.id])],
|
||||
})
|
||||
|
||||
# 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,535 @@
|
||||
# -*- 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
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSaleCouponProgramNumbers(TestSaleCouponCommon):
|
||||
|
||||
def setUp(self):
|
||||
super(TestSaleCouponProgramNumbers, self).setUp()
|
||||
|
||||
self.iPadMini = self.env.ref('product.product_product_6')
|
||||
self.iPod = self.env.ref('product.product_product_11')
|
||||
self.wirelessKeyboard = self.env.ref('product.product_product_9')
|
||||
self.computerCase = self.env.ref('product.product_product_16')
|
||||
self.littleServer = self.env.ref('product.consu_delivery_02')
|
||||
self.steve = self.env['res.partner'].create({
|
||||
'name': 'Steve Bucknor',
|
||||
'customer': True,
|
||||
'email': 'steve.bucknor@example.com',
|
||||
})
|
||||
self.empty_order = self.env['sale.order'].create({
|
||||
'partner_id': self.steve.id
|
||||
})
|
||||
|
||||
self.p1 = self.env['sale.coupon.program'].create({
|
||||
'name': 'Code for 10% on orders',
|
||||
'promo_code_usage': 'code_needed',
|
||||
'promo_code': 'test_10pc',
|
||||
'discount_type': 'percentage',
|
||||
'discount_percentage': 10.0,
|
||||
'program_type': 'promotion_program',
|
||||
})
|
||||
self.p2 = self.env['sale.coupon.program'].create({
|
||||
'name': 'Buy 3 cabinets, get one for free',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'reward_type': 'product',
|
||||
'program_type': 'promotion_program',
|
||||
'reward_product_id': self.iPadMini.id,
|
||||
'rule_min_quantity': 3,
|
||||
'rule_products_domain': '[["name","ilike","large cabinet"]]',
|
||||
})
|
||||
self.p3 = self.env['sale.coupon.program'].create({
|
||||
'name': 'Buy 1 drawer black, get a free Large Meeting Table',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'reward_type': 'product',
|
||||
'program_type': 'promotion_program',
|
||||
'reward_product_id': self.littleServer.id,
|
||||
'rule_products_domain': '[["name","ilike","drawer black"]]',
|
||||
})
|
||||
|
||||
def test_program_numbers_free_and_paid_product_qty(self):
|
||||
# These tests will focus on numbers (free product qty, SO total, reduction total..)
|
||||
order = self.empty_order
|
||||
sol1 = self.env['sale.order.line'].create({
|
||||
'product_id': self.iPadMini.id,
|
||||
'name': 'Large Cabinet',
|
||||
'product_uom_qty': 4.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
|
||||
# Check we correctly get a free product
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 2, "We should have 2 lines as we now have one 'Free Large Cabinet' line as we bought 4 of them")
|
||||
|
||||
# Check free product's price is not added to total when applying reduction (Or the discount will also be applied on the free product's price)
|
||||
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc')
|
||||
self.assertEqual(len(order.order_line.ids), 3, "We should 3 lines as we should have a new line for promo code reduction")
|
||||
self.assertEqual(order.amount_total, 864, "Only paid product should have their price discounted")
|
||||
order.order_line.filtered(lambda x: 'Discount' in x.name).unlink() # Remove Discount
|
||||
|
||||
# Check free product is removed since we are below minimum required quantity
|
||||
sol1.product_uom_qty = 3
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 1, "Free Large Cabinet should have been removed")
|
||||
|
||||
# Free product in cart will be considered as paid product when changing quantity of paid product, so the free product quantity computation will be wrong.
|
||||
# 100 iPad in cart, 25 free, set quantity to 10 ipad, you should have 2 free ipad but you get 8 because it add the 25 initial free ipad to the total paid ipad when computing (25+10 > 35 > /4 = 8 free ipad)
|
||||
sol1.product_uom_qty = 100
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 25, "We should have 25 Free Large Cabinet")
|
||||
sol1.product_uom_qty = 10
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 2, "We should have 2 Free Large Cabinet")
|
||||
|
||||
def test_program_numbers_check_eligibility(self):
|
||||
# These tests will focus on numbers (free product qty, SO total, reduction total..)
|
||||
|
||||
# Check if we have enough paid product to receive free product in case of a free product that is different from the paid product required
|
||||
# Buy A, get free b. (remember we need a paid B in cart to receive free b). If your cart is 4A 1B then you should receive 1b (you are eligible to receive 4 because you have 4A but since you dont have enought B in your cart, you are limited to the B quantity)
|
||||
order = self.empty_order
|
||||
sol1 = self.env['sale.order.line'].create({
|
||||
'product_id': self.computerCase.id,
|
||||
'name': 'drawer black',
|
||||
'product_uom_qty': 4.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
sol2 = self.env['sale.order.line'].create({
|
||||
'product_id': self.littleServer.id,
|
||||
'name': 'Large Meeting Table',
|
||||
'product_uom_qty': 1.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 3, "We should have a 'Free Large Meeting Table' promotion line")
|
||||
self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).product_uom_qty, 1, "We should receive one and only one free Large Meeting Table")
|
||||
|
||||
# Check the required value amount to be eligible for the program is correctly computed (eg: it does not add negative value (from free product) to total)
|
||||
# A = free b | Have your cart with A 2B b | cart value should be A + 1B but in code it is only A (free b value is subsstract 2 times)
|
||||
# This is because _amount_all() is summing all SO lines (so + (-b.value)) and again in _check_promo_code() order.amount_untaxed + order.reward_amount | amount_untaxed has already free product value substracted (_amount_all)
|
||||
sol1.product_uom_qty = 1
|
||||
sol2.product_uom_qty = 2
|
||||
self.p1.rule_minimum_amount = 5000
|
||||
order.recompute_coupon_lines()
|
||||
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc')
|
||||
self.assertEqual(len(order.order_line.ids), 4, "We should have 4 lines as we should have a new line for promo code reduction")
|
||||
|
||||
# Check you can still have auto applied promotion if you have a promo code set to the order
|
||||
self.env['sale.order.line'].create({
|
||||
'product_id': self.iPadMini.id,
|
||||
'name': 'Large Cabinet',
|
||||
'product_uom_qty': 4.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 6, "We should have 2 more lines as we now have one 'Free Large Cabinet' line since we bought 4 of them")
|
||||
|
||||
def test_program_numbers_taxes_and_rules(self):
|
||||
percent_tax = self.env['account.tax'].create({
|
||||
'name': "15% Tax",
|
||||
'amount_type': 'percent',
|
||||
'amount': 15,
|
||||
'price_include': True,
|
||||
})
|
||||
p_specific_product = self.env['sale.coupon.program'].create({
|
||||
'name': '20% reduction on Large Cabinet in cart',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'reward_type': 'discount',
|
||||
'program_type': 'promotion_program',
|
||||
'discount_type': 'percentage',
|
||||
'discount_percentage': 20.0,
|
||||
'rule_minimum_amount': 320.00,
|
||||
'discount_apply_on': 'specific_product',
|
||||
'discount_specific_product_id': self.iPadMini.id,
|
||||
})
|
||||
order = self.empty_order
|
||||
self.iPadMini.taxes_id = percent_tax
|
||||
sol1 = self.env['sale.order.line'].create({
|
||||
'product_id': self.iPadMini.id,
|
||||
'name': 'Large Cabinet',
|
||||
'product_uom_qty': 1.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 1, "We should not get the reduction line since we dont have 320$ tax excluded (cabinet is 320$ tax included)")
|
||||
sol1.tax_id.price_include = False
|
||||
sol1._compute_tax_id()
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (cabinet is 320$ tax included)")
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# iPod | 1 | 320.00 | 15% excl | 320.00 | 368.00 | 48.00
|
||||
# 20% discount on ipad | 1 | -64.00 | 15% excl | -64.00 | -73.60 | -9.60
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL | 256.00 | 294.40 | 38.40
|
||||
self.assertEqual(order.amount_total, 294.4, "Check discount has been applied correctly (eg: on taxes aswell)")
|
||||
|
||||
# test coupon with code works the same as auto applied_programs
|
||||
p_specific_product.write({'promo_code_usage': 'code_needed', 'promo_code': '20pc'})
|
||||
order.order_line.filtered(lambda l: l.is_reward_line).unlink()
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 1, "Reduction should be removed since we deleted it and it is now a promo code usage, it shouldn't be automatically reapplied")
|
||||
|
||||
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, '20pc')
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (cabinet is 320$ tax included)")
|
||||
|
||||
# check discount applied only on ipad
|
||||
self.env['sale.order.line'].create({
|
||||
'product_id': self.computerCase.id,
|
||||
'name': 'Computer Case',
|
||||
'product_uom_qty': 10.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
order.recompute_coupon_lines()
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# Computer Case | 10 | 25.00 | / | 250.00 | 250.00 | /
|
||||
# iPad | 1 | 320.00 | 15% excl | 320.00 | 368.00 | 48.00
|
||||
# 20% discount on ipad | 1 | -64.00 | 15% excl | -64.00 | -73.60 | -9.60
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL | 506.00 | 544.40 | 38.40
|
||||
self.assertEqual(order.amount_total, 544.4, "We should only get reduction on cabinet")
|
||||
sol1.product_uom_qty = 10
|
||||
order.recompute_coupon_lines()
|
||||
# Note: Since we now have 2 free ipads, we should discount only 8 of the 10 ipads in carts since we don't want to discount free ipads
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# Computer Case | 10 | 25.00 | / | 250.00 | 250.00 | /
|
||||
# iPad | 10 | 320.00 | 15% excl | 3200.00 | 3680.00 | 480.00
|
||||
# Free iPad | 2 | -320.00 | 15% excl | -640.00 | -736.00 | -96.00
|
||||
# 20% discount on ipad | 1 | -512.00 | 15% excl | -512.00 | -588.80 | -78.80
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL | 2298.00 | 2605.20 | 305.20
|
||||
self.assertEqual(order.amount_total, 2605.20, "Changing cabinet quantity should change discount amount correctly")
|
||||
|
||||
p_specific_product.discount_max_amount = 200
|
||||
order.recompute_coupon_lines()
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# Computer Case | 10 | 25.00 | / | 250.00 | 250.00 | /
|
||||
# iPad | 10 | 320.00 | 15% excl | 3200.00 | 3680.00 | 480.00
|
||||
# Free iPad | 2 | -320.00 | 15% excl | -640.00 | -736.00 | -96.00
|
||||
# 20% discount on ipad | 1 | -200.00 | 15% excl | -200.00 | -230.00 | -30.00
|
||||
# limited to 200 HTVA
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL | 2610.00 | 2964.00 | 354.00
|
||||
self.assertEqual(order.amount_total, 2964, "The discount should be limited to $200 tax excluded")
|
||||
self.assertEqual(order.amount_untaxed, 2610, "The discount should be limited to $200 tax excluded (2)")
|
||||
|
||||
def test_program_numbers_one_discount_line_per_tax(self):
|
||||
order = self.empty_order
|
||||
# Create taxes
|
||||
self.tax_15pc_excl = self.env['account.tax'].create({
|
||||
'name': "15% Tax excl",
|
||||
'amount_type': 'percent',
|
||||
'amount': 15,
|
||||
})
|
||||
self.tax_50pc_excl = self.env['account.tax'].create({
|
||||
'name': "50% Tax excl",
|
||||
'amount_type': 'percent',
|
||||
'amount': 50,
|
||||
})
|
||||
self.tax_35pc_incl = self.env['account.tax'].create({
|
||||
'name': "35% Tax incl",
|
||||
'amount_type': 'percent',
|
||||
'amount': 35,
|
||||
'price_include': True,
|
||||
})
|
||||
|
||||
# Set tax and prices on products as neeed for the test
|
||||
(self.product_A + self.iPadMini + self.iPod + self.wirelessKeyboard + self.computerCase).write({'list_price': 100})
|
||||
(self.iPadMini + self.computerCase).write({'taxes_id': [(4, self.tax_15pc_excl.id, False)]})
|
||||
self.iPod.taxes_id = self.tax_10pc_incl
|
||||
self.wirelessKeyboard.taxes_id = None
|
||||
self.product_A.taxes_id = (self.tax_35pc_incl + self.tax_50pc_excl)
|
||||
|
||||
# Add products in order
|
||||
self.env['sale.order.line'].create({
|
||||
'product_id': self.iPadMini.id,
|
||||
'name': 'iPad',
|
||||
'product_uom_qty': 7.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
sol2 = self.env['sale.order.line'].create({
|
||||
'product_id': self.iPod.id,
|
||||
'name': 'iPod',
|
||||
'product_uom_qty': 5.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
self.env['sale.order.line'].create({
|
||||
'product_id': self.wirelessKeyboard.id,
|
||||
'name': 'Wireless Keyboard',
|
||||
'product_uom_qty': 10.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
self.env['sale.order.line'].create({
|
||||
'product_id': self.product_A.id,
|
||||
'name': 'product A with multiple taxes',
|
||||
'product_uom_qty': 3.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
self.env['sale.order.line'].create({
|
||||
'product_id': self.computerCase.id,
|
||||
'name': 'Computer Case',
|
||||
'product_uom_qty': 2.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
|
||||
# Create needed programs
|
||||
self.p2.active = False
|
||||
self.p_ipad = self.env['sale.coupon.program'].create({
|
||||
'name': 'Buy 1 large cabinet, get one for free',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'reward_type': 'product',
|
||||
'program_type': 'promotion_program',
|
||||
'reward_product_id': self.iPadMini.id,
|
||||
'rule_products_domain': '[["name","ilike","large cabinet"]]',
|
||||
})
|
||||
self.p_ipod = self.env['sale.coupon.program'].create({
|
||||
'name': 'Buy 1 chair, get one for free',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'reward_type': 'product',
|
||||
'program_type': 'promotion_program',
|
||||
'reward_product_id': self.iPod.id,
|
||||
'rule_products_domain': '[["name","ilike","conference chair"]]',
|
||||
})
|
||||
self.p_keyboard = self.env['sale.coupon.program'].create({
|
||||
'name': 'Buy 1 bin, get one for free',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'reward_type': 'product',
|
||||
'program_type': 'promotion_program',
|
||||
'reward_product_id': self.wirelessKeyboard.id,
|
||||
'rule_products_domain': '[["name","ilike","pedal bin"]]',
|
||||
})
|
||||
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# iPod | 5 | 100.00 | 10% incl | 454.55 | 500.00 | 45.45
|
||||
# Keyboards | 10 | 100.00 | / | 1000.00 | 1000.00 | /
|
||||
# iPad | 7 | 100.00 | 15% excl | 700.00 | 805.00 | 105.00
|
||||
# Computer Case | 2 | 100.00 | 15% excl | 200.00 | 230.00 | 30.00
|
||||
# Product A | 3 | 100.00 | 35% incl | 222.22 | 450.00 | 227.78
|
||||
# 50% excl
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL | 2576.77 | 2985.00 | 408.23
|
||||
|
||||
self.assertEqual(order.amount_total, 2985, "The order total without any programs should be 2985")
|
||||
self.assertEqual(order.amount_untaxed, 2576.77, "The order untaxed total without any programs should be 2576.77")
|
||||
self.assertEqual(len(order.order_line.ids), 5, "The order without any programs should have 5 lines")
|
||||
|
||||
# Apply all the programs
|
||||
order.recompute_coupon_lines()
|
||||
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# Free iPod | 2 | -100.00 | 10% incl | -181.82 | -200.00 | -18.18
|
||||
# Free Keyboard | 5 | -100.00 | / | -500.00 | -500.00 | /
|
||||
# Free iPad | 3 | -100.00 | 15% excl | -300.00 | -345.00 | -45.00
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL AFTER APPLYING FREE PRODUCT PROGRAMS | 1594.95 | 1940.00 | 345.05
|
||||
|
||||
self.assertEqual(order.amount_total, 1940.00, "The order total with programs should be 1940")
|
||||
self.assertEqual(order.amount_untaxed, 1594.95, "The order untaxed total with programs should be 1594.95")
|
||||
self.assertEqual(len(order.order_line.ids), 8, "Order should contains 5 regular product lines and 3 free product lines")
|
||||
|
||||
# Apply 10% on top of everything
|
||||
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc')
|
||||
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# 10% on tax 10% incl | 1 | -30.00 | 10% incl | -27.27 | -30.00 | -2.73
|
||||
# 10% on no tax | 1 | -50.00 | / | -50.00 | -50.00 | /
|
||||
# 10% on tax 15% excl | 1 | -40.00 | 15% excl | -60.00 | -69.00 | -9.00
|
||||
# 10% on tax 35%+50% | 1 | -30.00 | 35% incl | -22.22 | -45.00 | -22.78
|
||||
# 50% excl
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL AFTER APPLYING 10% GLOBAL PROGRAM | 1435.46 | 1746.00 | -310.55
|
||||
|
||||
self.assertEqual(order.amount_total, 1746, "The order total with programs should be 1746")
|
||||
self.assertEqual(order.amount_untaxed, 1435.46, "The order untaxed total with programs should be 1435.46")
|
||||
self.assertEqual(len(order.order_line.ids), 12, "Order should contains 5 regular product lines, 3 free product lines and 4 discount lines (one for every tax)")
|
||||
|
||||
# -- This is a test inside the test
|
||||
order.order_line._compute_tax_id()
|
||||
self.assertEqual(order.amount_total, 1746, "Recomputing tax on sale order lines should not change total amount")
|
||||
self.assertEqual(order.amount_untaxed, 1435.46, "Recomputing tax on sale order lines should not change untaxed amount")
|
||||
self.assertEqual(len(order.order_line.ids), 12, "Recomputing tax on sale order lines should not change number of order line")
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(order.amount_total, 1746, "Recomputing tax on sale order lines should not change total amount")
|
||||
self.assertEqual(order.amount_untaxed, 1435.46, "Recomputing tax on sale order lines should not change untaxed amount")
|
||||
self.assertEqual(len(order.order_line.ids), 12, "Recomputing tax on sale order lines should not change number of order line")
|
||||
# -- End test inside the test
|
||||
|
||||
# Now we want to apply a 20% discount only on iPad
|
||||
self.env['sale.coupon.program'].create({
|
||||
'name': '20% reduction on ipad in cart',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'reward_type': 'discount',
|
||||
'program_type': 'promotion_program',
|
||||
'discount_type': 'percentage',
|
||||
'discount_percentage': 20.0,
|
||||
'discount_apply_on': 'specific_product',
|
||||
'discount_specific_product_id': self.iPadMini.id,
|
||||
})
|
||||
order.recompute_coupon_lines()
|
||||
# Note: we have 7 regular ipads and 3 free ipads. We should then discount only 4 really paid iPads
|
||||
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# 20% on iPad | 1 | -80.00 | 15% excl | -80.00 | -92.00 | -12.00
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL AFTER APPLYING 20% ON IPAD | 1355.45 | 1654.00 | -298.55
|
||||
|
||||
self.assertEqual(order.amount_total, 1654, "The order total with programs should be 1654")
|
||||
self.assertEqual(order.amount_untaxed, 1355.46, "The order untaxed total with programs should be 1435.45")
|
||||
self.assertEqual(len(order.order_line.ids), 13, "Order should have a new discount line for 20% on iPad")
|
||||
|
||||
# Check that if you delete one of the discount tax line, the others tax lines from the same promotion got deleted as well.
|
||||
order.order_line.filtered(lambda l: '10%' in l.name)[0].unlink()
|
||||
self.assertEqual(len(order.order_line.ids), 9, "All of the 10% discount line per tax should be removed")
|
||||
# At this point, removing the iPod's discount line (split per tax) removed also the others discount lines
|
||||
# linked to the same program (eg: other taxes lines). So the coupon got removed from the SO since there were no discount lines left
|
||||
|
||||
# Add back the coupon to continue the test flow
|
||||
self.env['sale.coupon.apply.code'].sudo().apply_coupon(order, 'test_10pc')
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 13, "The 10% discount line should be back")
|
||||
|
||||
# Check that if you change a product qty, his discount tax line got updated
|
||||
sol2.product_uom_qty = 7
|
||||
order.recompute_coupon_lines()
|
||||
# iPod | 5 | 100.00 | 10% incl | 454.55 | 500.00 | 45.45
|
||||
# Free iPod | 2 | -100.00 | 10% incl | -181.82 | -200.00 | -18.18
|
||||
# 10% on tax 10% incl | 1 | -30.00 | 10% incl | -27.27 | -30.00 | -2.73
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL OF IPOD LINES | 245.46 | 270.00 | 24.54
|
||||
# ==> Should become:
|
||||
# iPod | 7 | 100.00 | 10% incl | 636.36 | 700.00 | 63.64
|
||||
# Free iPod | 3 | -100.00 | 10% incl | -272.73 | -300.00 | -27.27
|
||||
# 10% on tax 10% incl | 1 | -40.00 | 10% incl | -36.36 | -40.00 | -3.64
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL OF IPOD LINES AFTER ADDING 2 IPODS | 327.27 | 360.00 | 32.73
|
||||
# --------------------------------------------------------------------------------
|
||||
# => DIFFERENCES BEFORE/AFTER | 81.81 | 90.00 | 8.19
|
||||
self.assertEqual(order.amount_untaxed, 1355.46 + 81.81, "The order should have one more paid ipod with 10% incl tax and discounted by 10%")
|
||||
|
||||
# Check that if you remove a product, his reward lines got removed, especially the discount per tax one
|
||||
sol2.unlink()
|
||||
order.recompute_coupon_lines()
|
||||
# Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
|
||||
# --------------------------------------------------------------------------------
|
||||
# Keyboards | 10 | 100.00 | / | 1000.00 | 1000.00 | /
|
||||
# iPad | 7 | 100.00 | 15% excl | 700.00 | 805.00 | 105.00
|
||||
# Computer Case | 2 | 100.00 | 15% excl | 200.00 | 230.00 | 30.00
|
||||
# Product A | 3 | 100.00 | 35% incl | 222.22 | 450.00 | 227.78
|
||||
# 50% excl
|
||||
# Free Keyboard | 5 | -100.00 | / | -500.00 | -500.00 | /
|
||||
# Free iPad | 3 | -100.00 | 15% excl | -300.00 | -345.00 | -45.00
|
||||
# 20% on iPad | 1 | -80.00 | 15% excl | -80.00 | -92.00 | -12.00
|
||||
# --------------------------------------------------------------------------------
|
||||
# TOTAL | 1242.22 | 1548.00 | 305.78
|
||||
self.assertEqual(order.amount_total, 1548, "The order total with programs should be 1548")
|
||||
self.assertEqual(order.amount_untaxed, 1242.22, "The order untaxed total with programs should be 1242.22")
|
||||
self.assertEqual(len(order.order_line.ids), 7, "Order should contains 7 lines: 4 products lines, 2 free products lines and a 20% discount line")
|
||||
|
||||
def test_program_numbers_extras(self):
|
||||
# Check that you can't apply a global discount promo code if there is already an auto applied global discount
|
||||
self.p1.copy({'promo_code_usage': 'no_code_needed', 'name': 'Auto applied 10% global discount'})
|
||||
order = self.empty_order
|
||||
self.env['sale.order.line'].create({
|
||||
'product_id': self.iPadMini.id,
|
||||
'name': 'iPad',
|
||||
'product_uom_qty': 1.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 2, "We should get 1 iPad line and 1 10% auto applied global discount line")
|
||||
self.assertEqual(order.amount_total, 288, "320$ - 10%")
|
||||
with self.assertRaises(UserError):
|
||||
# Can't apply a second global discount
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
|
||||
'coupon_code': 'test_10pc'
|
||||
}).process_coupon()
|
||||
|
||||
def test_program_fixed_price(self):
|
||||
# Check fixed amount discount
|
||||
order = self.empty_order
|
||||
fixed_amount_program = self.env['sale.coupon.program'].create({
|
||||
'name': '$249 discount',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'program_type': 'promotion_program',
|
||||
'discount_type': 'fixed_amount',
|
||||
'discount_fixed_amount': 249.0,
|
||||
})
|
||||
self.tax_0pc_excl = self.env['account.tax'].create({
|
||||
'name': "0% Tax excl",
|
||||
'amount_type': 'percent',
|
||||
'amount': 0,
|
||||
})
|
||||
fixed_amount_program.discount_line_product_id.write({'taxes_id': [(4, self.tax_0pc_excl.id, False)]})
|
||||
sol1 = self.env['sale.order.line'].create({
|
||||
'product_id': self.computerCase.id,
|
||||
'name': 'Computer Case',
|
||||
'product_uom_qty': 1.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(order.amount_total, 0, "Total should be null. The fixed amount discount is higher than the SO total, it should be reduced to the SO total")
|
||||
self.assertEqual(len(order.order_line.ids), 2, "There should be the product line and the reward line")
|
||||
sol1.product_uom_qty = 17
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(order.amount_total, 176, "Fixed amount discount should be totally deduced")
|
||||
self.assertEqual(len(order.order_line.ids), 2, "Number of lines should be unchanged as we just recompute the reward line")
|
||||
sol2 = order.order_line.filtered(lambda l: l.id != sol1.id)
|
||||
self.assertEqual(len(sol2.tax_id.ids), 1, "One tax should be present on the reward line")
|
||||
self.assertEqual(sol2.tax_id.id, self.tax_0pc_excl.id, "The tax should be 0% Tax excl")
|
||||
fixed_amount_program.write({'active': False}) # Check archived product will remove discount lines on recompute
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 1, "Archiving the program should remove the program reward line")
|
||||
|
||||
def test_program_next_order(self):
|
||||
order = self.empty_order
|
||||
self.env['sale.coupon.program'].create({
|
||||
'name': 'Free Keyboard if at least 1 article',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'promo_applicability': 'on_next_order',
|
||||
'program_type': 'promotion_program',
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': self.wirelessKeyboard.id,
|
||||
'rule_min_quantity': 2,
|
||||
})
|
||||
sol1 = self.env['sale.order.line'].create({
|
||||
'product_id': self.iPadMini.id,
|
||||
'name': 'iPad Mini',
|
||||
'product_uom_qty': 1.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart")
|
||||
self.assertEqual(len(order.generated_coupon_ids), 0, "No coupon should have been generated yet")
|
||||
|
||||
sol1.product_uom_qty = 2
|
||||
order.recompute_coupon_lines()
|
||||
generated_coupon = order.generated_coupon_ids
|
||||
self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart (2)")
|
||||
self.assertEqual(len(generated_coupon), 1, "A coupon should have been generated")
|
||||
self.assertEqual(generated_coupon.state, 'reserved', "The coupon should be reserved")
|
||||
|
||||
sol1.product_uom_qty = 1
|
||||
order.recompute_coupon_lines()
|
||||
generated_coupon = order.generated_coupon_ids
|
||||
self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart (3)")
|
||||
self.assertEqual(len(generated_coupon), 1, "No more coupon should have been generated and the existing one should not have been deleted")
|
||||
self.assertEqual(generated_coupon.state, 'expired', "The coupon should have been set as expired as it is no more valid since we don't have the required quantity")
|
||||
|
||||
sol1.product_uom_qty = 2
|
||||
order.recompute_coupon_lines()
|
||||
generated_coupon = order.generated_coupon_ids
|
||||
self.assertEqual(len(generated_coupon), 1, "We should still have only 1 coupon as we now benefit again from the program but no need to create a new one (see next assert)")
|
||||
self.assertEqual(generated_coupon.state, 'reserved', "The coupon should be set back to reserved as we had already an expired one, no need to create a new one")
|
||||
@@ -0,0 +1,242 @@
|
||||
# -*- 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.exceptions import UserError
|
||||
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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the partner is correct, the discount is not created")
|
||||
|
||||
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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
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': 1006,
|
||||
'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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
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.steve.id})
|
||||
order.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_A.id,
|
||||
'name': '10 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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
# 10*100 + 5 = 1005
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The promo offert should not be applied as the purchased amount is not enough")
|
||||
|
||||
self.immediate_promotion_program.rule_minimum_amount = 1005
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should be applied as the purchased amount is now enough")
|
||||
|
||||
# 10*(100*1.15) + (5*1.15) = 10*115 + 5.75 = 1155.75
|
||||
self.immediate_promotion_program.rule_minimum_amount = 1006
|
||||
self.immediate_promotion_program.rule_minimum_amount_tax_inclusion = 'tax_included'
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should be applied as the initial amount required is now tax included")
|
||||
|
||||
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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
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.steve.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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied as the number of uses is exceeded")
|
||||
|
||||
def test_program_rules_coupon_qty_and_amount_remove_not_eligible(self):
|
||||
''' This test will:
|
||||
* Check quantity and amount requirements works as expected (since it's slightly different from a promotion_program)
|
||||
* Ensure that if a reward from a coupon_program was allowed and the conditions are not met anymore,
|
||||
the reward will be removed on recompute.
|
||||
'''
|
||||
self.immediate_promotion_program.active = False # Avoid having this program to add rewards on this test
|
||||
order = self.empty_order
|
||||
|
||||
program = self.env['sale.coupon.program'].create({
|
||||
'name': 'Get 10% discount if buy at least 4 Product A and $320',
|
||||
'program_type': 'coupon_program',
|
||||
'reward_type': 'discount',
|
||||
'discount_type': 'percentage',
|
||||
'discount_percentage': 10.0,
|
||||
'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id),
|
||||
'rule_min_quantity': 3,
|
||||
'rule_minimum_amount': 320.00,
|
||||
})
|
||||
|
||||
sol1 = self.env['sale.order.line'].create({
|
||||
'product_id': self.product_A.id,
|
||||
'name': 'Product A',
|
||||
'product_uom_qty': 2.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
|
||||
sol2 = self.env['sale.order.line'].create({
|
||||
'product_id': self.product_B.id,
|
||||
'name': 'Product B',
|
||||
'product_uom_qty': 4.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
|
||||
# Default value for coupon generate wizard is generate by quantity and generate only one coupon
|
||||
self.env['sale.coupon.generate'].with_context(active_id=program.id).create({}).generate_coupon()
|
||||
coupon = program.coupon_ids[0]
|
||||
|
||||
# Not enough amount since we only have 220 (100*2 + 5*4)
|
||||
with self.assertRaises(UserError):
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
|
||||
'coupon_code': coupon.code
|
||||
}).process_coupon()
|
||||
|
||||
sol2.product_uom_qty = 24
|
||||
|
||||
# Not enough qty since we only have 3 Product A (Amount is ok: 100*2 + 5*24 = 320)
|
||||
with self.assertRaises(UserError):
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
|
||||
'coupon_code': coupon.code
|
||||
}).process_coupon()
|
||||
|
||||
sol1.product_uom_qty = 3
|
||||
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
|
||||
'coupon_code': coupon.code
|
||||
}).process_coupon()
|
||||
order.recompute_coupon_lines()
|
||||
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The order should contains the Product A line, the Product B line and the discount line")
|
||||
self.assertEqual(coupon.state, 'used', "The coupon should be set to Consumed as it has been used")
|
||||
|
||||
sol1.product_uom_qty = 2
|
||||
order.recompute_coupon_lines()
|
||||
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The discount line should have been removed as we don't meet the program requirements")
|
||||
self.assertEqual(coupon.state, 'new', "The coupon should be reset to Valid as it's reward got removed")
|
||||
@@ -0,0 +1,211 @@
|
||||
# -*- 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_usability(self):
|
||||
# After clicking "Generate coupons", there is no domain so it shows "Match all records".
|
||||
# But when you click, domain is false (default field value; empty string) so it won't generate anything.
|
||||
# This is even more weird because if you add something in the domain and then delete it,
|
||||
# you visually come back to the initial state except the domain became '[]' instead of ''.
|
||||
# In this case, it will generate the coupon for every partner.
|
||||
# Thus, we should ensure that if you leave the domain untouched, it generates a coupon for each partner
|
||||
# as hinted on the screen ('Match all records (X records)')
|
||||
self.env['sale.coupon.generate'].with_context(active_id=self.code_promotion_program.id).create({
|
||||
'generation_type': 'nbr_customer',
|
||||
}).generate_coupon()
|
||||
self.assertEqual(len(self.code_promotion_program.coupon_ids), len(self.env['res.partner'].search([])), "It should have generated a coupon for every partner")
|
||||
|
||||
def test_program_basic_operation_coupon_code(self):
|
||||
# Test case: Generate a coupon for my customer, and add a reward then remove it automatically
|
||||
|
||||
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()
|
||||
coupon = self.code_promotion_program.coupon_ids
|
||||
|
||||
# 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': coupon.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': coupon.code
|
||||
}).process_coupon()
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 2)
|
||||
self.assertEqual(coupon.state, 'used')
|
||||
|
||||
# Remove the product A from the sale order
|
||||
order.write({'order_line': [(2, order.order_line[0].id, False)]})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(order.order_line.ids), 0)
|
||||
self.assertEqual(coupon.state, 'new')
|
||||
|
||||
def test_on_next_order_reward_promotion_program(self):
|
||||
# The flow:
|
||||
# 1. Create a program `A` that gives a free `Product B` on next order if you buy a an `product A`
|
||||
# This program should be code_needed with code `free_B_on_next_order`
|
||||
# 2. Create a program `B` that gives 10% discount on next order automatically
|
||||
# 3. Create a SO with a `third product` and recompute coupon, you SHOULD get a coupon (from program `B`) for your next order that will discount 10%
|
||||
# 4. Try to apply `A`, it should error since we did not buy any product A.
|
||||
# 5. Add a product A to the cart and try to apply `A` again, this time it should work
|
||||
# 6. Verify you have 2 generated coupons and validate the SO (so the 2 generated coupons will be valid)
|
||||
# 7. Create a new SO (with the same partner) and try to apply coupon generated by `A`. it SHOULD error since we don't have any `Product B` in the cart
|
||||
# 8. Add a Product B in the cart
|
||||
# 9. Try to apply once again coupon generated by `A`, it should give you the free product B
|
||||
# 10. Try to apply coupon generated by `B`, it should give you 10% discount.
|
||||
# => SO will then be 0$ until we recompute the order lines
|
||||
|
||||
# 1.
|
||||
self.immediate_promotion_program.write({
|
||||
'promo_applicability': 'on_next_order',
|
||||
'promo_code_usage': 'code_needed',
|
||||
'promo_code': 'free_B_on_next_order',
|
||||
})
|
||||
# 2.
|
||||
self.p1 = self.env['sale.coupon.program'].create({
|
||||
'name': 'Code for 10% on next order',
|
||||
'discount_type': 'percentage',
|
||||
'discount_percentage': 10.0,
|
||||
'program_type': 'promotion_program',
|
||||
'promo_code_usage': 'no_code_needed',
|
||||
'promo_applicability': 'on_next_order',
|
||||
})
|
||||
# 3.
|
||||
order = self.empty_order.copy()
|
||||
self.third_product = self.env['product.product'].create({
|
||||
'name': 'Thrid Product',
|
||||
'list_price': 5,
|
||||
'sale_ok': True
|
||||
})
|
||||
order.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.third_product.id,
|
||||
'name': '1 Third Product',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
self.assertEqual(len(self.p1.coupon_ids.ids), 1, "You should get a coupon for you next order that will offer 10% discount")
|
||||
# 4.
|
||||
with self.assertRaises(UserError):
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
|
||||
'coupon_code': 'free_B_on_next_order'
|
||||
}).process_coupon()
|
||||
# 5.
|
||||
order.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_A.id,
|
||||
'name': '1 Product A',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
|
||||
'coupon_code': 'free_B_on_next_order'
|
||||
}).process_coupon()
|
||||
# 6.
|
||||
self.assertEqual(len(order.generated_coupon_ids), 2, "You should get a second coupon for your next order that will offer a free Product B")
|
||||
order.action_confirm()
|
||||
# 7.
|
||||
order_bis = self.empty_order
|
||||
with self.assertRaises(UserError):
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({
|
||||
'coupon_code': order.generated_coupon_ids[1].code
|
||||
}).process_coupon()
|
||||
# 8.
|
||||
order_bis.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_B.id,
|
||||
'name': '1 Product B',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
# 9.
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({
|
||||
'coupon_code': order.generated_coupon_ids[1].code
|
||||
}).process_coupon()
|
||||
self.assertEqual(len(order_bis.order_line), 2, "You should get a free Product B")
|
||||
# 10.
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({
|
||||
'coupon_code': order.generated_coupon_ids[0].code
|
||||
}).process_coupon()
|
||||
self.assertEqual(len(order_bis.order_line), 3, "You should get a 10% discount line")
|
||||
self.assertEqual(order_bis.amount_total, 0, "SO total should be null: (Paid product - Free product = 0) + 10% of nothing")
|
||||
|
||||
def test_on_next_order_reward_promotion_program_with_requirements(self):
|
||||
self.immediate_promotion_program.write({
|
||||
'promo_applicability': 'on_next_order',
|
||||
'promo_code_usage': 'code_needed',
|
||||
'promo_code': 'free_B_on_next_order',
|
||||
'rule_minimum_amount': 700,
|
||||
'rule_minimum_amount_tax_inclusion': 'tax_excluded'
|
||||
})
|
||||
order = self.empty_order.copy()
|
||||
self.product_A.lst_price = 700
|
||||
order.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_A.id,
|
||||
'name': '1 Product A',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
|
||||
'coupon_code': 'free_B_on_next_order'
|
||||
}).process_coupon()
|
||||
self.assertEqual(len(self.immediate_promotion_program.coupon_ids.ids), 1, "You should get a coupon for you next order that will offer a free product B")
|
||||
order_bis = self.empty_order
|
||||
order_bis.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_B.id,
|
||||
'name': '1 Product B',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
with self.assertRaises(UserError):
|
||||
# It should error since we did not validated the previous SO, so the coupon is `reserved` but not `new`
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({
|
||||
'coupon_code': order.generated_coupon_ids[0].code
|
||||
}).process_coupon()
|
||||
order.action_confirm()
|
||||
# It should not error even if the SO does not have the requirements (700$ and 1 product A), since these requirements where only used to generate the coupon that we are now applying
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=order_bis.id).create({
|
||||
'coupon_code': order.generated_coupon_ids[0].code
|
||||
}).process_coupon()
|
||||
self.assertEqual(len(order_bis.order_line), 2, "You should get 1 regular product_B and 1 free product_B")
|
||||
order_bis.recompute_coupon_lines()
|
||||
self.assertEqual(len(order_bis.order_line), 2, "Free product from a coupon generated from a promotion program on next order should not dissapear")
|
||||
|
||||
def test_on_next_order_reward_promo_program(self):
|
||||
# TODO: remove me in master, this was never executed due to bad indentation (the method is not indented under the class)
|
||||
# note that this flow did not worked and was not implemented.. now tested with `test_on_next_order_reward_promotion_program()`
|
||||
pass
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- 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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
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,
|
||||
})
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
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})]})
|
||||
order.recompute_coupon_lines()
|
||||
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})]})
|
||||
order.recompute_coupon_lines()
|
||||
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)
|
||||
]})
|
||||
order.recompute_coupon_lines()
|
||||
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,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.sale.coupon</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="sale.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='sale_coupon']" position="after">
|
||||
<div class="content-group">
|
||||
<div class="mt16" attrs="{'invisible': [('module_sale_coupon', '=', False)]}">
|
||||
<button name="%(sale_coupon.sale_coupon_program_action_promo_program)d" icon="fa-arrow-right" type="action" string="Promotion Programs" class="btn-link"/>
|
||||
</div>
|
||||
<div attrs="{'invisible': [('module_sale_coupon', '=', False)]}">
|
||||
<button name="%(sale_coupon.sale_coupon_program_action_coupon_program)d" icon="fa-arrow-right" type="action" string="Coupon Programs" class="btn-link"/>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,296 @@
|
||||
<?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" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options='{"terminology": "close"}'/>
|
||||
</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="domain" options="{'model': 'product.product'}"/>
|
||||
<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" class="o_row">
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="rule_minimum_amount" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="rule_minimum_amount_tax_inclusion"/>
|
||||
</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" string="Reward" widget="radio"/>
|
||||
<field name="discount_line_product_id" attrs="{'invisible': [('discount_line_product_id', '=', False)]}" readonly="True"/>
|
||||
</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"/>
|
||||
<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-ticket" 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"/>
|
||||
<span class="o_form_label oe_inline"> Days</span> <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="sequence" widget="handle"/>
|
||||
<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" name="active" domain="[('active', '=', True)]" help="Opened Programs"/>
|
||||
<filter string="Closed" name="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="view_sale_coupon_program_kanban" model="ir.ui.view">
|
||||
<field name="name">sale.coupon.program.kanban</field>
|
||||
<field name="model">sale.coupon.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<field name="name" />
|
||||
<field name="active"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
|
||||
<div class="text-center">
|
||||
<strong><span><field name="name"/></span></strong>
|
||||
</div>
|
||||
<hr class="mt4 mb4"/>
|
||||
<div class="row">
|
||||
<div class="col-4 text-center"><strong>Coupons</strong></div>
|
||||
<div class="col-4 text-center"><strong>Sales</strong></div>
|
||||
<div class="col-4 text-center"><strong>Active</strong></div>
|
||||
<div class="col-4 text-center"><field name="coupon_count"/></div>
|
||||
<div class="col-4 text-center"><field name="order_count"/></div>
|
||||
<div class="col-4 text-center">
|
||||
<field name="active" widget="boolean"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</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,kanban,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="o_view_nocontent_smiling_face">
|
||||
Create a new coupon program
|
||||
</p><p>
|
||||
Generate and share coupon codes with your customers to get discounts or free products.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_coupon_type_config" action="sale_coupon_program_action_coupon_program" parent="sale.product_menu_catalog" name="Coupon Programs" groups="sales_team.group_sale_manager" sequence="5"/>
|
||||
|
||||
|
||||
<!-- 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-ticket" 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="domain" options="{'model': 'res.partner'}"/>
|
||||
</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" name="active" domain="[('active', '=', True)]" help="Opened Programs"/>
|
||||
<filter string="Closed" name="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,kanban,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="o_view_nocontent_smiling_face">
|
||||
Create a new promotion program
|
||||
</p><p>
|
||||
Build up promotion programs to attract more customers with discounts, free products, free delivery, etc.
|
||||
You can share promotion codes or grant the promotions automatically if some conditions are met.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem action="sale_coupon_program_action_promo_program" id="menu_promotion_type_config" parent="sale.product_menu_catalog" name="Promotion Programs" groups="sales_team.group_sale_manager" sequence="4"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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>
|
||||
<button name="action_coupon_sent" type="object" string="Send by Email" class="oe_highlight"/>
|
||||
<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"/>
|
||||
<field name="sales_order_id"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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="//header/button[@name='action_cancel']" position="before">
|
||||
<button name="recompute_coupon_lines" string="Update Promotions" type="object" attrs="{'invisible': [('state', 'in', ['sale', 'done', 'cancel'])]}"/>
|
||||
</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.
|
||||
|
||||
from . import sale_coupon_apply_code
|
||||
from . import sale_coupon_generate
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- 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'
|
||||
_description = 'Sales Coupon Apply 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))
|
||||
if error_status.get('not_found', False):
|
||||
raise UserError(error_status.get('not_found', 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':
|
||||
# Avoid creating the coupon if it already exist
|
||||
if program.discount_line_product_id.id not in order.generated_coupon_ids.filtered(lambda coupon: coupon.state in ['new', 'reserved']).mapped('discount_line_product_id').ids:
|
||||
coupon = order._create_reward_coupon(program)
|
||||
return {
|
||||
'generated_coupon': {
|
||||
'reward': coupon.program_id.discount_line_product_id.name,
|
||||
'code': coupon.code,
|
||||
}
|
||||
}
|
||||
else: # The program is applied on this order
|
||||
order._create_reward_line(program)
|
||||
order.code_promo_program_id = program
|
||||
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 = {'not_found': _('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,37 @@
|
||||
# -*- 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'
|
||||
_description = 'Sales 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", default='[]')
|
||||
|
||||
@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.env['res.partner'].search(safe_eval(self.partners_domain)):
|
||||
vals.update({'partner_id': partner.id})
|
||||
coupon = self.env['sale.coupon'].create(vals)
|
||||
subject = '%s, a coupon has been generated for you' % (partner.name)
|
||||
template = self.env.ref('sale_coupon.mail_template_sale_coupon', raise_if_not_found=False)
|
||||
if template:
|
||||
template.send_mail(coupon.id, email_values={'email_to': partner.email, 'email_from': self.env.user.email or '', 'subject': subject,})
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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="domain" options="{'model': 'res.partner'}"/>
|
||||
<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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user