[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:
William Braeckman
2022-04-08 11:04:43 +02:00
parent e996ff630d
commit 1d92a264a6
33 changed files with 4188 additions and 3 deletions
+1 -1
View File
@@ -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 &amp; coupon programs
</div>
+5
View File
@@ -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
+21
View File
@@ -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>
+9
View File
@@ -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
+972
View File
@@ -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
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_program_salesman Loyalty Program (Salesperson) loyalty.model_loyalty_program sales_team.group_sale_salesman 1 0 0 0
3 access_program_manager Loyalty Program (Manager) loyalty.model_loyalty_program sales_team.group_sale_manager 1 1 1 1
4 access_applicability_salesman Loyalty Rule (Salesperson) loyalty.model_loyalty_rule sales_team.group_sale_salesman 1 0 0 0
5 access_applicability_manager Loyalty Rule (Manager) loyalty.model_loyalty_rule sales_team.group_sale_manager 1 1 1 1
6 access_coupon_salesman Loyalty (Salesperson) loyalty.model_loyalty_card sales_team.group_sale_salesman 1 1 0 0
7 access_coupon_manager Loyalty (Manager) loyalty.model_loyalty_card sales_team.group_sale_manager 1 1 1 0
8 access_reward_salesman Loyalty Reward (Salesperson) loyalty.model_loyalty_reward sales_team.group_sale_salesman 1 0 0 0
9 access_reward_manager Loyalty Reward (Manager) loyalty.model_loyalty_reward sales_team.group_sale_manager 1 1 1 1
10 access_communication_salesman Loyalty Communication (Salesperson) loyalty.model_loyalty_mail sales_team.group_sale_salesman 1 0 0 0
11 access_communication_manager Loyalty Communication (Manager) loyalty.model_loyalty_mail sales_team.group_sale_manager 1 1 1 1
12 access_sale_coupon_apply_code Loyalty Coupon Wizard model_sale_loyalty_coupon_wizard sales_team.group_sale_salesman 1 1 1 0
13 access_sale_coupon_apply_code_line Loyalty Reward Wizard model_sale_loyalty_reward_wizard sales_team.group_sale_salesman 1 1 1 0
14 access_sale_coupon_generate Coupon Generation loyalty.model_loyalty_generate_wizard sales_team.group_sale_salesman 1 1 1 0
15 access_sale_order_coupon_points_manager Coupon Points (Manager) model_sale_order_coupon_points sales_team.group_sale_manager 1 1 1 1
16 access_sale_order_coupon_points_salesman Coupon Points (Salesperson) model_sale_order_coupon_points sales_team.group_sale_salesman 1 0 0 0
+12
View File
@@ -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
+201
View File
@@ -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)
+78
View File
@@ -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 &amp; 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>
+60
View File
@@ -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, '&#8902;')"/>
</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>
+5
View File
@@ -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 &amp; 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>