[ADD] sale_loyalty: manage loyalty in sales orders
This module replaces both sale_coupon and sale_gift_card aswell as adding new loyalty programs feature to sale. TaskId-2675382
This commit is contained in:
@@ -77,7 +77,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
|
||||
module_product_email_template = fields.Boolean("Specific Email")
|
||||
module_sale_amazon = fields.Boolean("Amazon Sync")
|
||||
module_sale_coupon = fields.Boolean("Coupons & Promotions")
|
||||
module_sale_loyalty = fields.Boolean("Coupons & Loyalty")
|
||||
module_sale_margin = fields.Boolean("Margins")
|
||||
|
||||
#=== ONCHANGE METHODS ===#
|
||||
|
||||
@@ -105,10 +105,10 @@
|
||||
id="coupon_settings"
|
||||
title="Boost your sales with two kinds of discount programs: promotions and coupon codes. Specific conditions can be set (products, customers, minimum purchase amount, period). Rewards can be discounts (% or amount) or free products.">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_sale_coupon"/>
|
||||
<field name="module_sale_loyalty"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_sale_coupon"/>
|
||||
<label for="module_sale_loyalty"/>
|
||||
<div class="text-muted" id="sale_coupon">
|
||||
Manage promotion & coupon programs
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': 'Sale Loyalty',
|
||||
'summary': 'Use discounts and loyalty programs in sales orders',
|
||||
'description': 'Integrate discount and loyalty programs mechanisms in sales orders.',
|
||||
'category': 'Sales/Sales',
|
||||
'version': '1.0',
|
||||
'depends': ['sale', 'loyalty'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sale_loyalty_data.xml',
|
||||
'wizard/sale_loyalty_coupon_wizard_views.xml',
|
||||
'wizard/sale_loyalty_reward_wizard_views.xml',
|
||||
'views/loyalty_card_views.xml',
|
||||
'views/loyalty_program_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/templates.xml',
|
||||
],
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="loyalty.gift_card_product_50" model="product.product">
|
||||
<field name="taxes_id" eval="False"/>
|
||||
</record>
|
||||
<record id="loyalty.ewallet_product_50" model="product.product">
|
||||
<field name="taxes_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import loyalty_card
|
||||
from . import loyalty_program
|
||||
from . import loyalty_reward
|
||||
from . import sale_order_coupon_points
|
||||
from . import sale_order_line
|
||||
from . import sale_order
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class LoyaltyCard(models.Model):
|
||||
_inherit = 'loyalty.card'
|
||||
|
||||
order_id = fields.Many2one('sale.order', 'Order Reference', readonly=True,
|
||||
help="The sales order from which coupon is generated")
|
||||
|
||||
def _get_default_template(self):
|
||||
default_template = super()._get_default_template()
|
||||
if not default_template:
|
||||
default_template = self.env.ref('sale_loyalty.mail_template_sale_loyalty', raise_if_not_found=False)
|
||||
return default_template
|
||||
|
||||
def _get_mail_partner(self):
|
||||
return super()._get_mail_partner() or self.order_id.partner_id
|
||||
|
||||
def _get_signature(self):
|
||||
return self.order_id.user_id.signature or super()._get_signature()
|
||||
|
||||
def _compute_use_count(self):
|
||||
super()._compute_use_count()
|
||||
read_group_res = self.env['sale.order.line']._read_group(
|
||||
[('coupon_id', 'in', self.ids)], ['id'], ['coupon_id'])
|
||||
count_per_coupon = {r['coupon_id'][0]: r['coupon_id_count'] for r in read_group_res}
|
||||
for card in self:
|
||||
card.use_count += count_per_coupon.get(card.id, 0)
|
||||
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class LoyaltyProgram(models.Model):
|
||||
_inherit = 'loyalty.program'
|
||||
|
||||
order_count = fields.Integer(compute='_compute_order_count')
|
||||
|
||||
def _compute_order_count(self):
|
||||
# An order should count only once PER program but may appear in multiple programs
|
||||
read_group_res = self.env['sale.order.line']._read_group(
|
||||
[('reward_id', 'in', self.reward_ids.ids)], ['reward_id:array_agg'], ['order_id'])
|
||||
for program in self:
|
||||
program_reward_ids = program.reward_ids.ids
|
||||
program.order_count = sum(1 if any(id in group['reward_id'] for id in program_reward_ids) else 0 for group in read_group_res)
|
||||
|
||||
def _compute_total_order_count(self):
|
||||
super()._compute_total_order_count()
|
||||
for program in self:
|
||||
program.total_order_count += program.order_count
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
class LoyaltyReward(models.Model):
|
||||
_inherit = 'loyalty.reward'
|
||||
|
||||
def _get_discount_product_values(self):
|
||||
res = super()._get_discount_product_values()
|
||||
for vals in res:
|
||||
vals.update({
|
||||
'taxes_id': False,
|
||||
'supplier_taxes_id': False,
|
||||
})
|
||||
return res
|
||||
@@ -0,0 +1,972 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import itertools
|
||||
|
||||
import random
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.fields import Command
|
||||
from odoo.tools.float_utils import float_is_zero, float_round
|
||||
from odoo.osv import expression
|
||||
|
||||
def _generate_random_reward_code():
|
||||
return str(random.getrandbits(32))
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
# Contains how much points should be given to a coupon upon validating the order
|
||||
applied_coupon_ids = fields.Many2many('loyalty.card', string="Manually Applied Coupons", copy=False)
|
||||
code_enabled_rule_ids = fields.Many2many('loyalty.rule', string="Manually Triggered Rules", copy=False)
|
||||
coupon_point_ids = fields.One2many('sale.order.coupon.points', 'order_id', copy=False)
|
||||
reward_amount = fields.Float(compute='_compute_reward_total')
|
||||
|
||||
@api.depends('order_line')
|
||||
def _compute_reward_total(self):
|
||||
for order in self:
|
||||
reward_amount = 0
|
||||
for line in order.order_line:
|
||||
if not line.reward_id:
|
||||
continue
|
||||
if line.reward_id.reward_type != 'product':
|
||||
reward_amount += line.price_subtotal
|
||||
else:
|
||||
# Free product are 'regular' product lines with a price_unit of 0
|
||||
reward_amount -= line.product_id.lst_price * line.product_uom_qty
|
||||
order.reward_amount = reward_amount
|
||||
|
||||
def _get_no_effect_on_threshold_lines(self):
|
||||
"""
|
||||
Returns the lines that have no effect on the minimum amount to reach
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.env['sale.order.line']
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
order = super(SaleOrder, self).copy(default)
|
||||
reward_lines = order.order_line.filtered('is_reward_line')
|
||||
if reward_lines:
|
||||
reward_lines.unlink()
|
||||
return order
|
||||
|
||||
def action_confirm(self):
|
||||
for order in self:
|
||||
all_coupons = order.applied_coupon_ids | order.coupon_point_ids.coupon_id | order.order_line.coupon_id
|
||||
if any(order._get_real_points_for_coupon(coupon) < 0 for coupon in all_coupons):
|
||||
raise ValidationError(_('One or more rewards on the sale order is invalid. Please check them.'))
|
||||
order._update_programs_and_rewards()
|
||||
|
||||
# Remove any coupon from 'current' program that don't claim any reward.
|
||||
# This is to avoid ghost coupons that are lost forever.
|
||||
# Claiming a reward for that program will require either an automated check or a manual input again.
|
||||
reward_coupons = self.order_line.coupon_id
|
||||
self.coupon_point_ids.filtered(
|
||||
lambda pe: pe.coupon_id.program_id.applies_on == 'current' and pe.coupon_id not in reward_coupons)\
|
||||
.coupon_id.sudo().unlink()
|
||||
# Add/remove the points to our coupons
|
||||
for coupon, change in self.filtered(lambda s: s.state != 'sale')._get_point_changes().items():
|
||||
coupon.points += change
|
||||
self._send_reward_coupon_mail()
|
||||
return super(SaleOrder, self).action_confirm()
|
||||
|
||||
def action_cancel(self):
|
||||
previously_confirmed = self.filtered(lambda s: s.state in ('sale', 'done'))
|
||||
res = super(SaleOrder, self).action_cancel()
|
||||
# Add/remove the points to our coupons
|
||||
for coupon, changes in previously_confirmed.filtered(lambda s: s.state not in ('sale', 'done'))._get_point_changes().items():
|
||||
coupon.points -= changes
|
||||
# Remove any rewards
|
||||
self.order_line.filtered(lambda l: l.is_reward_line).unlink()
|
||||
self.coupon_point_ids.coupon_id.filtered(
|
||||
lambda c: not c.program_id.is_nominative and c.order_id in self and not c.use_count)\
|
||||
.sudo().unlink()
|
||||
self.coupon_point_ids.unlink()
|
||||
return res
|
||||
|
||||
def action_open_reward_wizard(self):
|
||||
self.ensure_one()
|
||||
self._update_programs_and_rewards()
|
||||
claimable_rewards = self._get_claimable_rewards()
|
||||
if len(claimable_rewards) == 1:
|
||||
coupon = next(iter(claimable_rewards))
|
||||
if len(claimable_rewards[coupon]) == 1:
|
||||
self._apply_program_reward(claimable_rewards[coupon], coupon)
|
||||
return True
|
||||
elif not claimable_rewards:
|
||||
return True
|
||||
return self.env['ir.actions.actions']._for_xml_id('sale_loyalty.sale_loyalty_reward_wizard_action')
|
||||
|
||||
def _send_reward_coupon_mail(self):
|
||||
coupons = self.env['loyalty.card']
|
||||
for order in self:
|
||||
coupons |= order._get_reward_coupons()
|
||||
if coupons:
|
||||
coupons._send_creation_communication()
|
||||
|
||||
def _get_applied_global_discount_lines(self):
|
||||
"""
|
||||
Returns the first line of the currently applied global discount or False
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.order_line.filtered(lambda l: l.reward_id.is_global_discount)
|
||||
|
||||
def _get_applied_global_discount(self):
|
||||
"""
|
||||
Returns the currently applied global discount reward or False
|
||||
"""
|
||||
return self._get_applied_global_discount_lines().reward_id
|
||||
|
||||
def _get_reward_values_product(self, reward, coupon, product=None, **kwargs):
|
||||
"""
|
||||
Returns an array of dict containing the values required for the reward lines
|
||||
"""
|
||||
self.ensure_one()
|
||||
assert reward.reward_type == 'product'
|
||||
|
||||
reward_products = reward.reward_product_ids
|
||||
product = product or reward_products[:1]
|
||||
if not product or not product in reward_products:
|
||||
raise UserError(_('Invalid product to claim.'))
|
||||
taxes = self.fiscal_position_id.map_tax(product.taxes_id.filtered(lambda t: t.company_id == self.company_id))
|
||||
points = self._get_real_points_for_coupon(coupon)
|
||||
claimable_count = float_round(points / reward.required_points, precision_rounding=1, rounding_method='DOWN') if not reward.clear_wallet else 1
|
||||
cost = points if reward.clear_wallet else claimable_count * reward.required_points
|
||||
return [{
|
||||
'name': _("Free Product - %(product)s", product=product.name),
|
||||
'product_id': product.id,
|
||||
'price_unit': 0,
|
||||
'product_uom_qty': reward.reward_product_qty * claimable_count,
|
||||
'reward_id': reward.id,
|
||||
'coupon_id': coupon.id,
|
||||
'points_cost': cost,
|
||||
'reward_identifier_code': _generate_random_reward_code(),
|
||||
'product_uom': product.uom_id.id,
|
||||
'tax_id': [(Command.CLEAR, 0, 0)] + [(Command.LINK, tax.id, False) for tax in taxes]
|
||||
}]
|
||||
|
||||
def _discountable_order(self, reward):
|
||||
"""
|
||||
Returns the discountable and discountable_per_tax for a discount that applies to the whole order
|
||||
"""
|
||||
self.ensure_one()
|
||||
assert reward.discount_applicability == 'order'
|
||||
|
||||
discountable = 0
|
||||
discountable_per_tax = defaultdict(int)
|
||||
for line in (self.order_line - self._get_no_effect_on_threshold_lines()):
|
||||
# Ignore lines from this reward
|
||||
if not line.product_uom_qty or not line.price_unit:
|
||||
continue
|
||||
line_discountable = line.price_unit * line.product_uom_qty * (1 - (line.discount or 0.0) / 100.0)
|
||||
discountable += line.price_total
|
||||
discountable_per_tax[line.tax_id] += line_discountable
|
||||
return discountable, discountable_per_tax
|
||||
|
||||
def _cheapest_line(self):
|
||||
self.ensure_one()
|
||||
cheapest_line = False
|
||||
for line in (self.order_line - self._get_no_effect_on_threshold_lines()):
|
||||
if line.reward_id or not line.product_uom_qty or not line.price_unit:
|
||||
continue
|
||||
if not cheapest_line or cheapest_line.price_unit > line.price_unit:
|
||||
cheapest_line = line
|
||||
return cheapest_line
|
||||
|
||||
def _discountable_cheapest(self, reward):
|
||||
"""
|
||||
Returns the discountable and discountable_per_tax for a discount that applies to the cheapest line
|
||||
"""
|
||||
self.ensure_one()
|
||||
assert reward.discount_applicability == 'cheapest'
|
||||
|
||||
cheapest_line = self._cheapest_line()
|
||||
discountable = cheapest_line.price_unit * (1 - (cheapest_line.discount or 0) / 100)
|
||||
return discountable, {cheapest_line.tax_id: discountable}
|
||||
|
||||
def _get_specific_discountable_lines(self, reward):
|
||||
"""
|
||||
Returns all lines to which `reward` can apply
|
||||
"""
|
||||
self.ensure_one()
|
||||
assert reward.discount_applicability == 'specific'
|
||||
|
||||
discountable_lines = self.env['sale.order.line']
|
||||
for line in (self.order_line - self._get_no_effect_on_threshold_lines()):
|
||||
if not line.reward_id and line.product_id in reward.all_discount_product_ids:
|
||||
discountable_lines |= line
|
||||
return discountable_lines
|
||||
|
||||
def _discountable_specific(self, reward):
|
||||
"""
|
||||
Special function to compute the discountable for 'specific' types of discount.
|
||||
The goal of this function is to make sure that applying a 5$ discount on an order with a
|
||||
5$ product and a 5% discount does not make the order go below 0.
|
||||
|
||||
Returns the discountable and discountable_per_tax for a discount that only applies to specific products.
|
||||
"""
|
||||
self.ensure_one()
|
||||
assert reward.discount_applicability == 'specific'
|
||||
|
||||
lines_to_discount = self.env['sale.order.line']
|
||||
discount_lines = defaultdict(lambda: self.env['sale.order.line'])
|
||||
order_lines = self.order_line - self._get_no_effect_on_threshold_lines()
|
||||
remaining_amount_per_line = defaultdict(int)
|
||||
for line in order_lines:
|
||||
if not line.product_uom_qty or not line.price_unit:
|
||||
continue
|
||||
remaining_amount_per_line[line] = line.price_total
|
||||
if not line.reward_id and line.product_id in reward.all_discount_product_ids:
|
||||
lines_to_discount |= line
|
||||
elif line.reward_id.reward_type == 'discount':
|
||||
if line.reward_id == reward:
|
||||
continue
|
||||
discount_lines[line.reward_identifier_code] |= line
|
||||
|
||||
cheapest_line = False
|
||||
for lines in discount_lines.values():
|
||||
line_reward = lines.reward_id
|
||||
discounted_lines = order_lines
|
||||
if line_reward.discount_applicability == 'cheapest':
|
||||
cheapest_line = cheapest_line or self._cheapest_line()
|
||||
discounted_lines = cheapest_line
|
||||
elif line_reward.discount_applicability == 'specific':
|
||||
discounted_lines = self._get_specific_discountable_lines(line_reward)
|
||||
if not discounted_lines:
|
||||
continue
|
||||
common_lines = discounted_lines & lines_to_discount
|
||||
if line_reward.discount_mode == 'percent':
|
||||
for line in discounted_lines:
|
||||
remaining_amount_per_line[line] *= (1 - line_reward.discount/100)
|
||||
else:
|
||||
non_common_lines = discounted_lines - lines_to_discount
|
||||
discounted_amount = abs(sum(lines.mapped('price_total')))
|
||||
for line in itertools.chain(non_common_lines, common_lines):
|
||||
if discounted_amount == 0:
|
||||
break
|
||||
remaining = remaining_amount_per_line[line]
|
||||
consumed = min(remaining, discounted_amount)
|
||||
discounted_amount -= consumed
|
||||
remaining_amount_per_line[line] -= consumed
|
||||
|
||||
discountable = 0
|
||||
discountable_per_tax = defaultdict(int)
|
||||
for line in lines_to_discount:
|
||||
discountable += remaining_amount_per_line[line]
|
||||
line_discountable = line.price_unit * line.product_uom_qty * (1 - (line.discount or 0.0) / 100.0)
|
||||
# line_discountable is the same as in a 'order' discount
|
||||
# but first multiplied by a factor for the taxes to apply
|
||||
# and then multiplied by another factor coming from the discountable
|
||||
discountable_per_tax[line.tax_id] += line_discountable *\
|
||||
(remaining_amount_per_line[line] / line.price_total)
|
||||
return discountable, discountable_per_tax
|
||||
|
||||
def _get_reward_values_discount(self, reward, coupon, **kwargs):
|
||||
self.ensure_one()
|
||||
assert reward.reward_type == 'discount'
|
||||
|
||||
# Figure out which lines are concerned by the discount
|
||||
# cheapest_line = self.env['sale.order.line']
|
||||
discountable = 0
|
||||
discountable_per_tax = defaultdict(int)
|
||||
reward_applies_on = reward.discount_applicability
|
||||
if reward_applies_on == 'order':
|
||||
discountable, discountable_per_tax = self._discountable_order(reward)
|
||||
elif reward_applies_on == 'specific':
|
||||
discountable, discountable_per_tax = self._discountable_specific(reward)
|
||||
elif reward_applies_on == 'cheapest':
|
||||
discountable, discountable_per_tax = self._discountable_cheapest(reward)
|
||||
# Discountable should never surpass the order's current total amount
|
||||
discountable = min(self.amount_total, discountable)
|
||||
if not discountable:
|
||||
raise UserError(_('There is nothing to discount'))
|
||||
max_discount = reward.currency_id._convert(reward.discount_max_amount, self.currency_id, self.company_id, fields.Date.today()) or float('inf')
|
||||
if reward.discount_mode == 'per_point':
|
||||
max_discount = min(max_discount,
|
||||
reward.currency_id._convert(reward.discount * self._get_real_points_for_coupon(coupon),
|
||||
self.currency_id, self.company_id, fields.Date.today()))
|
||||
elif reward.discount_mode == 'per_order':
|
||||
max_discount = min(max_discount,
|
||||
reward.currency_id._convert(reward.discount, self.currency_id, self.company_id, fields.Date.today()))
|
||||
elif reward.discount_mode == 'percent':
|
||||
max_discount = min(max_discount, discountable * (reward.discount / 100))
|
||||
# Discount per taxes
|
||||
reward_code = _generate_random_reward_code()
|
||||
point_cost = reward.required_points if not reward.clear_wallet else self._get_real_points_for_coupon(coupon)
|
||||
if reward.discount_mode == 'per_point' and not reward.clear_wallet:
|
||||
# Calculate the actual point cost if the cost is per point
|
||||
converted_discount = self.currency_id._convert(min(max_discount, discountable), reward.currency_id, self.company_id, fields.Date.today())
|
||||
point_cost = converted_discount / reward.discount
|
||||
# Gift cards and eWallets are considered gift cards and should not have any taxes
|
||||
if reward.program_id.program_type in ('ewallet', 'gift_card'):
|
||||
return [{
|
||||
'name': reward.description,
|
||||
'product_id': reward.discount_line_product_id.id,
|
||||
'price_unit': -min(max_discount, discountable),
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': reward.discount_line_product_id.uom_id.id,
|
||||
'reward_id': reward.id,
|
||||
'coupon_id': coupon.id,
|
||||
'points_cost': point_cost,
|
||||
'reward_identifier_code': reward_code,
|
||||
}]
|
||||
discount_factor = min(1, (max_discount / discountable)) if discountable else 1
|
||||
mapped_taxes = {tax: self.fiscal_position_id.map_tax(tax) for tax in discountable_per_tax}
|
||||
reward_dict = {tax: {
|
||||
'name': _(
|
||||
'Discount: %(desc)s%(tax_str)s',
|
||||
desc=reward.description,
|
||||
tax_str=_(' - On product with the following taxes: %(taxes)s', taxes=", ".join(mapped_taxes[tax].mapped('name'))),
|
||||
),
|
||||
'product_id': reward.discount_line_product_id.id,
|
||||
'price_unit': -(price * discount_factor),
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': reward.discount_line_product_id.uom_id.id,
|
||||
'reward_id': reward.id,
|
||||
'coupon_id': coupon.id,
|
||||
'points_cost': 0,
|
||||
'reward_identifier_code': reward_code,
|
||||
'tax_id': [(Command.CLEAR, 0, 0)] + [(Command.LINK, tax.id, False) for tax in mapped_taxes[tax]]
|
||||
} for tax, price in discountable_per_tax.items()}
|
||||
# We only assign the point cost to one line to avoid counting the cost multiple times
|
||||
if reward_dict:
|
||||
reward_dict[next(iter(reward_dict))]['points_cost'] = point_cost
|
||||
# Returning .values() directly does not return a subscribable list
|
||||
return list(reward_dict.values())
|
||||
|
||||
def _get_program_domain(self):
|
||||
"""
|
||||
Returns the base domain that all programs have to comply to.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return [('active', '=', True),
|
||||
('company_id', 'in', (self.company_id.id, False)),
|
||||
'|', ('date_to', '=', False), ('date_to', '>=', fields.Date.context_today(self))]
|
||||
|
||||
def _get_trigger_domain(self):
|
||||
"""
|
||||
Returns the base domain that all triggers have to comply to.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return [('active', '=', True),
|
||||
('company_id', 'in', (self.company_id.id, False)),
|
||||
'|', ('program_id.date_to', '=', False), ('program_id.date_to', '<=', fields.Date.context_today(self))]
|
||||
|
||||
def _get_applicable_program_points(self, domain=None):
|
||||
"""
|
||||
Returns a dict with the points per program for each (automatic) program that is applicable
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not domain:
|
||||
domain = [('trigger', '=', 'auto')]
|
||||
# Make sure domain always complies with the order's domain rules
|
||||
domain = expression.AND([self._get_program_domain(), domain])
|
||||
# No other way than to test all programs to the order
|
||||
programs = self.env['loyalty.program'].search(domain)
|
||||
all_status = self._program_check_compute_points(programs)
|
||||
program_points = {p: status['points'][0] for p, status in all_status.items() if 'points' in status}
|
||||
return program_points
|
||||
|
||||
def _get_points_programs(self):
|
||||
"""
|
||||
Returns all programs that give points on the current order.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.coupon_point_ids.coupon_id.program_id
|
||||
|
||||
def _get_reward_programs(self):
|
||||
"""
|
||||
Returns all programs that are being used for rewards.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.order_line.reward_id.program_id
|
||||
|
||||
def _get_reward_coupons(self):
|
||||
"""
|
||||
Returns all coupons that are a reward.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.coupon_point_ids.coupon_id.filtered(lambda c: c.program_id.applies_on == 'future')
|
||||
|
||||
def _get_applied_programs(self):
|
||||
"""
|
||||
Returns all applied programs on current order.
|
||||
|
||||
Applied programs is the combination of both new points for your order and the programs linked to rewards.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self._get_points_programs() | self._get_reward_programs()
|
||||
|
||||
def _get_invoice_status(self):
|
||||
# Handling of a specific situation: an order contains
|
||||
# a product invoiced on delivery and a promo line invoiced
|
||||
# on order. We would avoid having the invoice status 'to_invoice'
|
||||
# if the created invoice will only contain the promotion line
|
||||
super()._get_invoice_status()
|
||||
for order in self:
|
||||
if order.invoice_status != 'to_invoice':
|
||||
continue
|
||||
if not any(not line.is_reward_line and line.invoice_status == 'to invoice' for line in order.order_line):
|
||||
order.invoice_status = 'no'
|
||||
|
||||
def _get_invoiceable_lines(self, final=False):
|
||||
""" Ensures we cannot invoice only reward lines.
|
||||
|
||||
Since promotion lines are specified with service products,
|
||||
those lines are directly invoiceable when the order is confirmed
|
||||
which can result in invoices containing only promotion lines.
|
||||
|
||||
To avoid those cases, we allow the invoicing of promotion lines
|
||||
if at least another 'basic' lines is also invoiceable.
|
||||
"""
|
||||
invoiceable_lines = super()._get_invoiceable_lines(final)
|
||||
for line in invoiceable_lines:
|
||||
if not line.is_reward_line:
|
||||
return invoiceable_lines
|
||||
return self.env['sale.order.line']
|
||||
|
||||
def update_prices(self):
|
||||
"""Recompute coupons/promotions after pricelist prices reset."""
|
||||
super().update_prices()
|
||||
if any(line.is_reward_line for line in self.order_line):
|
||||
self._update_programs_and_rewards()
|
||||
|
||||
def _get_point_changes(self):
|
||||
"""
|
||||
Returns the changes in points per coupon as a dict.
|
||||
|
||||
Used when validating/cancelling an order
|
||||
"""
|
||||
points_per_coupon = defaultdict(lambda: 0)
|
||||
for coupon_point in self.coupon_point_ids:
|
||||
points_per_coupon[coupon_point.coupon_id] += coupon_point.points
|
||||
for line in self.order_line:
|
||||
if not line.reward_id or not line.coupon_id:
|
||||
continue
|
||||
points_per_coupon[line.coupon_id] -= line.points_cost
|
||||
return points_per_coupon
|
||||
|
||||
def _get_real_points_for_coupon(self, coupon, post_confirm=False):
|
||||
"""
|
||||
Returns the actual points usable for this coupon for this order. Set pos_confirm to True to include points for future orders.
|
||||
|
||||
This is calculated by taking the points on the coupon, the points the order will give to the coupon (if applicable) and removing the points taken by already applied rewards.
|
||||
"""
|
||||
self.ensure_one()
|
||||
points = coupon.points
|
||||
if (coupon.program_id.applies_on != 'future' and self.state not in ('sale', 'done')) or post_confirm:
|
||||
# Points that will be given by the order upon confirming the order
|
||||
points += self.coupon_point_ids.filtered(lambda p: p.coupon_id == coupon).points
|
||||
# Points already used by rewards
|
||||
points -= sum(self.order_line.filtered(lambda l: l.coupon_id == coupon).mapped('points_cost'))
|
||||
return points
|
||||
|
||||
def _add_points_for_coupon(self, coupon_points):
|
||||
"""
|
||||
Updates (or creates) an entry in coupon_point_ids for the given coupons.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state in ('sale', 'done'):
|
||||
for coupon, points in coupon_points.items():
|
||||
coupon.sudo().points += points
|
||||
for pe in self.coupon_point_ids.sudo():
|
||||
if pe.coupon_id in coupon_points:
|
||||
pe.points = coupon_points.pop(pe.coupon_id)
|
||||
if coupon_points:
|
||||
self.sudo().with_context(tracking_disable=True).write({
|
||||
'coupon_point_ids': [(0, 0, {
|
||||
'coupon_id': coupon.id,
|
||||
'points': points,
|
||||
}) for coupon, points in coupon_points.items()]
|
||||
})
|
||||
|
||||
def _remove_program_from_points(self, programs):
|
||||
self.coupon_point_ids.filtered(lambda p: p.coupon_id.program_id in programs).sudo().unlink()
|
||||
|
||||
def _get_reward_line_values(self, reward, coupon, **kwargs):
|
||||
self.ensure_one()
|
||||
self = self.with_context(lang=self.partner_id.lang)
|
||||
reward = reward.with_context(lang=self.partner_id.lang)
|
||||
if reward.reward_type == 'discount':
|
||||
return self._get_reward_values_discount(reward, coupon, **kwargs)
|
||||
elif reward.reward_type == 'product':
|
||||
return self._get_reward_values_product(reward, coupon, **kwargs)
|
||||
|
||||
def _write_vals_from_reward_vals(self, reward_vals, old_lines, delete=True):
|
||||
"""
|
||||
Update, create new reward line and delete old lines in one write on `order_line`
|
||||
|
||||
Returns the untouched old lines.
|
||||
"""
|
||||
self.ensure_one()
|
||||
command_list = []
|
||||
for vals, line in zip(reward_vals, old_lines):
|
||||
command_list.append((Command.UPDATE, line.id, vals))
|
||||
if len(reward_vals) > len(old_lines):
|
||||
command_list.extend((Command.CREATE, 0, vals) for vals in reward_vals[len(old_lines):])
|
||||
elif len(reward_vals) < len(old_lines) and delete:
|
||||
command_list.extend((Command.DELETE, line.id) for line in old_lines[len(reward_vals):])
|
||||
self.write({'order_line': command_list})
|
||||
return self.env['sale.order.line'] if delete else old_lines[len(reward_vals):]
|
||||
|
||||
def _apply_program_reward(self, reward, coupon, **kwargs):
|
||||
"""
|
||||
Applies the reward to the order provided the given coupon has enough points.
|
||||
This method does not check for program rules.
|
||||
|
||||
This method also assumes the points added by the program triggers have already been computed.
|
||||
The temporary points are used if the program is applicable to the current order.
|
||||
|
||||
Returns a dict containing the error message or empty if everything went correctly.
|
||||
NOTE: A call to `_update_programs_and_rewards` is expected to reorder the discounts.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Use the old lines before creating new ones. These should already be in a 'reset' state.
|
||||
old_reward_lines = kwargs.get('old_lines', self.env['sale.order.line'])
|
||||
if reward.is_global_discount:
|
||||
global_discount_reward_lines = self._get_applied_global_discount_lines()
|
||||
global_discount_reward = global_discount_reward_lines.reward_id
|
||||
if global_discount_reward and global_discount_reward != reward and global_discount_reward.discount >= reward.discount:
|
||||
return {'error': _('A better global discount is already applied.')}
|
||||
elif global_discount_reward and global_discount_reward != reward:
|
||||
# Invalidate the old global discount as it may impact the new discount to apply
|
||||
global_discount_reward_lines._reset_loyalty(True)
|
||||
old_reward_lines |= global_discount_reward_lines
|
||||
if not reward.program_id.is_nominative and reward.program_id.applies_on == 'future' and coupon in self.coupon_point_ids.coupon_id:
|
||||
return {'error': _('The coupon can only be claimed on future orders.')}
|
||||
elif self._get_real_points_for_coupon(coupon) < reward.required_points:
|
||||
return {'error': _('The coupon does not have enough points for the selected reward.')}
|
||||
reward_vals = self._get_reward_line_values(reward, coupon, **kwargs)
|
||||
self._write_vals_from_reward_vals(reward_vals, old_reward_lines)
|
||||
return {}
|
||||
|
||||
def _get_claimable_rewards(self, forced_coupons=None):
|
||||
"""
|
||||
Fetch all rewards that are currently claimable from all concerned coupons,
|
||||
meaning coupons from applied programs and applied rewards or the coupons given as parameter.
|
||||
|
||||
Returns a dict containing the all the claimable rewards grouped by coupon.
|
||||
Coupons that can not claim any reward are not contained in the result.
|
||||
"""
|
||||
self.ensure_one()
|
||||
all_coupons = forced_coupons or (self.coupon_point_ids.coupon_id | self.order_line.coupon_id | self.applied_coupon_ids)
|
||||
total_is_zero = float_is_zero(self.amount_total, precision_digits=2)
|
||||
result = defaultdict(lambda: self.env['loyalty.reward'])
|
||||
global_discount_reward = self._get_applied_global_discount()
|
||||
for coupon in all_coupons:
|
||||
points = self._get_real_points_for_coupon(coupon)
|
||||
for reward in coupon.program_id.reward_ids:
|
||||
if reward.is_global_discount and global_discount_reward and global_discount_reward.discount >= reward.discount:
|
||||
continue
|
||||
if reward.reward_type == 'discount' and total_is_zero:
|
||||
continue
|
||||
if points >= reward.required_points:
|
||||
result[coupon] |= reward
|
||||
return result
|
||||
|
||||
def _allow_nominative_programs(self):
|
||||
"""
|
||||
Whether or not this order may use nominative programs.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return True
|
||||
|
||||
def _update_programs_and_rewards(self):
|
||||
"""
|
||||
Updates applied programs's given points with the current state of the order.
|
||||
Checks automatic programs for applicability.
|
||||
Updates applied rewards using the new points and the current state of the order (for example with % discounts).
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# +===================================================+
|
||||
# | STEP 1: Retrieve all applicable programs |
|
||||
# +===================================================+
|
||||
|
||||
# Automatically load in eWallet coupons
|
||||
if self._allow_nominative_programs():
|
||||
ewallet_coupons = self.env['loyalty.card'].search(
|
||||
[('id', 'not in', self.applied_coupon_ids.ids), ('partner_id', '=', self.partner_id.id),
|
||||
('points', '>', 0), ('program_id.program_type', '=', 'ewallet')])
|
||||
if ewallet_coupons:
|
||||
self.applied_coupon_ids += ewallet_coupons
|
||||
# Programs that are applied to the order and count points
|
||||
points_programs = self._get_points_programs()
|
||||
# Coupon programs that require the program's rules to match but do not count for points
|
||||
coupon_programs = self.applied_coupon_ids.program_id
|
||||
# Programs that are automatic and not yet applied
|
||||
program_domain = self._get_program_domain()
|
||||
domain = expression.AND([program_domain, [('id', 'not in', points_programs.ids), ('trigger', '=', 'auto'), ('rule_ids.mode', '=', 'auto')]])
|
||||
automatic_programs = self.env['loyalty.program'].search(domain).filtered(lambda p:
|
||||
not p.limit_usage or p.total_order_count < p.max_usage)
|
||||
|
||||
all_programs_to_check = points_programs | coupon_programs | automatic_programs
|
||||
all_coupons = self.coupon_point_ids.coupon_id | self.applied_coupon_ids
|
||||
# First basic check using the program_domain -> for example if a program gets archived mid quotation
|
||||
domain_matching_programs = all_programs_to_check.filtered_domain(program_domain)
|
||||
all_programs_status = {p: {'error': 'error'} for p in all_programs_to_check - domain_matching_programs}
|
||||
# Compute applicability and points given for all programs that passed the domain check
|
||||
# Note that points are computed with reward lines present
|
||||
all_programs_status.update(self._program_check_compute_points(domain_matching_programs))
|
||||
# Delay any unlink to the end of the function since they cause a full cache invalidation
|
||||
lines_to_unlink = self.env['sale.order.line']
|
||||
coupons_to_unlink = self.env['loyalty.card']
|
||||
point_entries_to_unlink = self.env['sale.order.coupon.points']
|
||||
# Remove any coupons that do not belong to the customer or are expired
|
||||
self.applied_coupon_ids = self.applied_coupon_ids.filtered(lambda c:
|
||||
(not c.partner_id or c.partner_id == self.partner_id) and
|
||||
(not c.expiration_date or c.expiration_date >= fields.Date.today())
|
||||
)
|
||||
point_ids_per_program = defaultdict(lambda: self.env['sale.order.coupon.points'])
|
||||
for pe in self.coupon_point_ids:
|
||||
# Remove any point entry for a coupon that does not belong to the customer
|
||||
if pe.coupon_id.partner_id and pe.coupon_id.partner_id != self.partner_id:
|
||||
pe.points = 0
|
||||
point_entries_to_unlink |= pe
|
||||
else:
|
||||
point_ids_per_program[pe.coupon_id.program_id] |= pe
|
||||
|
||||
# +==========================================+
|
||||
# | STEP 2: Update applied programs |
|
||||
# +==========================================+
|
||||
|
||||
# Programs that were not applied via a coupon
|
||||
for program in points_programs:
|
||||
status = all_programs_status[program]
|
||||
program_point_entries = point_ids_per_program[program]
|
||||
if 'error' in status:
|
||||
# Program is not applicable anymore
|
||||
coupons_from_order = program_point_entries.coupon_id.filtered(lambda c: c.order_id == self)
|
||||
all_coupons -= coupons_from_order
|
||||
# Invalidate those lines so that they don't impact anything further down the line
|
||||
program_reward_lines = self.order_line.filtered(lambda l: l.coupon_id in coupons_from_order)
|
||||
program_reward_lines._reset_loyalty(True)
|
||||
lines_to_unlink |= program_reward_lines
|
||||
# Delete coupon created by this order for this program if it is not nominative
|
||||
if not program.is_nominative:
|
||||
coupons_to_unlink |= coupons_from_order
|
||||
else:
|
||||
# Only remove the coupon_point_id
|
||||
point_entries_to_unlink |= program_point_entries
|
||||
point_entries_to_unlink.points = 0
|
||||
# Remove the code activated rules
|
||||
self.code_enabled_rule_ids -= program.rule_ids
|
||||
else:
|
||||
# Program stays applicable, update our points
|
||||
all_point_changes = [p for p in status['points'] if p]
|
||||
if not all_point_changes and program.is_nominative:
|
||||
all_point_changes = [0]
|
||||
for pe, points in zip(program_point_entries.sudo(), all_point_changes):
|
||||
pe.points = points
|
||||
if len(program_point_entries) < len(all_point_changes):
|
||||
new_coupon_points = all_point_changes[len(program_point_entries):]
|
||||
# NOTE: Maybe we could batch the creation of coupons across multiple programs but this really only applies to gift cards
|
||||
new_coupons = self.env['loyalty.card'].with_context(loyalty_no_mail=True, tracking_disable=True).create([{
|
||||
'program_id': program.id,
|
||||
'partner_id': False,
|
||||
'points': 0,
|
||||
'order_id': self.id,
|
||||
} for _ in new_coupon_points])
|
||||
self._add_points_for_coupon({coupon: x for coupon, x in zip(new_coupons, new_coupon_points)})
|
||||
elif len(program_point_entries) > len(all_point_changes):
|
||||
point_ids_to_unlink = program_point_entries[len(all_point_changes):]
|
||||
all_coupons -= point_ids_to_unlink.coupon_id
|
||||
coupons_to_unlink |= point_ids_to_unlink.coupon_id
|
||||
point_ids_to_unlink.points = 0
|
||||
|
||||
# Programs applied using a coupon
|
||||
applied_coupon_per_program = defaultdict(lambda: self.env['loyalty.card'])
|
||||
for coupon in self.applied_coupon_ids:
|
||||
applied_coupon_per_program[coupon.program_id] |= coupon
|
||||
for program in coupon_programs:
|
||||
if program not in domain_matching_programs or\
|
||||
(program.applies_on == 'current' and 'error' in all_programs_status[program]):
|
||||
program_reward_lines = self.order_line.filtered(lambda l: l.coupon_id in applied_coupon_per_program[program])
|
||||
program_reward_lines._reset_loyalty(True)
|
||||
lines_to_unlink |= program_reward_lines
|
||||
self.applied_coupon_ids -= applied_coupon_per_program[program]
|
||||
all_coupons -= applied_coupon_per_program[program]
|
||||
|
||||
# +==========================================+
|
||||
# | STEP 3: Update reward lines |
|
||||
# +==========================================+
|
||||
|
||||
# We will reuse these lines as much as possible, this resets the order in a reward-less state
|
||||
reward_line_pool = self.order_line.filtered(lambda l: l.reward_id and l.coupon_id)._reset_loyalty()
|
||||
seen_rewards = set()
|
||||
line_rewards = []
|
||||
payment_rewards = [] # gift_card and ewallet are considered as payments and should always be applied last
|
||||
for line in self.order_line:
|
||||
if line.reward_identifier_code in seen_rewards or not line.reward_id or\
|
||||
not line.coupon_id:
|
||||
continue
|
||||
seen_rewards.add(line.reward_identifier_code)
|
||||
if line.reward_id.program_id.program_type not in ('gift_card', 'ewallet'):
|
||||
line_rewards.append((line.reward_id, line.coupon_id, line.reward_identifier_code, line.product_id))
|
||||
else:
|
||||
payment_rewards.append((line.reward_id, line.coupon_id, line.reward_identifier_code, line.product_id))
|
||||
|
||||
for reward_key in itertools.chain(line_rewards, payment_rewards):
|
||||
coupon = reward_key[1]
|
||||
reward = reward_key[0]
|
||||
program = reward.program_id
|
||||
points = self._get_real_points_for_coupon(coupon)
|
||||
if coupon not in all_coupons or points < reward.required_points or program not in domain_matching_programs:
|
||||
# Reward is not applicable anymore, the reward lines will simply be removed at the end of this function
|
||||
continue
|
||||
values_list = self._get_reward_line_values(reward, coupon, product=reward_key[3])
|
||||
reward_line_pool = self._write_vals_from_reward_vals(values_list, reward_line_pool, delete=False)
|
||||
|
||||
lines_to_unlink |= reward_line_pool
|
||||
|
||||
# +==========================================+
|
||||
# | STEP 4: Apply new programs |
|
||||
# +==========================================+
|
||||
|
||||
for program in automatic_programs:
|
||||
program_status = all_programs_status[program]
|
||||
if 'error' in program_status:
|
||||
continue
|
||||
self.__try_apply_program(program, False, program_status)
|
||||
|
||||
# +==========================================+
|
||||
# | STEP 5: Cleanup |
|
||||
# +==========================================+
|
||||
|
||||
order_line_update = [(Command.DELETE, line.id) for line in lines_to_unlink]
|
||||
if order_line_update:
|
||||
self.write({'order_line': order_line_update})
|
||||
if coupons_to_unlink:
|
||||
coupons_to_unlink.sudo().unlink()
|
||||
if point_entries_to_unlink:
|
||||
point_entries_to_unlink.sudo().unlink()
|
||||
|
||||
def _program_check_compute_points(self, programs):
|
||||
"""
|
||||
Checks the program validity from the order lines aswell as computing the number of points to add.
|
||||
|
||||
Returns a dict containing the error message or the points that will be given with the keys 'points'.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Prepare quantities
|
||||
order_lines = self.order_line.filtered(lambda line: line.product_id and not line.reward_id)
|
||||
products = order_lines.product_id
|
||||
products_qties = dict.fromkeys(products, 0)
|
||||
for line in order_lines:
|
||||
products_qties[line.product_id] += line.product_uom_qty
|
||||
# Contains the products that can be applied per rule
|
||||
products_per_rule = programs._get_valid_products(products)
|
||||
|
||||
# Prepare amounts
|
||||
no_effect_lines = self._get_no_effect_on_threshold_lines()
|
||||
base_untaxed_amount = self.amount_untaxed - sum(line.price_subtotal for line in no_effect_lines)
|
||||
base_tax_amount = self.amount_tax - sum(line.price_tax for line in no_effect_lines)
|
||||
amounts_per_program = {p: {'untaxed': base_untaxed_amount, 'tax': base_tax_amount} for p in programs}
|
||||
for line in self.order_line:
|
||||
if not line.reward_id or line.reward_id.reward_type != 'discount':
|
||||
continue
|
||||
for program in programs:
|
||||
# Do not consider the program's discount + automatic discount lines for the amount to check.
|
||||
if line.reward_id.program_id.trigger == 'auto' or line.reward_id.program_id == program:
|
||||
amounts_per_program[program]['untaxed'] -= line.price_subtotal
|
||||
amounts_per_program[program]['tax'] -= line.price_tax
|
||||
|
||||
result = {}
|
||||
for program in programs:
|
||||
untaxed_amount = amounts_per_program[program]['untaxed']
|
||||
tax_amount = amounts_per_program[program]['tax']
|
||||
|
||||
# Used for error messages
|
||||
# By default False, but True if no rules and applies_on current -> misconfigured coupons program
|
||||
code_matched = not bool(program.rule_ids) and program.applies_on == 'current' # Stays false if all triggers have code and none have been activated
|
||||
minimum_amount_matched = code_matched
|
||||
product_qty_matched = code_matched
|
||||
points = 0
|
||||
# Some rules may split their points per unit / money spent
|
||||
# (i.e. gift cards 2x50$ must result in two 50$ codes)
|
||||
rule_points = []
|
||||
program_result = result.setdefault(program, dict())
|
||||
for rule in program.rule_ids:
|
||||
if rule.mode == 'with_code' and rule not in self.code_enabled_rule_ids:
|
||||
continue
|
||||
code_matched = True
|
||||
rule_amount = rule._compute_amount(self.currency_id)
|
||||
if rule_amount > (rule.minimum_amount_tax_mode == 'incl' and (untaxed_amount + tax_amount) or untaxed_amount):
|
||||
continue
|
||||
minimum_amount_matched = True
|
||||
rule_products = products_per_rule[rule]
|
||||
ordered_rule_products_qty = sum(products_qties[product] for product in rule_products)
|
||||
if ordered_rule_products_qty < rule.minimum_qty or not rule_products:
|
||||
continue
|
||||
product_qty_matched = True
|
||||
if not rule.reward_point_amount:
|
||||
continue
|
||||
# Count all points separately if the order is for the future and the split option is enabled
|
||||
if program.applies_on == 'future' and rule.reward_point_split and rule.reward_point_mode != 'order':
|
||||
if rule.reward_point_mode == 'unit':
|
||||
rule_points.extend(rule.reward_point_amount for _ in range(int(ordered_rule_products_qty)))
|
||||
elif rule.reward_point_mode == 'money':
|
||||
for line in self.order_line:
|
||||
if line.is_reward_line or line.product_id not in rule_products or line.product_uom_qty <= 0:
|
||||
continue
|
||||
points_per_unit = float_round(
|
||||
(rule.reward_point_amount * line.price_total / line.product_uom_qty),
|
||||
precision_digits=2, rounding_method='DOWN')
|
||||
if not points_per_unit:
|
||||
continue
|
||||
rule_points.extend([points_per_unit] * int(line.product_uom_qty))
|
||||
else:
|
||||
# All checks have been passed we can now compute the points to give
|
||||
if rule.reward_point_mode == 'order':
|
||||
points += rule.reward_point_amount
|
||||
elif rule.reward_point_mode == 'money':
|
||||
# Compute amount paid for rule
|
||||
# NOTE: this does not account for discounts -> 1 point per $ * (100$ - 30%) will result in 100 points
|
||||
amount_paid = sum(max(0, line.price_total) for line in order_lines if line.product_id in rule_products)
|
||||
points += float_round(rule.reward_point_amount * amount_paid, precision_digits=2, rounding_method='DOWN')
|
||||
elif rule.reward_point_mode == 'unit':
|
||||
points += rule.reward_point_amount * ordered_rule_products_qty
|
||||
# NOTE: for programs that are nominative we always allow the program to be 'applied' on the order
|
||||
# with 0 points so that `_get_claimable_rewards` returns the rewards associated with those programs
|
||||
if not program.is_nominative:
|
||||
if not code_matched:
|
||||
program_result['error'] = _("This program requires a code to be applied.")
|
||||
elif not minimum_amount_matched:
|
||||
program_result['error'] = _(
|
||||
'A minimum of %(amount)s %(currency)s should be purchased to get the reward',
|
||||
amount=min(program.rule_ids.mapped('minimum_amount')),
|
||||
currency=program.currency_id.name,
|
||||
)
|
||||
elif not product_qty_matched:
|
||||
program_result['error'] = _("You don't have the required product quantities on your sales order.")
|
||||
elif not self._allow_nominative_programs():
|
||||
program_result['error'] = _("This program is not available for public users.")
|
||||
if 'error' not in program_result:
|
||||
points_result = [points] + rule_points
|
||||
program_result['points'] = points_result
|
||||
return result
|
||||
|
||||
def __try_apply_program(self, program, coupon, status):
|
||||
self.ensure_one()
|
||||
all_points = status['points']
|
||||
points = all_points[0]
|
||||
coupons = coupon or self.env['loyalty.card']
|
||||
if coupon:
|
||||
if program.is_nominative:
|
||||
self._add_points_for_coupon({coupon: points})
|
||||
elif not coupon:
|
||||
# If the program only applies on the current order it does not make sense to fetch already existing coupons
|
||||
if program.is_nominative:
|
||||
coupon = self.env['loyalty.card'].search(
|
||||
[('partner_id', '=', self.partner_id.id), ('program_id', '=', program.id)], limit=1)
|
||||
# Do not apply 'nominative' programs if no point is given and no coupon exists
|
||||
if not points and not coupon:
|
||||
return {'error': _('No card found for this loyalty program and no points will be given with this order.')}
|
||||
elif coupon:
|
||||
self._add_points_for_coupon({coupon: points})
|
||||
coupons = coupon
|
||||
if not coupon:
|
||||
all_points = [p for p in all_points if p]
|
||||
partner = False
|
||||
# Loyalty programs and ewallets are nominative
|
||||
if program.is_nominative:
|
||||
partner = self.partner_id.id
|
||||
coupons = self.env['loyalty.card'].sudo().with_context(loyalty_no_mail=True, tracking_disable=True).create([{
|
||||
'program_id': program.id,
|
||||
'partner_id': partner,
|
||||
'points': 0,
|
||||
'order_id': self.id,
|
||||
} for _ in all_points])
|
||||
self._add_points_for_coupon({coupon: x for coupon, x in zip(coupons, all_points)})
|
||||
return {'coupon': coupons}
|
||||
|
||||
def _try_apply_program(self, program, coupon=None):
|
||||
"""
|
||||
Tries to apply a program using the coupon if provided.
|
||||
|
||||
This function provides the full routine to apply a program, it will check for applicability
|
||||
aswell as creating the necessary coupons and co-models to give the points to the customer.
|
||||
|
||||
This function does not apply any reward to the order, rewards have to be given manually.
|
||||
|
||||
Returns a dict containing the error message or containing the associated coupon(s).
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Basic checks
|
||||
if not program.filtered_domain(self._get_program_domain()):
|
||||
return {'error': _('The program is not available for this order.')}
|
||||
elif program in self._get_applied_programs():
|
||||
return {'error': _('This program is already applied to this order.')}
|
||||
# Check for applicability from the program's triggers/rules.
|
||||
# This step should also compute the amount of points to give for that program on that order.
|
||||
status = self._program_check_compute_points(program)[program]
|
||||
if 'error' in status:
|
||||
return status
|
||||
return self.__try_apply_program(program, coupon, status)
|
||||
|
||||
def _try_apply_code(self, code):
|
||||
"""
|
||||
Tries to apply a promotional code to the sales order.
|
||||
It can be either from a coupon or a program rule.
|
||||
|
||||
Returns a dict with the following possible keys:
|
||||
- 'not_found': Populated with True if the code did not yield any result.
|
||||
- 'error': Any error message that could occur.
|
||||
OR The result of `_get_claimable_rewards` with the found or newly created coupon, it will be empty if the coupon was consumed completely.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
base_domain = self._get_trigger_domain()
|
||||
domain = expression.AND([base_domain, [('mode', '=', 'with_code'), ('code', '=', code)]])
|
||||
rule = self.env['loyalty.rule'].search(domain)
|
||||
program = rule.program_id
|
||||
coupon = False
|
||||
|
||||
if rule in self.code_enabled_rule_ids:
|
||||
return {'error': _('This promo code is already applied.')}
|
||||
|
||||
# No trigger was found from the code, try to find a coupon
|
||||
if not program:
|
||||
coupon = self.env['loyalty.card'].search([('partner_id', 'in', (False, self.partner_id.id)), ('code', '=', code)])
|
||||
if not coupon or not coupon.program_id.active or not coupon.program_id.reward_ids:
|
||||
return {'error': _('This code is invalid (%s).', code), 'not_found': True}
|
||||
elif coupon.expiration_date and coupon.expiration_date < fields.Date.today():
|
||||
return {'error': _('This coupon is expired.')}
|
||||
elif coupon.points < min(coupon.program_id.reward_ids.mapped('required_points')):
|
||||
return {'error': _('This coupon has already been used.')}
|
||||
program = coupon.program_id
|
||||
|
||||
if not program or not program.active:
|
||||
return {'error': _('This code is invalid (%s).', code), 'not_found': True}
|
||||
elif (program.limit_usage and program.total_order_count >= program.max_usage) or\
|
||||
(program.date_to and program.date_to <= fields.Date.context_today(self)):
|
||||
return {'error': _('This code is expired (%s).', code)}
|
||||
|
||||
# Rule will count the next time the points are updated
|
||||
if rule:
|
||||
self.code_enabled_rule_ids |= rule
|
||||
program_is_applied = program in self._get_points_programs()
|
||||
# Condition that need to apply program (if not applied yet):
|
||||
# current -> always
|
||||
# future -> if no coupon
|
||||
# nominative -> non blocking if card exists with points
|
||||
if coupon:
|
||||
self.applied_coupon_ids += coupon
|
||||
if program_is_applied:
|
||||
# Update the points for our programs, this will take the new trigger in account
|
||||
self._update_programs_and_rewards()
|
||||
elif program.applies_on != 'future' or not coupon:
|
||||
apply_result = self._try_apply_program(program, coupon)
|
||||
if 'error' in apply_result and (not program.is_nominative or (program.is_nominative and not coupon)):
|
||||
if rule:
|
||||
self.code_enabled_rule_ids -= rule
|
||||
if coupon:
|
||||
self.applied_coupon_ids -= coupon
|
||||
return apply_result
|
||||
coupon = apply_result.get('coupon', self.env['loyalty.card'])
|
||||
return self._get_claimable_rewards(forced_coupons=coupon)
|
||||
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class SaleOrderCouponPoints(models.Model):
|
||||
_name = 'sale.order.coupon.points'
|
||||
_description = 'Sale Order Coupon Points - Keeps track of how a sale order impacts a coupon'
|
||||
|
||||
order_id = fields.Many2one('sale.order', required=True, ondelete='cascade')
|
||||
coupon_id = fields.Many2one('loyalty.card', required=True, ondelete='cascade')
|
||||
points = fields.Float(required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('order_coupon_unique', 'UNIQUE (order_id, coupon_id)',
|
||||
'The coupon points entry already exists.')
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
is_reward_line = fields.Boolean('Is a program reward line', compute='_compute_is_reward_line')
|
||||
reward_id = fields.Many2one('loyalty.reward', ondelete='restrict', readonly=True)
|
||||
coupon_id = fields.Many2one('loyalty.card', ondelete='restrict', readonly=True)
|
||||
reward_identifier_code = fields.Char(help="""
|
||||
Technical field used to link multiple reward lines from the same reward together.
|
||||
""")
|
||||
points_cost = fields.Float(help='How much point this reward cost on the loyalty card.')
|
||||
|
||||
def _compute_name(self):
|
||||
# Avoid computing the name for reward lines
|
||||
reward = self.filtered('reward_id')
|
||||
super(SaleOrderLine, self - reward)._compute_name()
|
||||
|
||||
@api.depends('reward_id')
|
||||
def _compute_is_reward_line(self):
|
||||
for line in self:
|
||||
line.is_reward_line = bool(line.reward_id)
|
||||
|
||||
def _compute_tax_id(self):
|
||||
reward_lines = self.filtered('is_reward_line')
|
||||
super(SaleOrderLine, self - reward_lines)._compute_tax_id()
|
||||
# Discount reward line is split per tax, the discount is set on the line but not on the product
|
||||
# as the product is the generic discount line.
|
||||
# In case of a free product, retrieving the tax on the line instead of the product won't affect the behavior.
|
||||
for line in reward_lines:
|
||||
line = line.with_company(line.company_id)
|
||||
fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id._get_fiscal_position(line.order_partner_id)
|
||||
# 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)
|
||||
|
||||
def _get_display_price(self):
|
||||
# A product created from a promotion does not have a list_price.
|
||||
# The price_unit of a reward order line is computed by the promotion, so it can be used directly
|
||||
if self.is_reward_line:
|
||||
return self.price_unit
|
||||
return super()._get_display_price()
|
||||
|
||||
def _is_not_sellable_line(self):
|
||||
return self.is_reward_line or super()._is_not_sellable_line()
|
||||
|
||||
def _reset_loyalty(self, complete=False):
|
||||
"""
|
||||
Reset the line(s) to a state which does not impact reward computation.
|
||||
If complete is set to True we also remove the coupon and reward from the line(s).
|
||||
This option should be used when the line will be unlinked.
|
||||
|
||||
Returns self
|
||||
"""
|
||||
vals = {
|
||||
'points_cost': 0,
|
||||
'price_unit': 0,
|
||||
}
|
||||
if complete:
|
||||
vals.update({
|
||||
'coupon_id': False,
|
||||
'reward_id': False,
|
||||
})
|
||||
self.write(vals)
|
||||
return self
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
# Update our coupon points if the order is in a confirmed state
|
||||
for line in res:
|
||||
if line.coupon_id and line.points_cost and line.order_id.state in ('sale', 'done'):
|
||||
line.coupon_id.points -= line.points_cost
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
cost_in_vals = 'points_cost' in vals
|
||||
if cost_in_vals:
|
||||
previous_cost = {l: l.points_cost for l in self}
|
||||
res = super().write(vals)
|
||||
if cost_in_vals:
|
||||
# Update our coupon points if the order is in a confirmed state
|
||||
for line in self:
|
||||
if previous_cost[line] != line.points_cost and line.order_id.state in ('sale', 'done'):
|
||||
line.coupon_id.points += (previous_cost[line] - line.points_cost)
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
# Remove related reward lines
|
||||
reward_coupon_set = {(l.reward_id, l.coupon_id, l.reward_identifier_code) for l in self if l.reward_id}
|
||||
related_lines = self.env['sale.order.line']
|
||||
related_lines |= self.order_id.order_line.filtered(lambda l: (l.reward_id, l.coupon_id, l.reward_identifier_code) in reward_coupon_set)
|
||||
# Remove the line's coupon from order if it is the last line using that coupon
|
||||
coupons_to_unlink = self.env['loyalty.card']
|
||||
for line in self:
|
||||
if line.coupon_id:
|
||||
# 2 cases:
|
||||
# case 1: coupon has been applied directly
|
||||
# case 2: coupon was created from a program
|
||||
if line.coupon_id in line.order_id.applied_coupon_ids:
|
||||
line.order_id.applied_coupon_ids -= line.coupon_id
|
||||
elif line.coupon_id.order_id == line.order_id and line.coupon_id.program_id.applies_on == 'current' and\
|
||||
not any(oLine.coupon_id == line.coupon_id and oLine not in related_lines for oLine in line.order_id.order_line):
|
||||
# ondelete='restrict' would prevent deletion of the coupon unlink after unlinking lines
|
||||
coupons_to_unlink |= line.coupon_id
|
||||
line.order_id.code_enabled_rule_ids = line.order_id.code_enabled_rule_ids.filtered(lambda r: r.program_id != line.coupon_id.program_id)
|
||||
# Give back the points if the order is confirmed, points are given back if the order is cancelled but in this case we need to do it directly
|
||||
for line in related_lines:
|
||||
if line.order_id.state in ('sale', 'done'):
|
||||
line.coupon_id.points += line.points_cost
|
||||
res = super(SaleOrderLine, self | related_lines).unlink()
|
||||
coupons_to_unlink.sudo().unlink()
|
||||
return res
|
||||
@@ -0,0 +1,16 @@
|
||||
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_program_salesman,Loyalty Program (Salesperson),loyalty.model_loyalty_program,sales_team.group_sale_salesman,1,0,0,0
|
||||
access_program_manager,Loyalty Program (Manager),loyalty.model_loyalty_program,sales_team.group_sale_manager,1,1,1,1
|
||||
access_applicability_salesman,Loyalty Rule (Salesperson),loyalty.model_loyalty_rule,sales_team.group_sale_salesman,1,0,0,0
|
||||
access_applicability_manager,Loyalty Rule (Manager),loyalty.model_loyalty_rule,sales_team.group_sale_manager,1,1,1,1
|
||||
access_coupon_salesman,Loyalty (Salesperson),loyalty.model_loyalty_card,sales_team.group_sale_salesman,1,1,0,0
|
||||
access_coupon_manager,Loyalty (Manager),loyalty.model_loyalty_card,sales_team.group_sale_manager,1,1,1,0
|
||||
access_reward_salesman,Loyalty Reward (Salesperson),loyalty.model_loyalty_reward,sales_team.group_sale_salesman,1,0,0,0
|
||||
access_reward_manager,Loyalty Reward (Manager),loyalty.model_loyalty_reward,sales_team.group_sale_manager,1,1,1,1
|
||||
access_communication_salesman,Loyalty Communication (Salesperson),loyalty.model_loyalty_mail,sales_team.group_sale_salesman,1,0,0,0
|
||||
access_communication_manager,Loyalty Communication (Manager),loyalty.model_loyalty_mail,sales_team.group_sale_manager,1,1,1,1
|
||||
access_sale_coupon_apply_code,Loyalty Coupon Wizard,model_sale_loyalty_coupon_wizard,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_sale_coupon_apply_code_line,Loyalty Reward Wizard,model_sale_loyalty_reward_wizard,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_sale_coupon_generate,Coupon Generation,loyalty.model_loyalty_generate_wizard,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_sale_order_coupon_points_manager,Coupon Points (Manager),model_sale_order_coupon_points,sales_team.group_sale_manager,1,1,1,1
|
||||
access_sale_order_coupon_points_salesman,Coupon Points (Salesperson),model_sale_order_coupon_points,sales_team.group_sale_salesman,1,0,0,0
|
||||
|
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_buy_gift_card
|
||||
from . import test_loyalty
|
||||
from . import test_pay_with_gift_card
|
||||
from . import test_program_multi_company
|
||||
from . import test_program_numbers
|
||||
from . import test_program_rules
|
||||
from . import test_program_with_code_operations
|
||||
from . import test_program_without_code_operations
|
||||
from . import test_sale_invoicing
|
||||
@@ -0,0 +1,201 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from odoo.addons.sale.tests.test_sale_product_attribute_value_config import TestSaleProductAttributeValueCommon
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class TestSaleCouponCommon(TestSaleProductAttributeValueCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSaleCouponCommon, cls).setUpClass()
|
||||
|
||||
# set currency to not rely on demo data and avoid possible race condition
|
||||
cls.currency_ratio = 1.0
|
||||
pricelist = cls.env.ref('product.list0')
|
||||
pricelist.currency_id = cls._setup_currency(cls.currency_ratio)
|
||||
|
||||
# Set all the existing programs to active=False to avoid interference
|
||||
cls.env['loyalty.program'].search([]).write({'active': False})
|
||||
|
||||
# create partner for sale order.
|
||||
cls.steve = cls.env['res.partner'].create({
|
||||
'name': 'Steve Bucknor',
|
||||
'email': 'steve.bucknor@example.com',
|
||||
})
|
||||
|
||||
cls.empty_order = cls.env['sale.order'].create({
|
||||
'partner_id': cls.steve.id
|
||||
})
|
||||
|
||||
cls.uom_unit = cls.env.ref('uom.product_uom_unit')
|
||||
|
||||
# Taxes
|
||||
cls.tax_15pc_excl = cls.env['account.tax'].create({
|
||||
'name': "Tax 15%",
|
||||
'amount_type': 'percent',
|
||||
'amount': 15,
|
||||
'type_tax_use': 'sale',
|
||||
})
|
||||
|
||||
cls.tax_10pc_incl = cls.env['account.tax'].create({
|
||||
'name': "10% Tax incl",
|
||||
'amount_type': 'percent',
|
||||
'amount': 10,
|
||||
'price_include': True,
|
||||
})
|
||||
|
||||
#products
|
||||
cls.product_A = cls.env['product.product'].create({
|
||||
'name': 'Product A',
|
||||
'list_price': 100,
|
||||
'sale_ok': True,
|
||||
'taxes_id': [(6, 0, [cls.tax_15pc_excl.id])],
|
||||
})
|
||||
|
||||
cls.product_B = cls.env['product.product'].create({
|
||||
'name': 'Product B',
|
||||
'list_price': 5,
|
||||
'sale_ok': True,
|
||||
'taxes_id': [(6, 0, [cls.tax_15pc_excl.id])],
|
||||
})
|
||||
|
||||
cls.product_C = cls.env['product.product'].create({
|
||||
'name': 'Product C',
|
||||
'list_price': 100,
|
||||
'sale_ok': True,
|
||||
'taxes_id': [(6, 0, [])],
|
||||
})
|
||||
|
||||
cls.product_gift_card = cls.env['product.product'].create({
|
||||
'name': 'Gift Card 50',
|
||||
'detailed_type': 'service',
|
||||
'list_price': 50,
|
||||
'sale_ok': True,
|
||||
'taxes_id': False,
|
||||
})
|
||||
|
||||
# Immediate Program By A + B: get B free
|
||||
# No Conditions
|
||||
cls.program_gift_card = cls.env['loyalty.program'].create({
|
||||
'name': 'Gift Cards',
|
||||
'applies_on': 'future',
|
||||
'program_type': 'gift_card',
|
||||
'trigger': 'auto',
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': cls.product_gift_card,
|
||||
'reward_point_amount': 1,
|
||||
'reward_point_mode': 'money',
|
||||
'reward_point_split': True,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount': 1,
|
||||
'discount_mode': 'per_point',
|
||||
'discount_applicability': 'order',
|
||||
})]
|
||||
})
|
||||
cls.immediate_promotion_program = cls.env['loyalty.program'].create({
|
||||
'name': 'Buy A + 1 B, 1 B are free',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'company_id': cls.env.company.id,
|
||||
'trigger': 'auto',
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': cls.product_A,
|
||||
'reward_point_amount': 1,
|
||||
'reward_point_mode': 'order',
|
||||
'minimum_qty': 1,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': cls.product_B.id,
|
||||
'reward_product_qty': 1,
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.code_promotion_program = cls.env['loyalty.program'].create({
|
||||
'name': 'Buy 1 A + Enter code, 1 A is free',
|
||||
'program_type': 'coupons',
|
||||
'trigger': 'with_code',
|
||||
'applies_on': 'current',
|
||||
'company_id': cls.env.company.id,
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': cls.product_A,
|
||||
'reward_point_amount': 1,
|
||||
'reward_point_mode': 'order',
|
||||
'minimum_qty': 1,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': cls.product_A.id,
|
||||
'reward_product_qty': 1,
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.code_promotion_program_with_discount = cls.env['loyalty.program'].create({
|
||||
'name': 'Buy 1 C + Enter code, 10 percent discount on C',
|
||||
'program_type': 'coupons',
|
||||
'trigger': 'with_code',
|
||||
'applies_on': 'current',
|
||||
'company_id': cls.env.company.id,
|
||||
'rule_ids': [(0, 0, {
|
||||
'mode': 'with_code',
|
||||
'code': 'promotion_code_disc',
|
||||
'product_ids': cls.product_C,
|
||||
'reward_point_amount': 1,
|
||||
'reward_point_mode': 'order',
|
||||
'minimum_qty': 1,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount_mode': 'percent',
|
||||
'discount': 10,
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
|
||||
def _extract_rewards_from_claimable(self, status):
|
||||
rewards = self.env['loyalty.reward']
|
||||
for info in status.values():
|
||||
for reward_count in info['rewards']:
|
||||
rewards |= reward_count[0]
|
||||
|
||||
def _apply_promo_code(self, order, code, no_reward_fail=True):
|
||||
status = order._try_apply_code(code)
|
||||
if 'error' in status:
|
||||
raise ValidationError(status['error'])
|
||||
if not status and no_reward_fail:
|
||||
# Can happen if global discount got filtered out in `_get_claimable_rewards`
|
||||
raise ValidationError('No reward to claim with this coupon')
|
||||
coupons = self.env['loyalty.card']
|
||||
rewards = self.env['loyalty.reward']
|
||||
for coupon, coupon_rewards in status.items():
|
||||
coupons |= coupon
|
||||
rewards |= coupon_rewards
|
||||
if len(coupons) == 1 and len(rewards) == 1:
|
||||
status = order._apply_program_reward(rewards, coupons)
|
||||
if 'error' in status:
|
||||
raise ValidationError(status['error'])
|
||||
|
||||
def _claim_reward(self, order, program, coupon=False):
|
||||
if len(program.reward_ids) != 1:
|
||||
return False
|
||||
coupon = coupon or order.coupon_point_ids.coupon_id.filtered(lambda c: c.program_id == program)
|
||||
if len(coupon) != 1:
|
||||
return False
|
||||
status = order._apply_program_reward(program.reward_ids, coupon)
|
||||
return 'error' not in status
|
||||
|
||||
def _auto_rewards(self, order, programs):
|
||||
order._update_programs_and_rewards()
|
||||
coupons_per_program = defaultdict(lambda: self.env['loyalty.card'])
|
||||
for coupon in order.coupon_point_ids.coupon_id:
|
||||
coupons_per_program[coupon.program_id] |= coupon
|
||||
for program in programs:
|
||||
if len(program.reward_ids) > 1 or len(coupons_per_program[program]) != 1 or not program.active:
|
||||
continue
|
||||
self._claim_reward(order, program, coupons_per_program[program])
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
|
||||
from odoo.tests.common import tagged
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestBuyGiftCard(TestSaleCouponCommon):
|
||||
|
||||
def test_buying_gift_card(self):
|
||||
order = self.empty_order
|
||||
self.immediate_promotion_program.active = False
|
||||
order.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_A.id,
|
||||
'name': 'Ordinary Product A',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
}),
|
||||
(0, False, {
|
||||
'product_id': self.product_gift_card.id,
|
||||
'name': 'Gift Card Product',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
self.assertEqual(len(order.order_line.ids), 2)
|
||||
self.assertEqual(len(order._get_reward_coupons()), 0)
|
||||
order._update_programs_and_rewards()
|
||||
self.assertEqual(len(order._get_reward_coupons()), 1)
|
||||
order.order_line[1].product_uom_qty = 2
|
||||
order._update_programs_and_rewards()
|
||||
self.assertEqual(len(order._get_reward_coupons()), 2)
|
||||
order.order_line[1].product_uom_qty = 1
|
||||
order._update_programs_and_rewards()
|
||||
self.assertEqual(len(order._get_reward_coupons()), 1)
|
||||
@@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLoyalty(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env['loyalty.program'].search([]).write({'active': False})
|
||||
|
||||
def test_nominative_programs(self):
|
||||
loyalty_program, ewallet_program = self.env['loyalty.program'].create([
|
||||
{
|
||||
'name': 'Loyalty Program',
|
||||
'program_type': 'loyalty',
|
||||
'trigger': 'auto',
|
||||
'applies_on': 'both',
|
||||
'rule_ids': [(0, 0, {
|
||||
'reward_point_mode': 'money',
|
||||
'reward_point_amount': 10,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {})],
|
||||
},
|
||||
{
|
||||
'name': 'eWallet Program',
|
||||
'program_type': 'ewallet',
|
||||
'applies_on': 'future',
|
||||
'trigger': 'auto',
|
||||
'rule_ids': [(0, 0, {
|
||||
'reward_point_mode': 'money',
|
||||
'reward_point_amount': 10,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {})],
|
||||
}
|
||||
])
|
||||
product = self.env['product.product'].create({
|
||||
'name': 'Product C',
|
||||
'list_price': 100,
|
||||
'sale_ok': True,
|
||||
'taxes_id': [(6, 0, [])],
|
||||
})
|
||||
|
||||
jean_jacques = self.env['res.partner'].create({'name': 'Jean Jacques'})
|
||||
order = self.env['sale.order'].create({
|
||||
'partner_id': jean_jacques.id,
|
||||
})
|
||||
order._update_programs_and_rewards()
|
||||
claimable_rewards = order._get_claimable_rewards()
|
||||
# Should be empty since we do not have any coupon created yet
|
||||
self.assertFalse(claimable_rewards, "No program should be applicable")
|
||||
_, ewallet_coupon = self.env['loyalty.card'].create([
|
||||
{
|
||||
'program_id': loyalty_program.id,
|
||||
'partner_id': jean_jacques.id,
|
||||
'points': 10,
|
||||
},
|
||||
{
|
||||
'program_id': ewallet_program.id,
|
||||
'partner_id': jean_jacques.id,
|
||||
'points': 0,
|
||||
},
|
||||
])
|
||||
order.write({
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': 1,
|
||||
})]
|
||||
})
|
||||
order._update_programs_and_rewards()
|
||||
claimable_rewards = order._get_claimable_rewards()
|
||||
self.assertEqual(len(claimable_rewards), 1, "The ewallet program should not be applicable since the card has no points.")
|
||||
ewallet_coupon.points = 50
|
||||
order._update_programs_and_rewards()
|
||||
claimable_rewards = order._get_claimable_rewards()
|
||||
self.assertEqual(len(claimable_rewards), 2, "Now that the ewallet has some points they should both be applicable.")
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import Command
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
|
||||
from odoo.tests.common import tagged
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestPayWithGiftCard(TestSaleCouponCommon):
|
||||
|
||||
def test_paying_with_single_gift_card_over(self):
|
||||
self.env['loyalty.generate.wizard'].with_context(active_id=self.program_gift_card.id).create({
|
||||
'coupon_qty': 1,
|
||||
'points_granted': 100,
|
||||
}).generate_coupons()
|
||||
gift_card = self.program_gift_card.coupon_ids[0]
|
||||
order = self.empty_order
|
||||
order.write({'order_line': [
|
||||
Command.create({
|
||||
'product_id': self.product_A.id,
|
||||
'name': 'Ordinary Product A',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
before_gift_card_payment = order.amount_total
|
||||
self.assertNotEqual(before_gift_card_payment, 0)
|
||||
self._apply_promo_code(order, gift_card.code)
|
||||
order.action_confirm()
|
||||
self.assertEqual(before_gift_card_payment - order.amount_total, 100 - gift_card.points)
|
||||
|
||||
def test_paying_with_single_gift_card_under(self):
|
||||
self.env['loyalty.generate.wizard'].with_context(active_id=self.program_gift_card.id).create({
|
||||
'coupon_qty': 1,
|
||||
'points_granted': 100,
|
||||
}).generate_coupons()
|
||||
gift_card = self.program_gift_card.coupon_ids[0]
|
||||
order = self.empty_order
|
||||
order.write({'order_line': [
|
||||
Command.create({
|
||||
'product_id': self.product_B.id,
|
||||
'name': 'Ordinary Product b',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
before_gift_card_payment = order.amount_total
|
||||
self.assertNotEqual(before_gift_card_payment, 0)
|
||||
self._apply_promo_code(order, gift_card.code)
|
||||
order.action_confirm()
|
||||
self.assertEqual(before_gift_card_payment - order.amount_total, 100 - gift_card.points)
|
||||
|
||||
def test_paying_with_multiple_gift_card(self):
|
||||
self.env['loyalty.generate.wizard'].with_context(active_id=self.program_gift_card.id).create({
|
||||
'coupon_qty': 2,
|
||||
'points_granted': 100,
|
||||
}).generate_coupons()
|
||||
gift_card_1, gift_card_2 = self.program_gift_card.coupon_ids
|
||||
order = self.empty_order
|
||||
order.write({'order_line': [
|
||||
Command.create({
|
||||
'product_id': self.product_A.id,
|
||||
'name': 'Ordinary Product A',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 20.0,
|
||||
})
|
||||
]})
|
||||
before_gift_card_payment = order.amount_total
|
||||
self._apply_promo_code(order, gift_card_1.code)
|
||||
self._apply_promo_code(order, gift_card_2.code)
|
||||
self.assertEqual(order.amount_total, before_gift_card_payment - 200)
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSaleCouponMultiCompany(TestSaleCouponCommon):
|
||||
|
||||
def setUp(self):
|
||||
super(TestSaleCouponMultiCompany, self).setUp()
|
||||
|
||||
self.company_a = self.env.company
|
||||
self.company_b = self.env['res.company'].create(dict(name="TEST"))
|
||||
|
||||
self.immediate_promotion_program_c2 = self.env['loyalty.program'].create({
|
||||
'name': 'Buy A + 1 B, 1 B are free',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'company_id': self.company_b.id,
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': self.product_A,
|
||||
'reward_point_amount': 1,
|
||||
'reward_point_mode': 'order',
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': self.product_B.id,
|
||||
'reward_product_qty': 1,
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
|
||||
def _get_applicable_programs(self, order):
|
||||
return self.env['loyalty.program'].browse(p.id for p in order._get_applicable_program_points())
|
||||
|
||||
def test_applicable_programs(self):
|
||||
|
||||
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._update_programs_and_rewards()
|
||||
|
||||
self.assertNotIn(self.immediate_promotion_program_c2, self._get_applicable_programs(order))
|
||||
self.assertNotIn(self.immediate_promotion_program_c2, order._get_applied_programs())
|
||||
|
||||
order_b = self.env["sale.order"].create({
|
||||
'company_id': self.company_b.id,
|
||||
'partner_id': order.partner_id.id,
|
||||
})
|
||||
order_b.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_A.id,
|
||||
'name': '1 Product A',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
}),
|
||||
(0, False, {
|
||||
'product_id': self.product_B.id,
|
||||
'name': '2 Product B',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
self.assertNotIn(self.immediate_promotion_program, self._get_applicable_programs(order_b))
|
||||
order_b._update_programs_and_rewards()
|
||||
self.assertIn(self.immediate_promotion_program_c2, order_b._get_applied_programs())
|
||||
self.assertNotIn(self.immediate_promotion_program, order_b._get_applied_programs())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
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_minimum_purchased_amount(self):
|
||||
# Test case: Based on the minimum purchased
|
||||
|
||||
self.immediate_promotion_program.rule_ids.write({
|
||||
'minimum_amount': 1006,
|
||||
'minimum_amount_tax_mode': 'excl'
|
||||
})
|
||||
|
||||
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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The promo offer 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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
# 10*100 + 5 = 1005
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The promo offer should not be applied as the purchased amount is not enough")
|
||||
|
||||
self.immediate_promotion_program.rule_ids.minimum_amount = 1005
|
||||
order._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The promo offer 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_ids.minimum_amount = 1006
|
||||
self.immediate_promotion_program.rule_ids.minimum_amount_tax_mode = 'incl'
|
||||
order._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The promo offer should be applied as the initial amount required is now tax included")
|
||||
|
||||
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['loyalty.program'].create({
|
||||
'name': 'Get 10% discount if buy at least 4 Product A and $320',
|
||||
'program_type': 'coupons',
|
||||
'applies_on': 'current',
|
||||
'trigger': 'with_code',
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': self.product_A,
|
||||
'minimum_qty': 3,
|
||||
'minimum_amount': 320,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount_mode': 'percent',
|
||||
'discount': 10,
|
||||
'discount_applicability': 'order',
|
||||
})],
|
||||
})
|
||||
|
||||
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['loyalty.generate.wizard'].with_context(active_id=program.id).create({'coupon_qty': 1, 'points_granted': 1}).generate_coupons()
|
||||
coupon = program.coupon_ids[0]
|
||||
|
||||
# Not enough amount since we only have 220 (100*2 + 5*4)
|
||||
with self.assertRaises(ValidationError):
|
||||
self._apply_promo_code(order, coupon.code)
|
||||
|
||||
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(ValidationError):
|
||||
self._apply_promo_code(order, coupon.code)
|
||||
|
||||
sol1.product_uom_qty = 3
|
||||
|
||||
self._apply_promo_code(order, coupon.code)
|
||||
self._claim_reward(order, program, coupon)
|
||||
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The order should contain the Product A line, the Product B line and the discount line")
|
||||
|
||||
sol1.product_uom_qty = 2
|
||||
order._update_programs_and_rewards()
|
||||
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The discount line should have been removed as we don't meet the program requirements")
|
||||
|
||||
def test_program_rules_promotion_use_best(self):
|
||||
''' This test verifies that only the best global discount is applied.
|
||||
'''
|
||||
self.immediate_promotion_program.active = False # Avoid having this program to add rewards on this test
|
||||
order = self.empty_order
|
||||
|
||||
p1 = self.env['loyalty.program'].create({
|
||||
'name': 'Get 5% discount if buy at least 2 Product',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'reward_point_mode': 'order',
|
||||
'minimum_qty': 2,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount': 5,
|
||||
'discount_mode': 'percent',
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
p2 = self.env['loyalty.program'].create({
|
||||
'name': 'Get 10% discount if buy at least 4 Product',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'reward_point_mode': 'order',
|
||||
'minimum_qty': 4,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount': 10,
|
||||
'discount_mode': 'percent',
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
sol = self.env['sale.order.line'].create({
|
||||
'product_id': self.product_A.id,
|
||||
'name': 'Product A',
|
||||
'product_uom_qty': 1.0,
|
||||
'order_id': order.id,
|
||||
})
|
||||
|
||||
order._update_programs_and_rewards()
|
||||
self._claim_reward(order, p1)
|
||||
self._claim_reward(order, p2)
|
||||
self.assertEqual(len(order.order_line.ids), 1, "The order should only contains the Product A line")
|
||||
|
||||
sol.product_uom_qty = 3
|
||||
order._update_programs_and_rewards()
|
||||
self._claim_reward(order, p1)
|
||||
self._claim_reward(order, p2)
|
||||
discounts = set(order.order_line.mapped('name')) - {'Product A'}
|
||||
self.assertEqual(len(discounts), 1, "The order should contains the Product A line and a discount")
|
||||
# The name of the discount is dynamically changed to smth looking like:
|
||||
# "Discount: Get 5% discount if buy at least 2 Product - On product with following tax: Tax 15.00%"
|
||||
self.assertTrue('Discount: 5% on your order' in discounts.pop(), "The discount should be a 5% discount")
|
||||
|
||||
sol.product_uom_qty = 5
|
||||
order._update_programs_and_rewards()
|
||||
self._claim_reward(order, p1)
|
||||
self._claim_reward(order, p2)
|
||||
discounts = set(order.order_line.mapped('name')) - {'Product A'}
|
||||
self.assertEqual(len(discounts), 1, "The order should contains the Product A line and a discount")
|
||||
self.assertTrue('Discount: 10% on your order' in discounts.pop(), "The discount should be a 10% discount")
|
||||
|
||||
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({
|
||||
'date_to': date.today() - timedelta(days=2),
|
||||
'limit_usage': True,
|
||||
'max_usage': 1,
|
||||
})
|
||||
|
||||
order = self.empty_order
|
||||
order.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_A.id,
|
||||
'name': '1 Product A',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
}),
|
||||
(0, False, {
|
||||
'product_id': self.product_B.id,
|
||||
'name': '2 Product B',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
self._auto_rewards(order, self.immediate_promotion_program)
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied we're not between the validity dates")
|
||||
|
||||
self.immediate_promotion_program.write({
|
||||
'date_to': date.today() + 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,
|
||||
})
|
||||
]})
|
||||
self._auto_rewards(order, self.immediate_promotion_program)
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The promo offer should have been applied as we're between the validity dates")
|
||||
order = self.env['sale.order'].create({'partner_id': self.env['res.partner'].create({'name': 'My Partner'}).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,
|
||||
})
|
||||
]})
|
||||
# Invalidate total_order_count
|
||||
field_order_count = self.env['loyalty.program']._fields['order_count']
|
||||
field_total_order_count = self.env['loyalty.program']._fields['total_order_count']
|
||||
self.env.cache.invalidate([(field_order_count, self.immediate_promotion_program.ids), (field_total_order_count, self.immediate_promotion_program.ids)])
|
||||
self._auto_rewards(order, self.immediate_promotion_program)
|
||||
self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied as the number of uses is exceeded")
|
||||
@@ -0,0 +1,333 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
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['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program.id).create({
|
||||
'mode': 'selected',
|
||||
}).generate_coupons()
|
||||
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.immediate_promotion_program.active = False
|
||||
self.code_promotion_program.reward_ids.reward_type = 'discount'
|
||||
self.code_promotion_program.reward_ids.discount = 10
|
||||
|
||||
self.env['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program.id).create({
|
||||
'mode': 'selected',
|
||||
'customer_ids': self.steve,
|
||||
'points_granted': 1,
|
||||
}).generate_coupons()
|
||||
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['res.partner'].create({'name': 'My Partner'}).id,
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
self._apply_promo_code(wrong_partner_order, coupon.code)
|
||||
|
||||
# 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._apply_promo_code(order, coupon.code)
|
||||
self.assertEqual(len(order.order_line.ids), 2)
|
||||
|
||||
# Remove the product A from the sale order
|
||||
order.write({'order_line': [(2, order.order_line[0].id, False)]})
|
||||
order._update_programs_and_rewards()
|
||||
self.assertEqual(len(order.order_line.ids), 0)
|
||||
|
||||
def test_program_coupon_double_consuming(self):
|
||||
# Test case:
|
||||
# - Generate a coupon
|
||||
# - add to a sale order A, cancel the sale order
|
||||
# - add to a sale order B, confirm the order
|
||||
# - go back to A, reset to draft and confirm
|
||||
|
||||
self.immediate_promotion_program.active = False
|
||||
self.code_promotion_program.applies_on = 'future'
|
||||
self.code_promotion_program.reward_ids.reward_type = 'discount'
|
||||
self.code_promotion_program.reward_ids.discount = 10
|
||||
|
||||
self.env['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program.id).create({
|
||||
'coupon_qty': 1,
|
||||
'points_granted': 1,
|
||||
}).generate_coupons()
|
||||
coupon = self.code_promotion_program.coupon_ids
|
||||
|
||||
sale_order_a = self.empty_order.copy()
|
||||
sale_order_b = self.empty_order.copy()
|
||||
|
||||
sale_order_a.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._apply_promo_code(sale_order_a, coupon.code)
|
||||
self.assertEqual(len(sale_order_a.order_line.ids), 2)
|
||||
|
||||
sale_order_a.action_cancel()
|
||||
|
||||
sale_order_b.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._apply_promo_code(sale_order_b, coupon.code)
|
||||
self.assertEqual(len(sale_order_b.order_line.ids), 2)
|
||||
|
||||
sale_order_b.action_confirm()
|
||||
|
||||
sale_order_a.action_draft()
|
||||
sale_order_a.action_confirm()
|
||||
# reward line removed automatically
|
||||
self.assertEqual(len(sale_order_a.order_line.ids), 1)
|
||||
|
||||
def test_coupon_code_with_pricelist(self):
|
||||
# Test case: Generate a coupon (10% discount) and apply it on an order with a specific pricelist (10% discount)
|
||||
|
||||
self.code_promotion_program_with_discount.applies_on = 'future'
|
||||
self.env['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program_with_discount.id).create({
|
||||
'coupon_qty': 1,
|
||||
'points_granted': 1,
|
||||
}).generate_coupons()
|
||||
coupon = self.code_promotion_program_with_discount.coupon_ids
|
||||
|
||||
first_pricelist = self.env['product.pricelist'].create({
|
||||
'name': 'First pricelist',
|
||||
'discount_policy': 'with_discount',
|
||||
'item_ids': [(0, 0, {
|
||||
'compute_price': 'percentage',
|
||||
'base': 'list_price',
|
||||
'percent_price': 10,
|
||||
'applied_on': '3_global',
|
||||
'name': 'First discount'
|
||||
})]
|
||||
})
|
||||
|
||||
order = self.empty_order
|
||||
order.pricelist_id = first_pricelist
|
||||
order.write({'order_line': [
|
||||
(0, False, {
|
||||
'product_id': self.product_C.id,
|
||||
'name': '1 Product C',
|
||||
'product_uom': self.uom_unit.id,
|
||||
'product_uom_qty': 1.0,
|
||||
})
|
||||
]})
|
||||
self._apply_promo_code(order, coupon.code)
|
||||
self.assertEqual(len(order.order_line.ids), 2)
|
||||
self.assertEqual(order.amount_total, 81, "SO total should be 81: (10% of 100 with pricelist) + 10% of 90 with coupon code")
|
||||
|
||||
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)
|
||||
# 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({
|
||||
'applies_on': 'future',
|
||||
'trigger': 'with_code',
|
||||
})
|
||||
self.immediate_promotion_program.rule_ids.write({
|
||||
'mode': 'with_code',
|
||||
'code': 'free_B_on_next_order',
|
||||
})
|
||||
# 2.
|
||||
self.p1 = self.env['loyalty.program'].create({
|
||||
'name': 'Code for 10% on next order',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'future',
|
||||
'trigger': 'auto',
|
||||
'rule_ids': [(0, 0, {})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount': 10,
|
||||
'discount_mode': 'percent',
|
||||
'discount_applicability': '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._update_programs_and_rewards()
|
||||
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(ValidationError):
|
||||
self._apply_promo_code(order, 'free_B_on_next_order')
|
||||
# 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._apply_promo_code(order, 'free_B_on_next_order', no_reward_fail=False)
|
||||
# 6.
|
||||
self.assertEqual(len(order._get_reward_coupons()), 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
|
||||
|
||||
# 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._apply_promo_code(order_bis, order._get_reward_coupons()[1].code)
|
||||
self.assertEqual(len(order_bis.order_line), 2, "You should get a free Product B")
|
||||
# 10.
|
||||
self._apply_promo_code(order_bis, order._get_reward_coupons()[0].code)
|
||||
self.assertEqual(len(order_bis.order_line), 3, "You should get a 10% discount line")
|
||||
self.assertAlmostEqual(order_bis.amount_total, order_bis.order_line[0].price_total * 0.9, 2, "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({
|
||||
'applies_on': 'future',
|
||||
'trigger': 'with_code',
|
||||
})
|
||||
self.immediate_promotion_program.rule_ids.write({
|
||||
'minimum_amount': 700,
|
||||
'minimum_amount_tax_mode': 'excl',
|
||||
'mode': 'with_code',
|
||||
'code': 'free_B_on_next_order',
|
||||
})
|
||||
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._apply_promo_code(order, 'free_B_on_next_order', no_reward_fail=False)
|
||||
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(ValidationError):
|
||||
# It should error since we did not validate the previous SO, so the coupon is `reserved` but not `new`
|
||||
self._apply_promo_code(order_bis, order._get_reward_coupons()[0].code)
|
||||
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._apply_promo_code(order_bis, order._get_reward_coupons()[0].code, no_reward_fail=False)
|
||||
self.assertEqual(len(order_bis.order_line), 2, "You should get 1 regular product_B and 1 free product_B")
|
||||
order_bis._update_programs_and_rewards()
|
||||
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_edit_and_reapply_promotion_program(self):
|
||||
# The flow:
|
||||
# 1. Create a program auto applied, giving a fixed amount discount
|
||||
# 2. Create a SO and apply the program
|
||||
# 3. Change the program, requiring a mandatory code
|
||||
# 4. Reapply the program on the same SO via code
|
||||
|
||||
self.immediate_promotion_program.active = False
|
||||
# 1.
|
||||
self.p1 = self.env['loyalty.program'].create({
|
||||
'name': 'Promo fixed amount',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'rule_ids': [(0, 0, {})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount': 10,
|
||||
'discount_mode': 'per_point',
|
||||
'discount_applicability': 'order',
|
||||
})]
|
||||
})
|
||||
# 2.
|
||||
order = self.empty_order.copy()
|
||||
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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.p1)
|
||||
self.assertEqual(len(order.order_line), 2, "You should get a discount line") # product + discount
|
||||
# 3.
|
||||
self.p1.write({
|
||||
'trigger': 'with_code',
|
||||
})
|
||||
self.p1.rule_ids.write({
|
||||
'mode': 'with_code',
|
||||
'code': 'test',
|
||||
})
|
||||
order._update_programs_and_rewards()
|
||||
self.assertEqual(len(order.order_line), 1, "You loose a discount line")
|
||||
# 4.
|
||||
self._apply_promo_code(order, 'test')
|
||||
# But the above line should not add any reward
|
||||
self.assertEqual(len(order.order_line), 2, "You should get a discount line") # product + discount
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_loyalty.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.rule_ids.write({'minimum_qty': 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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
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 A 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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
self.assertEqual(len(order.order_line.ids), 3, "The promo offer 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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
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._update_programs_and_rewards()
|
||||
self._claim_reward(order, self.immediate_promotion_program)
|
||||
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,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSaleInvoicing(TestSaleCouponCommon):
|
||||
|
||||
def test_invoicing_order_with_promotions(self):
|
||||
discount_coupon_program = self.env['loyalty.program'].create({
|
||||
'name': '10% Discount',
|
||||
'program_type': 'coupons',
|
||||
'applies_on': 'current',
|
||||
'trigger': 'auto',
|
||||
'rule_ids': [(0, 0, {})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount': 10,
|
||||
'discount_mode': 'percent',
|
||||
'discount_applicability': 'order',
|
||||
})]
|
||||
})
|
||||
# Override the default invoice_policy on products
|
||||
discount_coupon_program.reward_ids.discount_line_product_id.invoice_policy = 'order'
|
||||
product = self.env['product.product'].create({
|
||||
'invoice_policy': 'delivery',
|
||||
'name': 'Product invoiced on delivery',
|
||||
'lst_price': 500,
|
||||
})
|
||||
|
||||
order = self.empty_order
|
||||
order.write({
|
||||
'order_line': [
|
||||
(0, 0, {
|
||||
'product_id': product.id,
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
order._update_programs_and_rewards()
|
||||
self._claim_reward(order, discount_coupon_program)
|
||||
# Order is not confirmed, there shouldn't be any invoiceable line
|
||||
invoiceable_lines = order._get_invoiceable_lines()
|
||||
self.assertEqual(len(invoiceable_lines), 0)
|
||||
|
||||
order.action_confirm()
|
||||
invoiceable_lines = order._get_invoiceable_lines()
|
||||
# Product was not delivered, we cannot invoice
|
||||
# the product line nor the promotion line
|
||||
self.assertEqual(len(invoiceable_lines), 0)
|
||||
with self.assertRaises(UserError):
|
||||
order._create_invoices()
|
||||
|
||||
order.order_line[0].qty_delivered = 1
|
||||
# Product is delivered, the two lines can be invoiced.
|
||||
invoiceable_lines = order._get_invoiceable_lines()
|
||||
self.assertEqual(order.order_line, invoiceable_lines)
|
||||
account_move = order._create_invoices()
|
||||
self.assertEqual(len(account_move.invoice_line_ids), 2)
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="loyalty_card_view_form_inherit_sale_loyalty" model="ir.ui.view">
|
||||
<field name="name">loyalty.card.view.form.inherit.sale.loyalty</field>
|
||||
<field name="model">loyalty.card</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_card_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="order_id" readonly="1" attrs="{'invisible': [('order_id', '=', False)]}" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem
|
||||
id="menu_loyalty_config"
|
||||
action="loyalty.loyalty_program_action"
|
||||
parent="sale.product_menu_catalog"
|
||||
name="Coupons & Loyalty"
|
||||
groups="sales_team.group_sale_manager"
|
||||
sequence="5"
|
||||
/>
|
||||
</odoo>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="sale_order_view_form_inherit_sale_loyalty" model="ir.ui.view">
|
||||
<field name="name">sale.order.view.form.inherit.sale.loyalty</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='note_group']" position="before">
|
||||
<div class="oe_right">
|
||||
<button name="%(sale_loyalty.sale_loyalty_coupon_wizard_action)d" class="btn btn-secondary"
|
||||
string="Coupon Code" type="action" groups="base.group_user" states="draft,sent,sale"/>
|
||||
<button name="action_open_reward_wizard" class="btn btn-secondary"
|
||||
string="Promotions" type="object" groups="base.group_user" states="draft,sent,sale"
|
||||
help="Update current promotional lines and select new rewards if applicable."/>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" ?>
|
||||
<odoo>
|
||||
|
||||
<template id="sale_loyalty.used_gift_card">
|
||||
<div class="text-muted d-md-block small" t-if="line.coupon_id">
|
||||
<span>
|
||||
Code:
|
||||
<t t-esc="line.coupon_id.code[-4:].rjust(14, '⋆')"/>
|
||||
</span>
|
||||
<t t-if="line.coupon_id.expiration_date">
|
||||
<br/>
|
||||
<span>
|
||||
Expired Date:
|
||||
<t t-esc="line.coupon_id.expiration_date"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="sale_order_portal_content_inherit" name="Gift Card Products Portal" inherit_id="sale.sale_order_portal_content">
|
||||
<xpath expr="//section[@id='details']//td[@id='product_name']/*[last()]" position="after">
|
||||
<t t-if="line.coupon_id and line.coupon_id.program_id.program_type == 'gift_card'" t-call="sale_loyalty.used_gift_card"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='total']" position="after">
|
||||
<t t-call="sale_loyalty.sale_purchased_gift_card">
|
||||
<t t-set="order" t-value="sale_order"/>
|
||||
<t t-set="hide_intro" t-value="1"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="sale_purchased_gift_card">
|
||||
<t t-set="gift_cards" t-value="order.coupon_point_ids.filtered(lambda pe: pe.coupon_id.program_id.program_type == 'gift_card').coupon_id"/>
|
||||
<div class="card mt-3 " t-if="gift_cards">
|
||||
<div class="card-body">
|
||||
<span t-if="not hide_intro">You will find below your gift cards code. An email has been sent with it. You can use it starting right now.</span>
|
||||
<table class="table text-center table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="font-weight-normal">Gift Card Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="range(len(gift_cards))" t-as="gift_card_idx">
|
||||
<t t-set="gift_card" t-value="gift_cards[gift_card_idx]"/>
|
||||
<td class="o_purchased_gift_card">
|
||||
Gift #<t t-esc="gift_card_idx + 1"/>
|
||||
(<span t-esc="order._get_real_points_for_coupon(gift_card, post_confirm=True)"/> <span t-esc="gift_card.point_name"/>)
|
||||
<strong t-esc="gift_card.code"/>
|
||||
<button class="btn btn-sm btn-secondary copy-to-clipboard" t-att-data-clipboard-text="gift_card.code">
|
||||
<span class="fa fa-clipboard"/> Copy
|
||||
</button>
|
||||
</td>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import sale_loyalty_coupon_wizard
|
||||
from . import sale_loyalty_reward_wizard
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
class SaleLoyaltyCouponWizard(models.TransientModel):
|
||||
_name = 'sale.loyalty.coupon.wizard'
|
||||
_description = 'Sale Loyalty - Apply Coupon Wizard'
|
||||
|
||||
order_id = fields.Many2one('sale.order', default=lambda self: self.env.context.get('active_id'), required=True)
|
||||
|
||||
coupon_code = fields.Char(required=True)
|
||||
|
||||
def action_apply(self):
|
||||
self.ensure_one()
|
||||
if not self.order_id:
|
||||
raise ValidationError(_('Invalid sales order.'))
|
||||
status = self.order_id._try_apply_code(self.coupon_code)
|
||||
if 'error' in status:
|
||||
raise ValidationError(status['error'])
|
||||
all_rewards = self.env['loyalty.reward']
|
||||
for rewards in status.values():
|
||||
all_rewards |= rewards
|
||||
action = self.env['ir.actions.actions']._for_xml_id('sale_loyalty.sale_loyalty_reward_wizard_action')
|
||||
action['context'] = {
|
||||
'active_id': self.order_id.id,
|
||||
'default_reward_ids': all_rewards.ids,
|
||||
}
|
||||
return action
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="sale_loyalty_coupon_wizard_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.loyalty.coupon.wizard.view.form</field>
|
||||
<field name="model">sale.loyalty.coupon.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="coupon_code"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button type="object" name="action_apply" string="Apply" class="btn btn-primary" data-hotkey="q"/>
|
||||
<button special="cancel" string="Discard" class="btn btn-secondary" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_loyalty_coupon_wizard_action" model="ir.actions.act_window">
|
||||
<field name="name">Enter Promotion or Coupon Code</field>
|
||||
<field name="res_model">sale.loyalty.coupon.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- 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 SaleLoyaltyRewardWizard(models.TransientModel):
|
||||
_name = 'sale.loyalty.reward.wizard'
|
||||
_description = 'Sale Loyalty - Reward Selection Wizard'
|
||||
|
||||
order_id = fields.Many2one('sale.order', default=lambda self: self.env.context.get('active_id'), required=True)
|
||||
|
||||
reward_ids = fields.Many2many('loyalty.reward', compute='_compute_claimable_reward_ids')
|
||||
selected_reward_id = fields.Many2one('loyalty.reward', domain="[('id', 'in', reward_ids)]")
|
||||
# In case of multi_product reward
|
||||
multi_product_reward = fields.Boolean(related='selected_reward_id.multi_product')
|
||||
reward_product_ids = fields.Many2many(related='selected_reward_id.reward_product_ids')
|
||||
selected_product_id = fields.Many2one('product.product', domain="[('id', 'in', reward_product_ids)]",
|
||||
compute='_compute_selected_product_id', readonly=False, store=True,)
|
||||
|
||||
@api.depends('order_id')
|
||||
def _compute_claimable_reward_ids(self):
|
||||
for wizard in self:
|
||||
if not wizard.order_id:
|
||||
wizard.reward_ids = False
|
||||
else:
|
||||
claimable_reward = wizard.order_id._get_claimable_rewards()
|
||||
reward_ids = self.env['loyalty.reward']
|
||||
for rewards in claimable_reward.values():
|
||||
reward_ids |= rewards
|
||||
wizard.reward_ids = reward_ids
|
||||
|
||||
@api.depends('reward_product_ids')
|
||||
def _compute_selected_product_id(self):
|
||||
for wizard in self:
|
||||
if not wizard.selected_reward_id.reward_type == 'product':
|
||||
wizard.selected_product_id = False
|
||||
else:
|
||||
wizard.selected_product_id = wizard.reward_product_ids[:1]
|
||||
|
||||
def action_apply(self):
|
||||
self.ensure_one()
|
||||
if not self.selected_reward_id:
|
||||
raise ValidationError(_('No reward selected.'))
|
||||
claimable_rewards = self.order_id._get_claimable_rewards()
|
||||
selected_coupon = False
|
||||
for coupon, rewards in claimable_rewards.items():
|
||||
if self.selected_reward_id in rewards:
|
||||
selected_coupon = coupon
|
||||
break
|
||||
if not selected_coupon:
|
||||
raise ValidationError(_('Coupon not found while trying to add the following reward: %s', self.selected_reward_id.description))
|
||||
self.order_id._apply_program_reward(self.selected_reward_id, coupon, product=self.selected_product_id)
|
||||
return True
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="sale_loyalty_reward_wizard_view_form" model="ir.ui.view">
|
||||
<field name="name">sale.loyalty.reward.wizard.view.form</field>
|
||||
<field name="model">sale.loyalty.reward.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="order_id" invisible="1"/>
|
||||
<field name="reward_ids" invisible="1"/>
|
||||
<field name="multi_product_reward" invisible="1"/>
|
||||
<field name="reward_product_ids" invisible="1"/>
|
||||
<a attrs="{'invisible': [('reward_ids', '!=', [])]}">
|
||||
No rewards available for this customer!
|
||||
</a>
|
||||
<label for="selected_reward_id" string="Choose your reward:" colspan="2"
|
||||
attrs="{'invisible': [('reward_ids', '=', [])]}"/>
|
||||
<field name="selected_reward_id" widget="radio" colspan="2" nolabel="1"
|
||||
attrs="{'invisible': [('reward_ids', '=', [])]}"/>
|
||||
<label for="selected_product_id" string="Choose a product:" colspan="2"
|
||||
attrs="{'invisible': [('multi_product_reward', '=', False)]}"/>
|
||||
<field name="selected_product_id" widget="radio" colspan="2" nolabel="1"
|
||||
attrs="{'invisible': [('multi_product_reward', '=', False)]}"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<!-- Has rewards -->
|
||||
<button type="object" name="action_apply" string="Apply" class="btn btn-primary"
|
||||
attrs="{'invisible': [('reward_ids', '=', [])]}" data-hotkey="q"/>
|
||||
<button special="cancel" string="Discard" class="btn btn-secondary"
|
||||
attrs="{'invisible': [('reward_ids', '=', [])]}" data-hotkey="z"/>
|
||||
<!-- No rewards -->
|
||||
<button special="cancel" string="Discard" class="btn btn-primary"
|
||||
attrs="{'invisible': [('reward_ids', '!=', [])]}" data-hotkey="z"/>
|
||||
<button type="action" name="%(loyalty.loyalty_program_action)d" string="Coupons & Loyalty" class="btn btn-secondary float-right"
|
||||
attrs="{'invisible': [('reward_ids', '!=', [])]}" groups="sales_team.group_sale_manager" data-hotkey="q"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_loyalty_reward_wizard_action" model="ir.actions.act_window">
|
||||
<field name="name">Available Rewards</field>
|
||||
<field name="res_model">sale.loyalty.reward.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user