[IMP] loyalty*: misc loyalty improvements
This commit improves some messages and usability for loyalty and its submodules: - Coupons should not be restricted to the partner assigned to them anymore - Merged "I have a promo code" and "Use a gift card" into a single toggle - Boolean fields have been added to each app to disable them where we don't want them. - Reduced the size of coupon codes This means that we increase the risk of collision which should be handled by the model as well now. Creating a coupon with an existing code will retry the creation (up to 10 times) until the coupon is indeed created. - Make it clearer in the coupon generation wizard that an email will be sent to the customer. - Reintroduce coupon/gift card details on sale order lines in eCommerce which was wrongfully removed. - Remove the "on product with taxes: " message on order lines when the amount is not split between taxes or if the tax message would be empty. - Make it possible for some error messages to be displayed as warning instead of errors - Split program types in two menuitems, one for promotions discount loyalty etc and one for gift cards and ewallet, the latter has simplified views. - Views overall have been reviewed to make it easier to understand how to programs work. New program types have also been added. TaskId-2951413 Part-of: odoo/odoo#99023 Co-authored-by: William Braeckman <wbr@odoo.com>
This commit is contained in:
committed by
William Braeckman
co-authored by
William Braeckman
parent
feef532b41
commit
6927dcda9f
@@ -28,10 +28,14 @@
|
||||
'loyalty/static/src/js/loyalty_control_panel_widget.js',
|
||||
'loyalty/static/src/js/loyalty_list_view.js',
|
||||
'loyalty/static/src/scss/loyalty.scss',
|
||||
'loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js',
|
||||
],
|
||||
'web.assets_qweb': [
|
||||
'loyalty/static/src/xml/loyalty_templates.xml',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'loyalty/static/tests/**/*.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<!-- 10 percent with code -->
|
||||
<record id="10_percent_with_code" model="loyalty.program">
|
||||
<field name="name">Code for 10% on orders</field>
|
||||
<field name="program_type">promotion</field>
|
||||
<field name="program_type">promo_code</field>
|
||||
<field name="trigger">with_code</field>
|
||||
<field name="portal_visible">False</field>
|
||||
<field name="portal_point_name">Discount point(s)</field>
|
||||
</record>
|
||||
|
||||
<record id="10_percent_with_code_rule" model="loyalty.rule">
|
||||
@@ -20,13 +22,14 @@
|
||||
<field name="discount_applicability">order</field>
|
||||
<field name="program_id" ref="loyalty.10_percent_with_code"/>
|
||||
</record>
|
||||
|
||||
|
||||
<!-- 3 cabinet + 1 free -->
|
||||
<record id="3_cabinets_plus_1_free" model="loyalty.program">
|
||||
<field name="name">Buy 3 large cabinets, get one for free</field>
|
||||
<field name="program_type">promotion</field>
|
||||
<field name="program_type">buy_x_get_y</field>
|
||||
<field name="applies_on">current</field>
|
||||
<field name="trigger">auto</field>
|
||||
<field name="portal_visible">False</field>
|
||||
<field name="portal_point_name">Credit(s)</field>
|
||||
</record>
|
||||
|
||||
<record id="3_cabinets_plus_1_free_rule" model="loyalty.rule">
|
||||
@@ -35,7 +38,7 @@
|
||||
<field name="reward_point_amount">1</field>
|
||||
<field name="product_ids" eval="[(4, ref('product.product_product_6'))]"/>
|
||||
<field name="program_id" ref="loyalty.3_cabinets_plus_1_free"/>
|
||||
</record>
|
||||
</record>
|
||||
|
||||
<record id="3_cabinets_plus_1_free_reward" model="loyalty.reward">
|
||||
<field name="reward_type">product</field>
|
||||
@@ -43,12 +46,13 @@
|
||||
<field name="required_points">3</field>
|
||||
<field name="program_id" ref="loyalty.3_cabinets_plus_1_free"/>
|
||||
</record>
|
||||
|
||||
<!-- 10 percent coupons -->
|
||||
<record id="10_percent_coupon" model="loyalty.program">
|
||||
<field name="name">10% Discount</field>
|
||||
<field name="applies_on">future</field>
|
||||
<field name="name">10% Discount Coupons</field>
|
||||
<field name="program_type">coupons</field>
|
||||
<field name="applies_on">current</field>
|
||||
<field name="trigger">with_code</field>
|
||||
<field name="portal_point_name">Coupon points</field>
|
||||
</record>
|
||||
|
||||
<record id="10_percent_coupon_rule" model="loyalty.rule">
|
||||
@@ -68,13 +72,14 @@
|
||||
<field name="mail_template_id" ref="loyalty.mail_template_loyalty_card"/>
|
||||
<field name="program_id" ref="loyalty.10_percent_coupon"/>
|
||||
</record>
|
||||
|
||||
<!-- Gift Cards -->
|
||||
<record id="gift_card_program" model="loyalty.program">
|
||||
<field name="name">Gift Cards</field>
|
||||
<field name="program_type">gift_card</field>
|
||||
<field name="applies_on">future</field>
|
||||
<field name="trigger">auto</field>
|
||||
<field name="portal_visible">True</field>
|
||||
<field name="portal_point_name">$</field>
|
||||
</record>
|
||||
|
||||
<record id="gift_card_program_rule" model="loyalty.rule">
|
||||
@@ -93,5 +98,4 @@
|
||||
<field name="required_points">1</field>
|
||||
<field name="program_id" ref="loyalty.gift_card_program"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -17,18 +17,19 @@ class LoyaltyCard(models.Model):
|
||||
"""
|
||||
Barcode identifiable codes.
|
||||
"""
|
||||
return '044' + str(uuid4())[4:-8][3:]
|
||||
return '044' + str(uuid4())[7:-18]
|
||||
|
||||
def name_get(self):
|
||||
return [(card.id, f'{card.program_id.name}: {card.code}') for card in self]
|
||||
|
||||
program_id = fields.Many2one('loyalty.program', ondelete='restrict')
|
||||
program_id = fields.Many2one('loyalty.program', ondelete='restrict', default=lambda self: self.env.context.get('active_id', None))
|
||||
company_id = fields.Many2one(related='program_id.company_id', store=True)
|
||||
currency_id = fields.Many2one(related='program_id.currency_id')
|
||||
# Reserved for this partner if non-empty
|
||||
partner_id = fields.Many2one('res.partner', index=True)
|
||||
points = fields.Float(tracking=True)
|
||||
point_name = fields.Char(related='program_id.portal_point_name', readonly=True)
|
||||
points_display = fields.Char(compute='_compute_points_display')
|
||||
|
||||
code = fields.Char(default=lambda self: self._generate_code(), required=True, readonly=True, index=True)
|
||||
expiration_date = fields.Date()
|
||||
@@ -45,6 +46,11 @@ class LoyaltyCard(models.Model):
|
||||
if self.env['loyalty.rule'].search_count([('mode', '=', 'with_code'), ('code', 'in', self.mapped('code'))]):
|
||||
raise ValidationError(_('A trigger with the same code as one of your coupon already exists.'))
|
||||
|
||||
@api.depends('points', 'point_name')
|
||||
def _compute_points_display(self):
|
||||
for card in self:
|
||||
card.points_display = "%.2f %s" % (card.points or 0, card.point_name or '')
|
||||
|
||||
# Meant to be overriden
|
||||
def _compute_use_count(self):
|
||||
self.use_count = 0
|
||||
|
||||
@@ -24,21 +24,31 @@ class LoyaltyProgram(models.Model):
|
||||
|
||||
total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
|
||||
|
||||
rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Triggers', copy=True,
|
||||
rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Conditional rules', copy=True,
|
||||
compute='_compute_from_program_type', readonly=False, store=True)
|
||||
reward_ids = fields.One2many('loyalty.reward', 'program_id', 'Rewards', copy=True,
|
||||
compute='_compute_from_program_type', readonly=False, store=True)
|
||||
communication_plan_ids = fields.One2many('loyalty.mail', 'program_id', copy=True,
|
||||
compute='_compute_from_program_type', readonly=False, store=True)
|
||||
|
||||
# These fields are used for the simplified view of gift_card and ewallet
|
||||
mail_template_id = fields.Many2one('mail.template', compute='_compute_mail_template_id', inverse='_inverse_mail_template_id', string="Email template", readonly=False)
|
||||
trigger_product_ids = fields.Many2many(related='rule_ids.product_ids', readonly=False)
|
||||
|
||||
coupon_ids = fields.One2many('loyalty.card', 'program_id')
|
||||
coupon_count = fields.Integer(compute='_compute_coupon_count')
|
||||
coupon_count_display = fields.Char(compute='_compute_coupon_count_display', string="Items")
|
||||
|
||||
program_type = fields.Selection([
|
||||
('coupons', 'Coupons'),
|
||||
('gift_card', 'Gift Card'),
|
||||
('loyalty', 'Loyalty Cards'),
|
||||
('promotion', 'Promotions'),
|
||||
('ewallet', 'eWallet')], default='promotion', required=True,
|
||||
('ewallet', 'eWallet'),
|
||||
('promo_code', 'Discount Code'),
|
||||
('buy_x_get_y', 'Buy X Get Y'),
|
||||
('next_order_coupons', 'Next Order Coupons')],
|
||||
default='promotion', required=True,
|
||||
)
|
||||
date_to = fields.Date(string='Validity')
|
||||
limit_usage = fields.Boolean(string='Limit Usage')
|
||||
@@ -67,9 +77,16 @@ class LoyaltyProgram(models.Model):
|
||||
Show in web portal, PoS customer ticket, eCommerce checkout, the number of points available and used by reward.
|
||||
""")
|
||||
portal_point_name = fields.Char(default='Points', translate=True,
|
||||
compute='_compute_from_program_type', readonly=False, store=True)
|
||||
compute='_compute_portal_point_name', readonly=False, store=True)
|
||||
is_nominative = fields.Boolean(compute='_compute_is_nominative')
|
||||
|
||||
# Technical field used for a label
|
||||
available_on = fields.Boolean("Available On", store=False,
|
||||
help="""
|
||||
Manage where your program should be available for use.
|
||||
"""
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('check_max_usage', 'CHECK (limit_usage = False OR max_usage > 0)',
|
||||
'Max usage must be strictly positive if a limit is used.'),
|
||||
@@ -77,12 +94,43 @@ class LoyaltyProgram(models.Model):
|
||||
|
||||
@api.constrains('reward_ids')
|
||||
def _constrains_reward_ids(self):
|
||||
if self.env.context.get('loyalty_skip_reward_check'):
|
||||
return
|
||||
if any(not program.reward_ids for program in self):
|
||||
raise ValidationError(_('A program must have at least one reward.'))
|
||||
|
||||
def _compute_total_order_count(self):
|
||||
self.total_order_count = 0
|
||||
|
||||
@api.depends('coupon_count', 'program_type')
|
||||
def _compute_coupon_count_display(self):
|
||||
program_items_name = self._program_items_name()
|
||||
for program in self:
|
||||
program.coupon_count_display = "%i %s" % (program.coupon_count or 0, program_items_name[program.program_type] or '')
|
||||
|
||||
@api.depends("communication_plan_ids.mail_template_id")
|
||||
def _compute_mail_template_id(self):
|
||||
for program in self:
|
||||
program.mail_template_id = program.communication_plan_ids.mail_template_id[:1]
|
||||
|
||||
def _inverse_mail_template_id(self):
|
||||
for program in self:
|
||||
if program.program_type not in ("gift_card", "ewallet"):
|
||||
continue
|
||||
if not program.mail_template_id:
|
||||
program.communication_plan_ids = [(5, 0, 0)]
|
||||
elif not program.communication_plan_ids:
|
||||
program.communication_plan_ids = self.env['loyalty.mail'].create({
|
||||
'program_id': program.id,
|
||||
'trigger': 'create',
|
||||
'mail_template_id': program.mail_template_id.id,
|
||||
})
|
||||
else:
|
||||
program.communication_plan_ids.write({
|
||||
'trigger': 'create',
|
||||
'mail_template_id': program.mail_template_id.id,
|
||||
})
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_currency_id(self):
|
||||
for program in self:
|
||||
@@ -101,17 +149,30 @@ class LoyaltyProgram(models.Model):
|
||||
program.is_nominative = program.applies_on == 'both' or\
|
||||
(program.program_type == 'ewallet' and program.applies_on == 'future')
|
||||
|
||||
@api.model
|
||||
def _program_items_name(self):
|
||||
return {
|
||||
'coupons': _('Coupons'),
|
||||
'promotion': _('Promos'),
|
||||
'gift_card': _('Gift Cards'),
|
||||
'loyalty': _('Loyalty Cards'),
|
||||
'ewallet': _('eWallets'),
|
||||
'promo_code': _('Discounts'),
|
||||
'buy_x_get_y': _('Promos'),
|
||||
'next_order_coupons': _('Coupons'),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _program_type_default_values(self):
|
||||
# All values to change when program_type changes
|
||||
# NOTE: any field used in `rule_ids`, `reward_ids` and `communication_plan_ids` MUST be present in the kanban view for it to work properly.
|
||||
first_sale_product = self.env['product.product'].search([('sale_ok', '=', True)], limit=1)
|
||||
return {
|
||||
'coupons': {
|
||||
'applies_on': 'current',
|
||||
'trigger': 'with_code',
|
||||
'portal_visible': False,
|
||||
'portal_point_name': _('Points'),
|
||||
# Coupons don't use rules by default
|
||||
'portal_point_name': _('Coupon point(s)'),
|
||||
'rule_ids': [(5, 0, 0)],
|
||||
'reward_ids': [(5, 0, 0), (0, 0, {
|
||||
'required_points': 1,
|
||||
@@ -126,7 +187,7 @@ class LoyaltyProgram(models.Model):
|
||||
'applies_on': 'current',
|
||||
'trigger': 'auto',
|
||||
'portal_visible': False,
|
||||
'portal_point_name': _('Points'),
|
||||
'portal_point_name': _('Promo point(s)'),
|
||||
'rule_ids': [(5, 0, 0), (0, 0, {
|
||||
'reward_point_amount': 1,
|
||||
'reward_point_mode': 'order',
|
||||
@@ -155,7 +216,7 @@ class LoyaltyProgram(models.Model):
|
||||
'discount': 1,
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
'description': _('Pay With Gift Card'),
|
||||
'description': _('Gift Card'),
|
||||
})],
|
||||
'communication_plan_ids': [(5, 0, 0), (0, 0, {
|
||||
'trigger': 'create',
|
||||
@@ -166,7 +227,7 @@ class LoyaltyProgram(models.Model):
|
||||
'applies_on': 'both',
|
||||
'trigger': 'auto',
|
||||
'portal_visible': True,
|
||||
'portal_point_name': _('Loyalty Points'),
|
||||
'portal_point_name': _('Loyalty point(s)'),
|
||||
'rule_ids': [(5, 0, 0), (0, 0, {
|
||||
'reward_point_mode': 'money',
|
||||
})],
|
||||
@@ -177,8 +238,8 @@ class LoyaltyProgram(models.Model):
|
||||
'communication_plan_ids': [(5, 0, 0)],
|
||||
},
|
||||
'ewallet': {
|
||||
'applies_on': 'future',
|
||||
'trigger': 'auto',
|
||||
'applies_on': 'future',
|
||||
'portal_visible': True,
|
||||
'portal_point_name': self.env.company.currency_id.symbol,
|
||||
'rule_ids': [(5, 0, 0), (0, 0, {
|
||||
@@ -192,9 +253,62 @@ class LoyaltyProgram(models.Model):
|
||||
'discount': 1,
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
'description': _('eWallet'),
|
||||
})],
|
||||
'communication_plan_ids': [(5, 0, 0)],
|
||||
},
|
||||
'promo_code': {
|
||||
'applies_on': 'current',
|
||||
'trigger': 'with_code',
|
||||
'portal_visible': False,
|
||||
'portal_point_name': _('Discount point(s)'),
|
||||
'rule_ids': [(5, 0, 0), (0, 0, {
|
||||
'mode': 'with_code',
|
||||
'code': 'PROMO_CODE_' + str(uuid4())[:4], # We should try not to trigger any unicity constraint
|
||||
})],
|
||||
'reward_ids': [(5, 0, 0), (0, 0, {
|
||||
'discount_applicability': 'specific',
|
||||
'discount_product_ids': first_sale_product,
|
||||
'discount_mode': 'percent',
|
||||
'discount': 10,
|
||||
})],
|
||||
'communication_plan_ids': [(5, 0, 0)],
|
||||
},
|
||||
'buy_x_get_y': {
|
||||
'applies_on': 'current',
|
||||
'trigger': 'auto',
|
||||
'portal_visible': False,
|
||||
'portal_point_name': _('Credit(s)'),
|
||||
'rule_ids': [(5, 0, 0), (0, 0, {
|
||||
'reward_point_mode': 'unit',
|
||||
'product_ids': first_sale_product,
|
||||
})],
|
||||
'reward_ids': [(5, 0, 0), (0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': first_sale_product.id,
|
||||
'required_points': 2,
|
||||
})],
|
||||
'communication_plan_ids': [(5, 0, 0)],
|
||||
},
|
||||
'next_order_coupons': {
|
||||
'applies_on': 'future',
|
||||
'trigger': 'auto',
|
||||
'portal_visible': True,
|
||||
'portal_point_name': _('Coupon point(s)'),
|
||||
'rule_ids': [(5, 0, 0), (0, 0, {
|
||||
'minimum_amount': 100,
|
||||
})],
|
||||
'reward_ids': [(5, 0, 0), (0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount_mode': 'percent',
|
||||
'discount': 15,
|
||||
'discount_applicability': 'order',
|
||||
})],
|
||||
'communication_plan_ids': [(5, 0, 0), (0, 0, {
|
||||
'trigger': 'create',
|
||||
'mail_template_id': (self.env.ref('loyalty.mail_template_gift_card', raise_if_not_found=False) or self.env['mail.template']).id,
|
||||
})],
|
||||
},
|
||||
}
|
||||
|
||||
@api.depends('program_type')
|
||||
@@ -207,6 +321,13 @@ class LoyaltyProgram(models.Model):
|
||||
if program_type in program_type_defaults:
|
||||
programs.write(program_type_defaults[program_type])
|
||||
|
||||
@api.depends("currency_id", "program_type")
|
||||
def _compute_portal_point_name(self):
|
||||
for program in self:
|
||||
if program.program_type not in ('ewallet', 'gift_card'):
|
||||
continue
|
||||
program.portal_point_name = program.currency_id.symbol or ''
|
||||
|
||||
def _get_valid_products(self, products):
|
||||
'''
|
||||
Returns a dict containing the products that match per rule of the program
|
||||
@@ -220,6 +341,13 @@ class LoyaltyProgram(models.Model):
|
||||
rule_products[rule] = products
|
||||
return rule_products
|
||||
|
||||
def action_open_loyalty_cards(self):
|
||||
self.ensure_one()
|
||||
action = self.env['ir.actions.act_window']._for_xml_id("loyalty.loyalty_card_action")
|
||||
action['name'] = self._program_items_name()[self.program_type]
|
||||
action['display_name'] = action['name']
|
||||
return action
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_active(self):
|
||||
if any(program.active for program in self):
|
||||
@@ -234,22 +362,39 @@ class LoyaltyProgram(models.Model):
|
||||
program.communication_plan_ids.active = program.active
|
||||
program.reward_ids.discount_line_product_id.active = program.active
|
||||
|
||||
def write(self, vals):
|
||||
# There is an issue when we change the program type, since we clear the rewards and create new ones.
|
||||
# The orm actually does it in this order upon writing, triggering the constraint before creating the new rewards.
|
||||
# However we can check that the result of reward_ids would actually be empty or not, and if not, skip the constraint.
|
||||
if 'reward_ids' in vals and self._fields['reward_ids'].convert_to_cache(vals['reward_ids'], self):
|
||||
self = self.with_context(loyalty_skip_reward_check=True)
|
||||
return super().write(vals)
|
||||
|
||||
@api.model
|
||||
def get_program_templates(self):
|
||||
'''
|
||||
Returns the templates to be used for promotional programs.
|
||||
'''
|
||||
ctx_menu_type = self.env.context.get('menu_type')
|
||||
if ctx_menu_type == 'gift_ewallet':
|
||||
return {
|
||||
'gift_card': {
|
||||
'title': _('Gift Card'),
|
||||
'description': _('Sell Gift Cards, that can be used to purchase products'),
|
||||
'icon': 'gift_card',
|
||||
},
|
||||
'ewallet': {
|
||||
'title': _('eWallet'),
|
||||
'description': _('Fill in your eWallet, and use it to pay future orders'),
|
||||
'icon': 'ewallet',
|
||||
},
|
||||
}
|
||||
return {
|
||||
'promo_code': {
|
||||
'title': _('Promo Code'),
|
||||
'description': _('Get a code to receive 10% discount on specific products'),
|
||||
'icon': 'promo_code',
|
||||
},
|
||||
'gift_card': {
|
||||
'title': _('Gift Card'),
|
||||
'description': _('Sell Gift Cards, that can be used to purchase products'),
|
||||
'icon': 'gift_card',
|
||||
},
|
||||
'loyalty': {
|
||||
'title': _('Loyalty Cards'),
|
||||
'description': _('Win points with each purchases, and use points to get gifts'),
|
||||
@@ -270,15 +415,15 @@ class LoyaltyProgram(models.Model):
|
||||
'description': _('Send unique coupons that give access to rewards'),
|
||||
'icon': 'coupons',
|
||||
},
|
||||
'buy_two_get_one': {
|
||||
'buy_x_get_y': {
|
||||
'title': _('2+1 Free'),
|
||||
'description': _('Buy 2 products and get a third one for free'),
|
||||
'icon': '2_plus_1',
|
||||
},
|
||||
'ewallet': {
|
||||
'title': _('eWallet'),
|
||||
'description': _('Fill in your eWallet, and use it to pay future orders'),
|
||||
'icon': 'ewallet',
|
||||
'next_order_coupons': {
|
||||
'title': _('Next Order Coupons'),
|
||||
'description': _('Send unique, single-use coupon code for the next purchase'),
|
||||
'icon': 'coupons',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -293,7 +438,11 @@ class LoyaltyProgram(models.Model):
|
||||
if template_id not in template_values:
|
||||
return False
|
||||
program = self.create(template_values[template_id])
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_action')
|
||||
action = {}
|
||||
if self.env.context.get('menu_type') == 'gift_ewallet':
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_discount_loyalty_action')
|
||||
else:
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_gift_ewallet_action')
|
||||
action['views'] = [[False, 'form']]
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = program.id
|
||||
@@ -334,20 +483,19 @@ class LoyaltyProgram(models.Model):
|
||||
**program_type_defaults['promotion'],
|
||||
},
|
||||
'promo_code': {
|
||||
'name': _('Promo Code'),
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'trigger': 'with_code',
|
||||
'rule_ids': [(0, 0, {
|
||||
'mode': 'with_code',
|
||||
'code': '10PERCENT_' + str(uuid4())[:4], # Require a random code in case a user creates multiple program using this template
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'discount_applicability': 'specific',
|
||||
'discount_product_ids': product,
|
||||
'discount_mode': 'percent',
|
||||
'discount': 10,
|
||||
})]
|
||||
'name': _('Discount code'),
|
||||
'program_type': 'promo_code',
|
||||
**program_type_defaults['promo_code'],
|
||||
},
|
||||
'buy_x_get_y': {
|
||||
'name': _('2+1 Free'),
|
||||
'program_type': 'buy_x_get_y',
|
||||
**program_type_defaults['buy_x_get_y'],
|
||||
},
|
||||
'next_order_coupons': {
|
||||
'name': _('Next Order Coupons'),
|
||||
'program_type': 'next_order_coupons',
|
||||
**program_type_defaults['next_order_coupons'],
|
||||
},
|
||||
'fidelity': {
|
||||
'name': _('Fidelity Cards'),
|
||||
@@ -366,19 +514,4 @@ class LoyaltyProgram(models.Model):
|
||||
'discount': 10,
|
||||
})]
|
||||
},
|
||||
'buy_two_get_one': {
|
||||
'name': _('2+1 Free'),
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'trigger': 'auto',
|
||||
'rule_ids': [(0, 0, {
|
||||
'reward_point_mode': 'unit',
|
||||
'product_ids': product,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': product and product.id or False,
|
||||
'required_points': 2,
|
||||
})]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class LoyaltyReward(models.Model):
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
|
||||
program_type = fields.Selection(related="program_id.program_type")
|
||||
# Stored for security rules
|
||||
company_id = fields.Many2one(related='program_id.company_id', store=True)
|
||||
currency_id = fields.Many2one(related='program_id.currency_id')
|
||||
@@ -38,6 +39,7 @@ class LoyaltyReward(models.Model):
|
||||
('discount', 'Discount')],
|
||||
default='discount', required=True,
|
||||
)
|
||||
user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
|
||||
|
||||
# Discount rewards
|
||||
discount = fields.Float('Discount', default=10)
|
||||
@@ -116,9 +118,15 @@ class LoyaltyReward(models.Model):
|
||||
def _compute_description(self):
|
||||
for reward in self:
|
||||
reward_string = ""
|
||||
if reward.reward_type == 'product':
|
||||
if reward.program_type == 'gift_card':
|
||||
reward_string = _("Gift Card")
|
||||
elif reward.program_type == 'ewallet':
|
||||
reward_string = _("eWallet")
|
||||
elif reward.reward_type == 'product':
|
||||
products = reward.reward_product_ids
|
||||
if len(products) == 1:
|
||||
if len(products) == 0:
|
||||
reward_string = _('Free Product')
|
||||
elif len(products) == 1:
|
||||
reward_string = _('Free Product - %s', reward.reward_product_id.name)
|
||||
else:
|
||||
reward_string = _('Free Product - [%s]', ', '.join(products.mapped('name')))
|
||||
@@ -157,6 +165,11 @@ class LoyaltyReward(models.Model):
|
||||
reward.discount_applicability == 'order' and\
|
||||
reward.discount_mode == 'percent'
|
||||
|
||||
@api.depends_context('uid')
|
||||
@api.depends("reward_type")
|
||||
def _compute_user_has_debug(self):
|
||||
self.user_has_debug = self.user_has_groups('base.group_no_one')
|
||||
|
||||
def _create_missing_discount_line_products(self):
|
||||
# Make sure we create the product that will be used for our discounts
|
||||
rewards = self.filtered(lambda r: not r.discount_line_product_id)
|
||||
|
||||
@@ -23,11 +23,13 @@ class LoyaltyRule(models.Model):
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
|
||||
program_type = fields.Selection(related="program_id.program_type")
|
||||
# Stored for security rules
|
||||
company_id = fields.Many2one(related='program_id.company_id', store=True)
|
||||
currency_id = fields.Many2one(related='program_id.currency_id')
|
||||
|
||||
# Only for dev mode
|
||||
user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
|
||||
product_domain = fields.Char(default="[]")
|
||||
|
||||
product_ids = fields.Many2many('product.product', string='Products')
|
||||
@@ -51,8 +53,8 @@ class LoyaltyRule(models.Model):
|
||||
mode = fields.Selection([
|
||||
('auto', 'Automatic'),
|
||||
('with_code', 'With a promotion code'),
|
||||
], string="Application", default="auto")
|
||||
code = fields.Char(string='Promotion Code', compute='_compute_code', store=True, readonly=False)
|
||||
], string="Application", compute='_compute_mode', store=True, readonly=False)
|
||||
code = fields.Char(string='Discount code', compute='_compute_code', store=True, readonly=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('reward_point_amount_positive', 'CHECK (reward_point_amount > 0)', 'Rule points reward must be strictly positive.'),
|
||||
@@ -84,6 +86,19 @@ class LoyaltyRule(models.Model):
|
||||
if rule.mode == 'auto':
|
||||
rule.code = False
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_mode(self):
|
||||
for rule in self:
|
||||
if rule.code:
|
||||
rule.mode = 'with_code'
|
||||
else:
|
||||
rule.mode = 'auto'
|
||||
|
||||
@api.depends_context('uid')
|
||||
@api.depends("mode")
|
||||
def _compute_user_has_debug(self):
|
||||
self.user_has_debug = self.user_has_groups('base.group_no_one')
|
||||
|
||||
def _get_valid_product_domain(self):
|
||||
self.ensure_one()
|
||||
domain = []
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { SelectionField } from "@web/views/fields/selection/selection_field";
|
||||
|
||||
/**
|
||||
* The purpose of this field is to be able to define some values which should not be
|
||||
* displayed on our selection field, this way we can have multiple views for the same model
|
||||
* that uses different possible sets of values on the same selection field.
|
||||
*/
|
||||
export class FilterableSelectionField extends SelectionField {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get options() {
|
||||
let options = super.options;
|
||||
if (this.props.whitelisted_values) {
|
||||
options = options.filter((option) => {
|
||||
return option[0] === this.props.value || this.props.whitelisted_values.includes(option[0])
|
||||
});
|
||||
} else if (this.props.blacklisted_values) {
|
||||
options = options.filter((option) => {
|
||||
return option[0] === this.props.value || !this.props.blacklisted_values.includes(option[0]);
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
};
|
||||
|
||||
FilterableSelectionField.props = {
|
||||
...SelectionField.props,
|
||||
whitelisted_values: { type: Array, optional: true },
|
||||
blacklisted_values: { type: Array, optional: true },
|
||||
};
|
||||
|
||||
FilterableSelectionField.extractProps = ({ attrs }) => {
|
||||
return {
|
||||
...SelectionField.extractProps({ attrs }),
|
||||
whitelisted_values: attrs.options.whitelisted_values,
|
||||
blacklisted_values: attrs.options.blacklisted_values,
|
||||
};
|
||||
};
|
||||
|
||||
registry.category("fields").add("filterable_selection", FilterableSelectionField);
|
||||
@@ -16,6 +16,10 @@ export class LoyaltyActionHelper extends Component {
|
||||
this.loyaltyTemplateData = await this.orm.call(
|
||||
"loyalty.program",
|
||||
"get_program_templates",
|
||||
[],
|
||||
{
|
||||
context: this.env.model.root.context,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -36,7 +40,10 @@ LoyaltyActionHelper.template = "loyalty.LoyaltyActionHelper";
|
||||
|
||||
export class LoyaltyListRenderer extends ListRenderer {};
|
||||
LoyaltyListRenderer.template = "loyalty.LoyaltyListRenderer";
|
||||
LoyaltyListRenderer.components.LoyaltyActionHelper = LoyaltyActionHelper;
|
||||
LoyaltyListRenderer.components = {
|
||||
...LoyaltyListRenderer.components,
|
||||
LoyaltyActionHelper,
|
||||
};
|
||||
|
||||
export const LoyaltyListView = {
|
||||
...listView,
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
|
||||
.o_kanban_renderer {
|
||||
padding: 0px !important;
|
||||
|
||||
.o_kanban_record {
|
||||
margin-right: 0px;
|
||||
margin-left: 0px;
|
||||
|
||||
.o_kanban_tags .o_tag span {
|
||||
// Remove the small ball before the tags
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
background-color: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_kanban_record .o_kanban_tags .o_tag span {
|
||||
// Remove the small ball before the tags
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
background-color: none !important;
|
||||
.o_loyalty_kanban_card_right {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +63,9 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.loyalty-program-list-view {
|
||||
.o_view_nocontent {
|
||||
top: 10% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<t t-out="props.noContentHelp"/>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="row loyalty-templates-container">
|
||||
<div class="row justify-content-center loyalty-templates-container">
|
||||
<t t-foreach="Object.entries(loyaltyTemplateData)" t-as="data" t-key="data[0]">
|
||||
<t t-set="loyalty_el_icon" t-value="data[1].icon"/>
|
||||
<t t-set="loyalty_el_title" t-value="data[1].title"/>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { click, editSelect, getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
// Note: the containsN always check for one more as there will be an invisible empty option every time.
|
||||
QUnit.module("Fields", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
program: {
|
||||
fields: {
|
||||
program_type: {
|
||||
type: "selection",
|
||||
selection: [
|
||||
["coupon", "Coupons"],
|
||||
["promotion", "Promotion"],
|
||||
["gift_card", "gift_card"],
|
||||
],
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
records: [
|
||||
{ id: 1, program_type: "coupon" },
|
||||
{ id: 2, program_type: "gift_card" },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("Loyalty > FilterableSelectionField");
|
||||
|
||||
QUnit.test("FilterableSelectionField test whitelist", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "program",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="program_type" widget="filterable_selection" options="{'whitelisted_values': ['coupon', 'promotion']}"/>
|
||||
</form>`,
|
||||
});
|
||||
await click(target, ".o_form_button_edit");
|
||||
|
||||
assert.containsN(target, "select option", 3);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("FilterableSelectionField test blacklist", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "program",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="program_type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
|
||||
</form>`,
|
||||
});
|
||||
await click(target, ".o_form_button_edit");
|
||||
|
||||
assert.containsN(target, "select option", 3);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("FilterableSelectionField test with invalid value", async (assert) => {
|
||||
// The field should still display the current value in the list
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "program",
|
||||
resId: 2,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="program_type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
|
||||
</form>`,
|
||||
});
|
||||
await click(target, ".o_form_button_edit");
|
||||
|
||||
assert.containsN(target, "select option", 4);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"gift_card\"']",
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
|
||||
);
|
||||
|
||||
await editSelect(target, ".o_field_widget[name='program_type'] select", '"coupon"');
|
||||
assert.containsN(target, "select option", 3);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,16 @@
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="expiration_date"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="points"/>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="expiration_date"/>
|
||||
<field name="partner_id"/>
|
||||
<label string="Balance" for="points"/>
|
||||
<span class="d-inline-block">
|
||||
<field name="points" class="w-auto oe_inline me-1"/>
|
||||
<field name="point_name" no_label="1" class="d-inline"/>
|
||||
</span>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
@@ -26,7 +32,8 @@
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Coupons" create="false" edit="false" delete="false">
|
||||
<field name="code"/>
|
||||
<field name="points" string="Balance"/>
|
||||
<field name="create_date" optional="hide"/>
|
||||
<field name="points_display" string="Balance"/>
|
||||
<field name="expiration_date"/>
|
||||
<field name="program_id"/>
|
||||
<field name="partner_id"/>
|
||||
@@ -35,6 +42,17 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="loyalty_card_view_search" model="ir.ui.view">
|
||||
<field name="name">loyalty.card.view.search</field>
|
||||
<field name="model">loyalty.card</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<filter name="active" string="Active" domain="['&', ('points', '>', 0), '|', ('expiration_date', '>=', context_today().strftime('%Y-%m-%d 00:00:00')), ('expiration_date', '=', False)]"/>
|
||||
<filter name="inactive" string="Inactive" domain="['|', ('points', '<=', 0), ('expiration_date', '<', context_today().strftime('%Y-%m-%d 23:59:59'))]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="loyalty_card_action" model="ir.actions.act_window">
|
||||
<field name="name">Coupons</field>
|
||||
<field name="res_model">loyalty.card</field>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- DISCOUNT & LOYALTY -->
|
||||
<record id="loyalty_program_view_form" model="ir.ui.view">
|
||||
<field name="name">loyalty.program.view.form</field>
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Coupons & Loyalty">
|
||||
<form string="Discount & Loyalty">
|
||||
<header>
|
||||
<button name="%(loyalty_generate_wizard_action)d" string="Generate Coupons" class="btn-primary" type="action"
|
||||
attrs="{'invisible': [('program_type', '!=', 'coupons')]}"/>
|
||||
@@ -14,17 +16,19 @@
|
||||
attrs="{'invisible': [('program_type', '!=', 'ewallet')]}" context="{'default_mode': 'selected'}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button class="oe_stat_button" type="action" name="%(loyalty_card_action)d" icon="fa-tags">
|
||||
<button class="oe_stat_button" type="object" name="action_open_loyalty_cards" icon="fa-tags">
|
||||
<div class="o_form_field o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="coupon_count"/>
|
||||
</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">Coupons</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', 'not in', ('coupons', 'next_order_coupons'))]}">Coupons</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'loyalty')]}">Loyalty Cards</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'buy_x_get_y'))]}">Promos</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'promo_code')]}">Discount</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}">Gift Cards</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}">eWallets</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'loyalty')]}">Loyalty Cards</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'promotion')]}">Promos</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -33,69 +37,88 @@
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Program Name"/>
|
||||
<h1>
|
||||
<field name="name" class="text-break" placeholder="e.g. 10% discount on laptops"/>
|
||||
<field name="name" placeholder="e.g. 10% discount on laptops"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<label for="program_type"/>
|
||||
<div>
|
||||
<field name="program_type" widget="selection"/>
|
||||
<field name="program_type" widget="filterable_selection" attrs="{'readonly': [('coupon_count', '!=', 0)]}" options="{'blacklisted_values': ['gift_card', 'ewallet']}"/>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'coupons')]}" colspan="2">
|
||||
Generate & share coupon code manually.
|
||||
<br/>
|
||||
Rewards are provided when the user provides a coupon code in the eCommerce, Point of Sale or regular orders.
|
||||
<br/>
|
||||
Add triggers for constraints on coupon usage.
|
||||
Generate & share coupon codes manually. It can be used in eCommerce, Point of Sale or regular orders to claim the Reward. You can define constraints on its usage through conditional rule.
|
||||
<div groups="base.group_no_one">
|
||||
When generating coupon, you can define a specific points value that can be exchanged for rewards.
|
||||
</div>
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'loyalty')]}" colspan="2">
|
||||
Once a sale order is validated, the customers get Loyalty Points that can be used in the current order, or accumulated in future orders.
|
||||
When customers make an order, they accumulate points they can exchange for rewards on the current order or on a future one.
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'promotion')]}" colspan="2">
|
||||
Set up conditional rules on the order that will give access to rewards for customers
|
||||
<div groups="base.group_no_one">
|
||||
Each rule can grant points to the customer he will be able to exchange against rewards
|
||||
</div>
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'promo_code')]}" colspan="2">
|
||||
Define Discount codes on conditional rules then share it with your customers for rewards.
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'buy_x_get_y')]}" colspan="2">
|
||||
Grant 1 credit for each item bought then reward the customer with Y items in exchange of X credits.
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'next_order_coupons')]}" colspan="2">
|
||||
Drive repeat purchases by sending a unique, single-use coupon code for the next purchase when a customer buys something in your store.
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}" colspan="2">
|
||||
Gift Cards are created and sent by email when the customer orders a product defined in your triggers.
|
||||
Gift Cards are created manually or automatically sent by email when the customer orders a gift card product.
|
||||
<br/>
|
||||
Then, Gift Cards can be used to pay orders.
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'promotion')]}" colspan="2">
|
||||
Set up triggers based on a promotional code and/or products purchased, that give access to reward on the current order.
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}" colspan="2">
|
||||
Personal eWallet are created when the customer orders a product defined in the triggers.
|
||||
eWallets are created manually or automatically when the customer orders a eWallet product.
|
||||
<br/>
|
||||
Then, eWallets are proposed during the checkout, to pay orders.
|
||||
</p>
|
||||
</div>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="trigger_product_ids" string="Gift Card Products" widget="many2many_tags" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}"/>
|
||||
<field name="trigger_product_ids" string="eWallet Products" widget="many2many_tags" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}"/>
|
||||
<field name="mail_template_id" widget="many2one_tags" attrs="{'invisible': [('program_type', 'not in', ('gift_card', 'ewallet'))]}"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="currency_symbol" invisible="1"/>
|
||||
<field name="portal_point_name" attrs="{'invisible': [('program_type', 'in', ('loyalty', 'gift_card', 'ewallet'))]}" string="Points Unit" groups="base.group_no_one"/>
|
||||
<field name="portal_point_name" attrs="{'invisible': ['|', ('program_type', 'in', ('gift_card', 'ewallet')), ('program_type', '!=', 'loyalty')]}" string="Points Unit"/>
|
||||
<field name="portal_visible" invisible="1"/>
|
||||
<field name="portal_visible" groups="base.group_no_one" string="Show points Unit" attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}"/>
|
||||
<field name="trigger" invisible="1"/>
|
||||
<field name="trigger" string="Program trigger" groups="base.group_no_one" widget="selection" readonly="1" force_save="1"/>
|
||||
<field name="applies_on" invisible="1"/>
|
||||
<field name="applies_on" string="Use points on" groups="base.group_no_one" widget="radio" readonly="1" force_save="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="date_to"/>
|
||||
<label for="limit_usage"/>
|
||||
<span>
|
||||
<field name="date_to" attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}"/>
|
||||
<label for="limit_usage" attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}"/>
|
||||
<span attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}">
|
||||
<field name="limit_usage" class="oe_inline"/>
|
||||
<span attrs="{'invisible': [('limit_usage', '=', False)]}"> to <field name="max_usage" class="oe_inline"/> usages</span>
|
||||
</span>
|
||||
<label for="portal_visible" string="Show points"/>
|
||||
<span>
|
||||
<field name="portal_visible" class="oe_inline"/>
|
||||
<span attrs="{'invisible': [('portal_visible', '=', False)]}"> as <field name="portal_point_name" class="oe_inline"/></span>
|
||||
</span>
|
||||
<field name="applies_on" widget="radio" groups="base.group_no_one"/>
|
||||
<field name="trigger" widget="radio" groups="base.group_no_one" attrs="{'invisible': [('applies_on', '!=', 'current')]}"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="available_on" invisible="1"/>
|
||||
<label class="o_form_label" for="available_on" string="Available On" invisible="1"/>
|
||||
<div id="o_loyalty_program_availabilities" invisible="1"/>
|
||||
<field name="portal_point_name" attrs="{'invisible': [('program_type', 'not in', ('gift_card', 'ewallet'))]}" string="Displayed as" groups="base.group_no_one"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Triggers & Rewards" name="rules_rewards">
|
||||
<notebook attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}">
|
||||
<page string="Rules & Rewards" name="rules_rewards">
|
||||
<group>
|
||||
<group>
|
||||
<field name="rule_ids" colspan="2" mode="kanban" nolabel="1" add-label="Add a trigger"
|
||||
<field name="rule_ids" colspan="2" mode="kanban" nolabel="1" add-label="Add a rule"
|
||||
class="o_loyalty_kanban_inline" widget="loyalty_one2many" context="{'currency_symbol': currency_symbol}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="reward_ids" colspan="2" mode="kanban" nolabel="1" add-label="Add a reward"
|
||||
class="o_loyalty_kanban_inline" widget="loyalty_one2many" context="{'currency_symbol': currency_symbol}"/>
|
||||
class="o_loyalty_kanban_inline" widget="loyalty_one2many" context="{'currency_symbol': currency_symbol}"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
@@ -112,11 +135,11 @@
|
||||
<field name="name">loyalty.program.view.tree</field>
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree js_class="loyalty_program_list_view">
|
||||
<tree js_class="loyalty_program_list_view" class="loyalty-program-list-view">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="program_type"/>
|
||||
<field name="active"/>
|
||||
<field name="coupon_count_display" string="Items"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
@@ -134,15 +157,74 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="loyalty_program_action" model="ir.actions.act_window">
|
||||
<field name="name">Promotions, Gift Card, Loyalty</field>
|
||||
<record id="loyalty_program_discount_loyalty_action" model="ir.actions.act_window">
|
||||
<field name="name">Discount & Loyalty</field>
|
||||
<field name="res_model">loyalty.program</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="domain">[('program_type', 'not in', ('gift_card', 'ewallet'))]</field>
|
||||
<field name="help" type="html">
|
||||
<div class="o_loyalty_not_found container">
|
||||
<h1>No loyalty program found.</h1>
|
||||
<p class="lead fw-light">Create a new one from scratch, or use one of the templates below.</p>
|
||||
<p class="lead">Create a new one from scratch, or use one of the templates below.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_loyalty_program_tree_discount_loyalty" model="ir.actions.act_window.view">
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="view_id" ref="loyalty_program_view_tree"/>
|
||||
<field name="act_window_id" ref="loyalty_program_discount_loyalty_action"/>
|
||||
</record>
|
||||
|
||||
<record id="action_loyalty_program_form_discount_loyalty" model="ir.actions.act_window.view">
|
||||
<field name="view_mode">form</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="view_id" ref="loyalty_program_view_form"/>
|
||||
<field name="act_window_id" ref="loyalty_program_discount_loyalty_action"/>
|
||||
</record>
|
||||
|
||||
<!-- GIFT & EWALLET -->
|
||||
<record id="loyalty_program_gift_ewallet_view_form" model="ir.ui.view">
|
||||
<field name="name">loyalty.program.gift.ewallet.view.form</field>
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="inherit_id" ref="loyalty_program_view_form"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<form position="attributes">
|
||||
<attribute name="string">Gift & Ewallet</attribute>
|
||||
</form>
|
||||
<field name="program_type" position="attributes">
|
||||
<attribute name="options">{'whitelisted_values': ['gift_card', 'ewallet']}</attribute>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="loyalty_program_gift_ewallet_action" model="ir.actions.act_window">
|
||||
<field name="name">Gift cards & eWallet</field>
|
||||
<field name="res_model">loyalty.program</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'menu_type': 'gift_ewallet', 'default_program_type': 'gift_card'}</field>
|
||||
<field name="domain">[('program_type', 'in', ('gift_card', 'ewallet'))]</field>
|
||||
<field name="help" type="html">
|
||||
<div class="o_loyalty_not_found container">
|
||||
<h1>No loyalty program found.</h1>
|
||||
<p class="lead">Create a new one from scratch, or use one of the templates below.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_loyalty_program_tree_gift_card_ewallet" model="ir.actions.act_window.view">
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="view_id" ref="loyalty_program_view_tree"/>
|
||||
<field name="act_window_id" ref="loyalty_program_gift_ewallet_action"/>
|
||||
</record>
|
||||
|
||||
<record id="action_loyalty_program_form_gift_card_ewallet" model="ir.actions.act_window.view">
|
||||
<field name="view_mode">form</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="view_id" ref="loyalty_program_gift_ewallet_view_form"/>
|
||||
<field name="act_window_id" ref="loyalty_program_gift_ewallet_action"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -5,51 +5,55 @@
|
||||
<field name="model">loyalty.reward</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<field name="program_type" invisible="1"/>
|
||||
<field name="user_has_debug" invisible="1"/>
|
||||
<field name="multi_product" invisible="1"/>
|
||||
<field name="reward_product_ids" invisible="1"/>
|
||||
<field name="all_discount_product_ids" invisible="1"/>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="reward_type" widget="radio"/>
|
||||
</group>
|
||||
<group id="discount_group" attrs="{'invisible': [('reward_type', '!=', 'discount')]}">
|
||||
<label for="discount"/>
|
||||
<div class="o_row">
|
||||
<field name="discount" class="oe_edit_only col-2 oe_inline text-center pe-2"/>
|
||||
<field name="discount_mode" class="oe_edit_only col-4 oe_inline pe-2"/>
|
||||
<field name="discount" class="oe_read_only"/>
|
||||
<field name="discount_mode" class="oe_read_only"/>
|
||||
<group string="Reward" id="reward_type_group">
|
||||
<field name="reward_type" widget="selection" attrs="{'readonly' : [('program_type', '=', 'buy_x_get_y')]}"/>
|
||||
<label for="discount" attrs="{'invisible': [('reward_type', '!=', 'discount')]}"/>
|
||||
<div class="d-flex flex-row" attrs="{'invisible': [('reward_type', '!=', 'discount')]}">
|
||||
<field name="discount" class="oe_inline me-1"/>
|
||||
<field name="discount_mode" no_label="1" class="w-auto me-1"/>
|
||||
<span>on</span>
|
||||
</div>
|
||||
<!-- Align right -->
|
||||
<field name="all_discount_product_ids" invisible="1"/>
|
||||
<label for="discount_applicability" string=""/>
|
||||
<field name="discount_applicability" nolabel="1" widget="radio"/>
|
||||
<field name="discount_product_domain" groups="base.group_no_one" widget="domain" options="{'model': 'product.product', 'in_dialog': true}"/>
|
||||
<field name="discount_product_ids" widget="many2many_tags"
|
||||
attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
|
||||
<field name="discount_product_category_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
|
||||
<field name="discount_product_tag_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
|
||||
<field name="discount_max_amount"/>
|
||||
<label for="discount_applicability" string="" attrs="{'invisible': [('reward_type', '!=', 'discount')]}"/>
|
||||
<field name="discount_applicability" nolabel="1" widget="radio" attrs="{'invisible': [('reward_type', '!=', 'discount')]}"/>
|
||||
</group>
|
||||
<group id="product_group" attrs="{'invisible': [('reward_type', '!=', 'product')]}">
|
||||
<field name="multi_product" invisible="1"/>
|
||||
<field name="reward_product_ids" invisible="1"/>
|
||||
|
||||
<group string="Among" attrs="{'invisible': [('reward_type', '!=', 'product')]}">
|
||||
<field name="reward_product_qty" string="Quantity rewarded"/>
|
||||
<field name="reward_product_id" attrs="{'required': [('reward_type', '=', 'product'), ('reward_product_ids', '=', [])]}"/>
|
||||
<field name="reward_product_tag_id" attrs="{'required': [('reward_type', '=', 'product'), ('reward_product_ids', '=', [])]}"/>
|
||||
<label for="reward_product_qty"/>
|
||||
<div class="row m-0">
|
||||
<field name="reward_product_qty" class="oe_inline"/>
|
||||
<field name="reward_product_uom_id" class="oe_inline" attrs="{'invisible': [('multi_product', '=', True)]}"/>
|
||||
</div>
|
||||
</group>
|
||||
|
||||
<group string="Discount" attrs="{'invisible': [('reward_type', '!=', 'discount')]}">
|
||||
<field name="discount_max_amount"/>
|
||||
<field name="discount_product_domain" groups="base.group_no_one" widget="domain" options="{'model': 'product.product', 'in_dialog': true}" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
|
||||
<field name="discount_product_ids" widget="many2many_tags" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
|
||||
<field name="discount_product_category_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
|
||||
<field name="discount_product_tag_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Points" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
|
||||
<group>
|
||||
<field name="required_points"/>
|
||||
<field name="clear_wallet"/>
|
||||
<label for="required_points" string="In exchange of"/>
|
||||
<div class="o_row">
|
||||
<field name="required_points" class="oe_edit_only col-2 oe_inline text-center pe-2"/>
|
||||
<field name="point_name" no_label="1"/>
|
||||
<span attrs="{'invisible': [('clear_wallet', '!=', True)]}"> (or more)</span>
|
||||
</div>
|
||||
<label for="clear_wallet" string="Clear all promo point(s)" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'in', ('loyalty', 'buy_x_get_y'))]}"/>
|
||||
<div class="o_row" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'in', ('loyalty', 'buy_x_get_y'))]}">
|
||||
<field name="clear_wallet"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="description"/>
|
||||
</group>
|
||||
<field name="description" string="Description on order"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
@@ -70,6 +74,8 @@
|
||||
<field name="discount_mode"/>
|
||||
<field name="discount_applicability"/>
|
||||
<field name="discount_product_domain"/>
|
||||
<field name="discount_product_category_id"/>
|
||||
<field name="discount_product_tag_id"/>
|
||||
<field name="discount_max_amount"/>
|
||||
<field name="discount_line_product_id"/>
|
||||
<field name="reward_product_id"/>
|
||||
@@ -80,27 +86,74 @@
|
||||
<field name="required_points"/>
|
||||
<field name="point_name"/>
|
||||
<field name="clear_wallet"/>
|
||||
<field name="program_type"/>
|
||||
<field name="user_has_debug"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click_edit mx-0">
|
||||
<field name="description"/>
|
||||
<t t-if="record.reward_type.raw_value === 'discount' && record.discount_applicability.raw_value === 'specific'">
|
||||
<t t-if="record.discount_product_domain.raw_value && record.discount_product_domain.raw_value !== '[]'" groups="base.group_no_one">
|
||||
<a><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="discount_product_domain"/></a>
|
||||
<div class="oe_kanban_global_click_edit mx-0 d-flex flex-row">
|
||||
<div class="o_loyalty_kanban_card_left mw-75 flex-grow-1" id="reward_info">
|
||||
<t t-if="record.reward_type.raw_value === 'discount'">
|
||||
|
||||
<t t-if="record.discount">
|
||||
<a><field name="discount"/><field name="discount_mode"/> discount <t t-if="record.discount_max_amount.raw_value > 0">( Max <field name="discount_max_amount"/> )</t></a>
|
||||
</t>
|
||||
|
||||
<t t-if="record.discount_applicability.raw_value === 'specific'">
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<span class="fw-bold text-decoration-underline">Applied to:</span>
|
||||
|
||||
<t t-if="record.discount_product_ids.raw_value.length > 0">
|
||||
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="discount_product_ids" widget="many2many_tags" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.discount_product_category_id.raw_value">
|
||||
<div class="d-flex"><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="discount_product_category_id" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.discount_product_tag_id.raw_value">
|
||||
<div class="d-flex"><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="discount_product_tag_id" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.discount_product_domain.raw_value && record.discount_product_domain.raw_value !== '[]'" groups="base.group_no_one">
|
||||
<div class="d-flex"><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="discount_product_domain" class="d-inline"/></div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-elif="record.discount_applicability.raw_value === 'cheapest'">
|
||||
on the cheapest product
|
||||
<br/>
|
||||
</t>
|
||||
|
||||
<t t-elif="record.discount_applicability.raw_value === 'order'">
|
||||
on your order
|
||||
<br/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="record.discount_product_ids.raw_value.length > 0">
|
||||
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="discount_product_ids" widget="many2many_tags" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.discount_product_category_id.raw_value">
|
||||
<a><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="discount_product_category_id"/></a>
|
||||
|
||||
<t t-if="record.reward_type.raw_value === 'product'">
|
||||
<a>Free product</a>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-if="record.discount_product_tag_id.raw_value">
|
||||
<a><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="discount_product_tag_id"/></a>
|
||||
<br/>
|
||||
<t t-if="record.reward_product_id.raw_value">
|
||||
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Product Domain"/><field name="reward_product_id"/> <t t-if="record.reward_product_qty.raw_value > 1"><span> x </span><field name="reward_product_qty"/></t></div>
|
||||
</t>
|
||||
<t t-if="record.reward_product_tag_id.raw_value">
|
||||
<div class="d-flex"><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="reward_product_tag_id" class="d-inline"/></div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<p class="m-0">With <field name="required_points"/> <field name="point_name"/><t t-if="record.clear_wallet.raw_value"> (or more)</t></p>
|
||||
</div>
|
||||
|
||||
<div class="o_loyalty_kanban_card_right" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
|
||||
<p class="text-muted">
|
||||
<span class="fw-bold text-decoration-underline">In exchange of</span>
|
||||
<br/>
|
||||
<t t-if="record.clear_wallet.raw_value">
|
||||
all <field name="point_name"/> (if at least <field name="required_points"/> <field name="point_name"/>)
|
||||
</t>
|
||||
<t t-else="">
|
||||
<field name="required_points"/> <field name="point_name"/>
|
||||
</t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -5,37 +5,40 @@
|
||||
<field name="model">loyalty.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form class="loyalty-rule-form">
|
||||
<field name="program_type" invisible="1"/>
|
||||
<field name="user_has_debug" invisible="1"/>
|
||||
<sheet>
|
||||
<group>
|
||||
<group attrs="{'invisible': [('program_type', '!=', 'promo_code')]}">
|
||||
<field name="code" attrs="{'required': [('program_type', '=', 'promo_code')]}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<separator string="Conditions" colspan="2"/>
|
||||
<field name="minimum_qty"/>
|
||||
<label for="minimum_amount" attrs="{'invisible': [('program_type', '=', 'buy_x_get_y')]}"/>
|
||||
<div class="d-flex flex-row">
|
||||
<field name="minimum_amount" class="oe_inline me-1"/>
|
||||
<span>tax</span>
|
||||
<field name="minimum_amount_tax_mode" class="ms-1"/>
|
||||
</div>
|
||||
<separator string="Among" colspan="2"/>
|
||||
<field name="product_domain" groups="base.group_no_one" widget="domain" options="{'model': 'product.product', 'in_dialog': true}"/>
|
||||
<field name="product_ids" widget="many2many_tags"/>
|
||||
<field name="product_category_id"/>
|
||||
<field name="product_tag_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="reward_point_amount"/>
|
||||
<span>
|
||||
<field name="reward_point_amount" class="oe_inline"/>
|
||||
<field name="reward_point_name" class="oe_inline"/>
|
||||
</span>
|
||||
<label for="reward_point_mode" string=""/>
|
||||
<field name="reward_point_mode" widget="radio" nolabel="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="minimum_qty"/>
|
||||
<label for="minimum_amount"/>
|
||||
<div class="o_row">
|
||||
<field name="minimum_amount" class="oe_inline pe-2"/>
|
||||
<span>tax</span>
|
||||
<field name="minimum_amount_tax_mode" class="oe_inline"/>
|
||||
<group attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
|
||||
<separator string="Point(s)" colspan="2"/>
|
||||
<span colspan="2" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">Grant the amount of coupon popints defined as the coupon value</span>
|
||||
<label for="reward_point_amount" string="Grant" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}"/>
|
||||
<div class="d-flex flex-row" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}">
|
||||
<field name="reward_point_amount" class="oe_inline me-1"/>
|
||||
<field name="reward_point_name" class="w-auto"/>
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<field name="mode" widget="radio"/>
|
||||
<field name="code"
|
||||
attrs="{'invisible': [('mode', '!=', 'with_code')],
|
||||
'required': [('mode', '=', 'with_code')]}"/>
|
||||
<label for="reward_point_mode" string=""/>
|
||||
<field name="reward_point_mode" widget="radio" nolabel="1" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
@@ -64,43 +67,55 @@
|
||||
<field name="minimum_amount_tax_mode"/>
|
||||
<field name="mode"/>
|
||||
<field name="code"/>
|
||||
<field name="program_type"/>
|
||||
<field name="user_has_debug"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click_edit mx-0">
|
||||
<t t-if="record.product_domain.raw_value && record.product_domain.raw_value !== '[]'" groups="base.group_no_one">
|
||||
<a><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="product_domain"/></a>
|
||||
</t>
|
||||
<t t-if="record.product_ids.raw_value.length > 0">
|
||||
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="product_ids" widget="many2many_tags" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.product_category_id.raw_value">
|
||||
<a><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="product_category_id"/></a>
|
||||
<div class="oe_kanban_global_click_edit mx-0 d-flex flex-row">
|
||||
<div class="o_loyalty_kanban_card_left mw-75 flex-grow-1">
|
||||
<t t-if="record.code.raw_value"><span>Discount code <field name="code"/></span><br/></t>
|
||||
<t t-if="record.minimum_qty.raw_value > 0"><span>If minimum <field name="minimum_qty"/> item(s) bought</span><br/></t>
|
||||
<t t-if="record.minimum_amount.raw_value > 0"><span>If minimum <field name="minimum_amount"/> spent<t t-if="record.minimum_amount_tax_mode.raw_value === 'excl'"> (tax excluded)</t></span><br/></t>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-if="record.product_tag_id.raw_value">
|
||||
<a><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="product_tag_id"/></a>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-if="record.product_ids.raw_value.length === 0 && !record.product_category_id.raw_value && !record.product_tag_id.raw_value">
|
||||
<a><i class="fa fa-cube fa-fw" title="Products"/> All Products</a>
|
||||
<br/>
|
||||
</t>
|
||||
<a>
|
||||
<t t-if="record.minimum_qty.raw_value > 1 || record.minimum_amount.raw_value">
|
||||
<t t-set="qty" t-value="record.minimum_qty.raw_value"/>
|
||||
<t t-set="amt" t-value="record.minimum_amount.raw_value"/>
|
||||
<t t-set="tax_mode" t-value="record.minimum_amount_tax_mode.raw_value"/>
|
||||
<span>If<t t-if="qty > 1"> minimum <field name="minimum_qty"/> PCE</t>
|
||||
<t t-if="amt"><t t-if="qty > 1">and</t> minimum <field name="minimum_amount"/><t t-if="tax_mode === 'excl'"> (tax excluded)</t></t>
|
||||
</span>
|
||||
|
||||
<t t-if="record.product_ids.raw_value.length != 0 || record.product_category_id.raw_value || record.product_tag_id.raw_value">
|
||||
<span class="fw-bold text-decoration-underline">Among:</span>
|
||||
<br/>
|
||||
|
||||
<t t-if="record.product_ids.raw_value.length > 0">
|
||||
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="product_ids" widget="many2many_tags" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.product_category_id.raw_value">
|
||||
<div class="d-flex"><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="product_category_id" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.product_tag_id.raw_value">
|
||||
<div class="d-flex"><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="product_tag_id" class="d-inline"/></div>
|
||||
</t>
|
||||
<t t-if="record.product_ids.raw_value.length === 0 && !record.product_category_id.raw_value && !record.product_tag_id.raw_value">
|
||||
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/><span>All Products</span></div>
|
||||
</t>
|
||||
<t t-if="record.product_domain.raw_value && record.product_domain.raw_value !== '[]'" groups="base.group_no_one">
|
||||
<div class="d-flex"><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="product_domain" class="d-inline"/></div>
|
||||
</t>
|
||||
</t>
|
||||
</a>
|
||||
<t t-if="record.code.raw_value">
|
||||
<a><i class="fa fa-pencil-square-o fa-fw" title="Code"/>With Code: <field name="code"/></a>
|
||||
<br/>
|
||||
</t>
|
||||
<i class="fa fa-gift fa-fw" title="conditions"/> <span>Reward <field name="reward_point_amount"/> <field name="reward_point_name"/> <field name="reward_point_mode"/></span>
|
||||
</div>
|
||||
|
||||
<div class="o_loyalty_kanban_card_right" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">
|
||||
<span class="fw-bold text-decoration-underline">Grant</span>
|
||||
<br/>
|
||||
the value of the coupon
|
||||
</p>
|
||||
<p class="text-muted" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}">
|
||||
<span class="fw-bold text-decoration-underline">Grant</span>
|
||||
<br/>
|
||||
<field name="reward_point_amount"/>
|
||||
<span> </span>
|
||||
<field name="reward_point_name"/>
|
||||
<span> </span>
|
||||
<field name="reward_point_mode"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -26,6 +26,7 @@ class LoyaltyGenerateWizard(models.TransientModel):
|
||||
points_granted = fields.Float('Grant', required=True, default=1)
|
||||
points_name = fields.Char(related='program_id.portal_point_name', readonly=True)
|
||||
valid_until = fields.Date()
|
||||
will_send_mail = fields.Boolean(compute='_compute_will_send_mail')
|
||||
|
||||
def _get_partners(self):
|
||||
self.ensure_one()
|
||||
@@ -46,6 +47,11 @@ class LoyaltyGenerateWizard(models.TransientModel):
|
||||
else:
|
||||
wizard.coupon_qty = wizard.coupon_qty or 0
|
||||
|
||||
@api.depends("mode", "program_id")
|
||||
def _compute_will_send_mail(self):
|
||||
for wizard in self:
|
||||
wizard.will_send_mail = wizard.mode == 'selected' and 'create' in wizard.program_id.mapped('communication_plan_ids.trigger')
|
||||
|
||||
def _get_coupon_values(self, partner):
|
||||
self.ensure_one()
|
||||
return {
|
||||
|
||||
@@ -4,26 +4,46 @@
|
||||
<field name="name">loyalty.generate.wizard.view.form</field>
|
||||
<field name="model">loyalty.generate.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Generate Coupons">
|
||||
<form string="Generate">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="program_id" invisible="1"/>
|
||||
<field name="program_type" invisible="1"/>
|
||||
<field name="mode" widget="radio" attrs="{'invisible': [('program_type', '=', 'ewallet')]}"/>
|
||||
<field name="customer_ids" widget="many2many_tags_avatar" attrs="{'invisible': [('mode', '=', 'anonymous')]}"/>
|
||||
<field name="customer_tag_ids" widget="many2many_tags" attrs="{'invisible': [('mode', '=', 'anonymous')]}" options="{'color_field': 'color'}"/>
|
||||
<separator/>
|
||||
<field name="coupon_qty" attrs="{'readonly': [('mode', '=', 'selected')], 'required': [('mode', '=', 'anonymous')]}"/>
|
||||
<label for="points_granted"/>
|
||||
<span>
|
||||
<field name="points_granted" class="oe_inline"/>
|
||||
<field name="points_name" class="oe_inline"/>
|
||||
</span>
|
||||
<field name="valid_until"/>
|
||||
<group>
|
||||
<field name="program_id" invisible="1"/>
|
||||
<field name="will_send_mail" invisible="1"/>
|
||||
<field name="program_type" invisible="1"/>
|
||||
<field name="mode" widget="radio" attrs="{'invisible': [('program_type', '=', 'ewallet')]}"/>
|
||||
<field name="customer_ids" widget="many2many_tags" attrs="{'invisible': [('mode', '=', 'anonymous')]}"/>
|
||||
<field name="customer_tag_ids" widget="many2many_tags" attrs="{'invisible': [('mode', '=', 'anonymous')]}" options="{'color_field': 'color'}"/>
|
||||
<field name="coupon_qty" string="Quantity to generate" attrs="{'readonly': [('mode', '=', 'selected')], 'required': [('mode', '=', 'anonymous')]}"/>
|
||||
<label string="Coupon value" for="points_granted" groups="base.group_no_one" attrs="{'invisible': [('program_type', '!=', 'coupons')]}"/>
|
||||
<span class="d-inline-block" groups="base.group_no_one" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">
|
||||
<field name="points_granted" class="oe_inline"/>
|
||||
<field name="points_name" class="oe_inline"/>
|
||||
</span>
|
||||
<label string="Gift Card value" for="points_granted" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}"/>
|
||||
<span class="d-inline-block" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}">
|
||||
<field name="points_granted" class="w-auto oe_inline me-1"/>
|
||||
<field name="points_name" class="d-inline" no_label="1"/>
|
||||
</span>
|
||||
<label string="eWallet value" for="points_granted" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}"/>
|
||||
<span class="d-inline-block" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}">
|
||||
<field name="points_granted" class="w-auto oe_inline me-1"/>
|
||||
<field name="points_name" class="d-inline" no_label="1"/>
|
||||
</span>
|
||||
<field name="valid_until"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="generate_coupons" type="object" string="Generate Coupons" class="btn-primary" data-hotkey="q"/>
|
||||
<button name="generate_coupons" type="object" class="btn-primary" data-hotkey="q">
|
||||
<span attrs="{'invisible': [('will_send_mail', '=', False)]}">
|
||||
Generate and Send
|
||||
</span>
|
||||
<span attrs="{'invisible': [('will_send_mail', '=', True)]}">
|
||||
Generate
|
||||
</span>
|
||||
<field name="program_type" nolabel="1"/>
|
||||
</button>
|
||||
<button special="cancel" data-hotkey="z" string="Cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -31,7 +51,7 @@
|
||||
</record>
|
||||
|
||||
<record id="loyalty_generate_wizard_action" model="ir.actions.act_window">
|
||||
<field name="name">Generate Coupons</field>
|
||||
<field name="name">Generate</field>
|
||||
<field name="res_model">loyalty.generate.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
|
||||
@@ -10,23 +10,26 @@ class LoyaltyProgram(models.Model):
|
||||
def _program_type_default_values(self):
|
||||
res = super()._program_type_default_values()
|
||||
# Add a loyalty reward for free shipping
|
||||
res['loyalty']['reward_ids'].append((0, 0, {
|
||||
'reward_type': 'shipping',
|
||||
'required_points': 100,
|
||||
}))
|
||||
if 'loyalty' in res:
|
||||
res['loyalty']['reward_ids'].append((0, 0, {
|
||||
'reward_type': 'shipping',
|
||||
'required_points': 100,
|
||||
}))
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def get_program_templates(self):
|
||||
# Override 'promotion' template to say free shipping
|
||||
res = super().get_program_templates()
|
||||
res['promotion']['description'] = _("Automatic promotion: free shipping on orders higher than $50")
|
||||
if 'promotion' in res:
|
||||
res['promotion']['description'] = _("Automatic promotion: free shipping on orders higher than $50")
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_template_values(self):
|
||||
res = super()._get_template_values()
|
||||
res['promotion']['reward_ids'] = [(5, 0, 0), (0, 0, {
|
||||
'reward_type': 'shipping',
|
||||
})]
|
||||
if 'promotion' in res:
|
||||
res['promotion']['reward_ids'] = [(5, 0, 0), (0, 0, {
|
||||
'reward_type': 'shipping',
|
||||
})]
|
||||
return res
|
||||
|
||||
@@ -5,11 +5,26 @@
|
||||
<field name="model">loyalty.reward</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_reward_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@id='product_group']" position="after">
|
||||
<group id="shipping" attrs="{'invisible': [('reward_type', '!=', 'shipping')]}">
|
||||
<xpath expr="//group[@id='reward_type_group']" position="after">
|
||||
<group id="shipping" string="Free shipping" attrs="{'invisible': [('reward_type', '!=', 'shipping')]}">
|
||||
<field name="discount_max_amount"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="loyalty_reward_view_kanban_inherit_loyalty_delivery" model="ir.ui.view">
|
||||
<field name="name">loyalty.reward.view.kanban.inherit.loyalty.delivery</field>
|
||||
<field name="model">loyalty.reward</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_reward_view_kanban"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='reward_info']" position="inside">
|
||||
<t t-if="record.reward_type.raw_value === 'shipping'">
|
||||
|
||||
<a>Free shipping <t t-if="record.discount_max_amount.raw_value > 0">( Max <field name="discount_max_amount"/> )</t></a>
|
||||
<br/><br/>
|
||||
</t>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
<!-- 15% next order -->
|
||||
<record id="15_pc_on_next_order" model="loyalty.program">
|
||||
<field name="name">15% on next order</field>
|
||||
<field name="program_type">promotion</field>
|
||||
<field name="program_type">next_order_coupons</field>
|
||||
<field name="trigger">auto</field>
|
||||
<field name="applies_on">future</field>
|
||||
<field name="portal_visible">True</field>
|
||||
<field name="portal_point_name">Coupon point(s)</field>
|
||||
</record>
|
||||
|
||||
<record id="15_pc_on_next_order_rule" model="loyalty.rule">
|
||||
@@ -62,8 +64,10 @@
|
||||
<record id="loyalty_program" model="loyalty.program">
|
||||
<field name="name">Loyalty Program</field>
|
||||
<field name="program_type">loyalty</field>
|
||||
<field name="trigger">auto</field>
|
||||
<field name="applies_on">both</field>
|
||||
<field name="trigger">auto</field>
|
||||
<field name="portal_visible">True</field>
|
||||
<field name="portal_point_name">Loyalty Points</field>
|
||||
</record>
|
||||
|
||||
<record id="loyalty_program_rule" model="loyalty.rule">
|
||||
|
||||
@@ -9,6 +9,7 @@ class LoyaltyProgram(models.Model):
|
||||
|
||||
pos_config_ids = fields.Many2many('pos.config', string="Point of Sales", readonly=True)
|
||||
pos_order_count = fields.Integer("PoS Order Count", compute='_compute_pos_order_count')
|
||||
pos_ok = fields.Boolean("Point of Sale", default=True)
|
||||
|
||||
def _compute_pos_order_count(self):
|
||||
read_group_res = self.env['pos.order.line']._read_group(
|
||||
|
||||
@@ -13,16 +13,16 @@ class PosConfig(models.Model):
|
||||
use_coupon_programs = fields.Boolean('Coupons & Promotions',
|
||||
help="Use coupon and promotion programs in this PoS configuration.")
|
||||
coupon_program_ids = fields.Many2many(
|
||||
'loyalty.program', string="Coupon Programs", domain=[('program_type', '=', 'coupons')],
|
||||
'loyalty.program', string="Coupon Programs", domain=[('pos_ok', '=', True), ('program_type', '=', 'coupons')],
|
||||
relation='pos_config_coupon_program_rel')
|
||||
promo_program_ids = fields.Many2many(
|
||||
'loyalty.program', string="Promotion Programs", domain=[('program_type', '=', 'promotion')],
|
||||
'loyalty.program', string="Promotion Programs", domain=[('pos_ok', '=', True), ('program_type', 'in', ('promotion', 'buy_x_get_y', 'promo_code', 'next_order_coupons'))],
|
||||
relation='pos_config_promo_program_rel')
|
||||
|
||||
loyalty_program_id = fields.Many2one('loyalty.program', "PoS Loyalty Programs", domain=[('program_type', '=', 'loyalty')], default=_default_loyalty_program)
|
||||
loyalty_program_id = fields.Many2one('loyalty.program', "PoS Loyalty Programs", domain=[('pos_ok', '=', True), ('program_type', '=', 'loyalty')], default=_default_loyalty_program)
|
||||
|
||||
use_gift_card = fields.Boolean('Gift Cards')
|
||||
gift_card_program_id = fields.Many2one('loyalty.program', "PoS Gift Card Program", domain=[('program_type', '=', 'gift_card')])
|
||||
gift_card_program_id = fields.Many2one('loyalty.program', "PoS Gift Card Program", domain=[('pos_ok', '=', True), ('program_type', '=', 'gift_card')])
|
||||
gift_card_settings = fields.Selection(
|
||||
[
|
||||
("create_set", "Generate a new barcode and set a price"),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="loyalty_program_view_form_inherit_pos_loyalty" model="ir.ui.view">
|
||||
<field name="name">loyalty.program.view.form.inherit.pos.loyalty</field>
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//label[@for='available_on']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="inside">
|
||||
<span class="d-inline-block">
|
||||
<field name="pos_ok" class="w-auto"/>
|
||||
<label for="pos_ok"/>
|
||||
</span>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,11 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem
|
||||
id="menu_coupon_type_config"
|
||||
action="loyalty.loyalty_program_action"
|
||||
name="Coupons & Loyalty"
|
||||
id="menu_discount_loyalty_type_config"
|
||||
action="loyalty.loyalty_program_discount_loyalty_action"
|
||||
name="Discount & Loyalty"
|
||||
parent="point_of_sale.pos_config_menu_catalog"
|
||||
groups="point_of_sale.group_pos_manager"
|
||||
sequence="91"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_gift_ewallet_type_config"
|
||||
action="loyalty.loyalty_program_gift_ewallet_action"
|
||||
name="Gift cards & eWallet"
|
||||
parent="point_of_sale.pos_config_menu_catalog"
|
||||
groups="point_of_sale.group_pos_manager"
|
||||
sequence="92"
|
||||
/>
|
||||
</odoo>
|
||||
|
||||
@@ -103,14 +103,14 @@
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
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.">
|
||||
title="Boost your sales with multiple kinds of programs: Coupons, Promotions, Gift Card, Loyalty. 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_loyalty"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_sale_loyalty"/>
|
||||
<label for="module_sale_loyalty" string="Discounts, Loyalty & Gift Card"/>
|
||||
<div class="text-muted" id="sale_coupon">
|
||||
Manage promotion & coupon programs
|
||||
Manage Promotions, coupons, loyalty cards, Gift cards & eWallet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ class LoyaltyProgram(models.Model):
|
||||
_inherit = 'loyalty.program'
|
||||
|
||||
order_count = fields.Integer(compute='_compute_order_count')
|
||||
sale_ok = fields.Boolean("Sales", default=True)
|
||||
|
||||
def _compute_order_count(self):
|
||||
# An order should count only once PER program but may appear in multiple programs
|
||||
|
||||
@@ -325,7 +325,7 @@ class SaleOrder(models.Model):
|
||||
'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'))),
|
||||
tax_str=len(discountable_per_tax) and any(t.name for t in mapped_taxes[tax]) and _(' - On product with the following taxes: %(taxes)s', taxes=", ".join(mapped_taxes[tax].mapped('name'))) or '',
|
||||
),
|
||||
'product_id': reward.discount_line_product_id.id,
|
||||
'price_unit': -(price * discount_factor),
|
||||
@@ -348,7 +348,7 @@ class SaleOrder(models.Model):
|
||||
Returns the base domain that all programs have to comply to.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return [('active', '=', True),
|
||||
return [('active', '=', True), ('sale_ok', '=', True),
|
||||
('company_id', 'in', (self.company_id.id, False)),
|
||||
'|', ('date_to', '=', False), ('date_to', '>=', fields.Date.context_today(self))]
|
||||
|
||||
@@ -357,7 +357,7 @@ class SaleOrder(models.Model):
|
||||
Returns the base domain that all triggers have to comply to.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return [('active', '=', True),
|
||||
return [('active', '=', True), ('program_id.sale_ok', '=', True),
|
||||
('company_id', 'in', (self.company_id.id, False)),
|
||||
'|', ('program_id.date_to', '=', False), ('program_id.date_to', '<=', fields.Date.context_today(self))]
|
||||
|
||||
@@ -621,9 +621,8 @@ class SaleOrder(models.Model):
|
||||
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
|
||||
# Remove any coupons that 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'])
|
||||
@@ -723,7 +722,11 @@ class SaleOrder(models.Model):
|
||||
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])
|
||||
try:
|
||||
values_list = self._get_reward_line_values(reward, coupon, product=reward_key[3])
|
||||
except UserError:
|
||||
# It could happen that we have nothing to discount after changing the order.
|
||||
values_list = []
|
||||
reward_line_pool = self._write_vals_from_reward_vals(values_list, reward_line_pool, delete=False)
|
||||
|
||||
lines_to_unlink |= reward_line_pool
|
||||
@@ -937,8 +940,11 @@ class SaleOrder(models.Model):
|
||||
|
||||
# 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:
|
||||
coupon = self.env['loyalty.card'].search([('code', '=', code)])
|
||||
if not coupon or\
|
||||
not coupon.program_id.active or\
|
||||
not coupon.program_id.reward_ids or\
|
||||
not coupon.program_id.filtered_domain(self._get_program_domain()):
|
||||
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.')}
|
||||
|
||||
@@ -221,3 +221,111 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueCommon):
|
||||
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])
|
||||
|
||||
class TestSaleCouponNumbersCommon(TestSaleCouponCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.largeCabinet = cls.env['product.product'].create({
|
||||
'name': 'Large Cabinet',
|
||||
'list_price': 320.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.conferenceChair = cls.env['product.product'].create({
|
||||
'name': 'Conference Chair',
|
||||
'list_price': 16.5,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.pedalBin = cls.env['product.product'].create({
|
||||
'name': 'Pedal Bin',
|
||||
'list_price': 47.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.drawerBlack = cls.env['product.product'].create({
|
||||
'name': 'Drawer Black',
|
||||
'list_price': 25.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.largeMeetingTable = cls.env['product.product'].create({
|
||||
'name': 'Large Meeting Table',
|
||||
'list_price': 40000.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
|
||||
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.p1 = cls.env['loyalty.program'].create({
|
||||
'name': 'Code for 10% on orders',
|
||||
'trigger': 'with_code',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'mode': 'with_code',
|
||||
'code': 'test_10pc',
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount_mode': 'percent',
|
||||
'discount': 10,
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.p2 = cls.env['loyalty.program'].create({
|
||||
'name': 'Buy 3 cabinets, get one for free',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': cls.largeCabinet,
|
||||
'reward_point_mode': 'unit',
|
||||
'minimum_qty': 3,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': cls.largeCabinet.id,
|
||||
'reward_product_qty': 1,
|
||||
'required_points': 3,
|
||||
})],
|
||||
})
|
||||
cls.p3 = cls.env['loyalty.program'].create({
|
||||
'name': 'Buy 1 drawer black, get a free Large Meeting Table',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': cls.drawerBlack,
|
||||
'reward_point_mode': 'order',
|
||||
'minimum_qty': 1,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': cls.largeMeetingTable.id,
|
||||
'reward_product_qty': 1,
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.discount_coupon_program = cls.env['loyalty.program'].create({
|
||||
'name': '$100 coupon',
|
||||
'program_type': 'coupons',
|
||||
'trigger': 'with_code',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'minimum_amount': 100,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount_mode': 'per_point',
|
||||
'discount': 100,
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.all_programs = cls.env['loyalty.program'].search([])
|
||||
|
||||
@@ -1,121 +1,14 @@
|
||||
# -*- 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.addons.sale_loyalty.tests.common import TestSaleCouponNumbersCommon
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tools.float_utils import float_compare
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSaleCouponProgramNumbers(TestSaleCouponCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.largeCabinet = cls.env['product.product'].create({
|
||||
'name': 'Large Cabinet',
|
||||
'list_price': 320.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.conferenceChair = cls.env['product.product'].create({
|
||||
'name': 'Conference Chair',
|
||||
'list_price': 16.5,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.pedalBin = cls.env['product.product'].create({
|
||||
'name': 'Pedal Bin',
|
||||
'list_price': 47.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.drawerBlack = cls.env['product.product'].create({
|
||||
'name': 'Drawer Black',
|
||||
'list_price': 25.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
cls.largeMeetingTable = cls.env['product.product'].create({
|
||||
'name': 'Large Meeting Table',
|
||||
'list_price': 40000.0,
|
||||
'taxes_id': False,
|
||||
})
|
||||
|
||||
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.p1 = cls.env['loyalty.program'].create({
|
||||
'name': 'Code for 10% on orders',
|
||||
'trigger': 'with_code',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'mode': 'with_code',
|
||||
'code': 'test_10pc',
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount_mode': 'percent',
|
||||
'discount': 10,
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.p2 = cls.env['loyalty.program'].create({
|
||||
'name': 'Buy 3 cabinets, get one for free',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': cls.largeCabinet,
|
||||
'reward_point_mode': 'unit',
|
||||
'minimum_qty': 3,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': cls.largeCabinet.id,
|
||||
'reward_product_qty': 1,
|
||||
'required_points': 3,
|
||||
})],
|
||||
})
|
||||
cls.p3 = cls.env['loyalty.program'].create({
|
||||
'name': 'Buy 1 drawer black, get a free Large Meeting Table',
|
||||
'trigger': 'auto',
|
||||
'program_type': 'promotion',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'product_ids': cls.drawerBlack,
|
||||
'reward_point_mode': 'order',
|
||||
'minimum_qty': 1,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': cls.largeMeetingTable.id,
|
||||
'reward_product_qty': 1,
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.discount_coupon_program = cls.env['loyalty.program'].create({
|
||||
'name': '$100 coupon',
|
||||
'program_type': 'coupons',
|
||||
'trigger': 'with_code',
|
||||
'applies_on': 'current',
|
||||
'rule_ids': [(0, 0, {
|
||||
'minimum_amount': 100,
|
||||
})],
|
||||
'reward_ids': [(0, 0, {
|
||||
'reward_type': 'discount',
|
||||
'discount_mode': 'per_point',
|
||||
'discount': 100,
|
||||
'discount_applicability': 'order',
|
||||
'required_points': 1,
|
||||
})],
|
||||
})
|
||||
cls.all_programs = cls.env['loyalty.program'].search([])
|
||||
class TestSaleCouponProgramNumbers(TestSaleCouponNumbersCommon):
|
||||
|
||||
def test_program_numbers_free_and_paid_product_qty(self):
|
||||
# These tests will focus on numbers (free product qty, SO total, reduction total..)
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="loyalty_program_view_form_inherit_sale_loyalty" model="ir.ui.view">
|
||||
<field name="name">loyalty.program.view.form.inherit.sale.loyalty</field>
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//label[@for='available_on']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="inside">
|
||||
<span class="d-inline-block">
|
||||
<field name="sale_ok" class="w-auto"/>
|
||||
<label for="sale_ok"/>
|
||||
</span>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_loyalty_config"
|
||||
action="loyalty.loyalty_program_action"
|
||||
id="menu_discount_loyalty_type_config"
|
||||
action="loyalty.loyalty_program_discount_loyalty_action"
|
||||
name="Discount & Loyalty"
|
||||
parent="sale.product_menu_catalog"
|
||||
name="Coupons & Loyalty"
|
||||
groups="sales_team.group_sale_manager"
|
||||
sequence="5"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_gift_ewallet_type_config"
|
||||
action="loyalty.loyalty_program_gift_ewallet_action"
|
||||
name="Gift cards & eWallet"
|
||||
parent="sale.product_menu_catalog"
|
||||
groups="sales_team.group_sale_manager"
|
||||
sequence="6"
|
||||
/>
|
||||
</odoo>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<odoo>
|
||||
|
||||
<template id="sale_loyalty.used_gift_card">
|
||||
<div class="text-muted d-md-block small" t-if="line.coupon_id">
|
||||
<div class="text-muted d-md-block small"
|
||||
t-if="line.coupon_id and line.order_id != line.coupon_id.order_id and not line.coupon_id.program_id.is_nominative">
|
||||
<span>
|
||||
Code:
|
||||
<t t-esc="line.coupon_id.code[-4:].rjust(14, '⋆')"/>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- No rewards -->
|
||||
<button special="cancel" string="Discard" class="btn btn-primary"
|
||||
attrs="{'invisible': [('reward_ids', '!=', [])]}" data-hotkey="z"/>
|
||||
<button type="action" name="%(loyalty.loyalty_program_action)d" string="Coupons & Loyalty" class="btn btn-secondary float-end"
|
||||
<button type="action" name="%(loyalty.loyalty_program_discount_loyalty_action)d" string="Coupons & Loyalty" class="btn btn-secondary float-end"
|
||||
attrs="{'invisible': [('reward_ids', '!=', [])]}" groups="sales_team.group_sale_manager" data-hotkey="q"/>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -762,7 +762,7 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onClickShowCoupon: function (ev) {
|
||||
$(ev.currentTarget).hide();
|
||||
$(".show_coupon").hide();
|
||||
$('.coupon_form').removeClass('d-none');
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -198,9 +198,9 @@
|
||||
<field name="module_sale_loyalty" />
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_sale_loyalty" string="Promotions, Coupons, Gift Card & Loyalty Program"/>
|
||||
<label for="module_sale_loyalty" string="Discounts, Loyalty & Gift Card"/>
|
||||
<div class="text-muted" id="website_sale_loyalty">
|
||||
Manage promotion that will grant customers discounts or gifts
|
||||
Manage Promotions, coupons, loyalty cards, Gift cards & eWallet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ odoo.define('website_sale_delivery.test', function (require) {
|
||||
},
|
||||
{
|
||||
content: "click on 'Pay with gift card'",
|
||||
trigger: '.js_show_gift_card',
|
||||
trigger: '.show_coupon',
|
||||
run: 'click'
|
||||
},
|
||||
{
|
||||
@@ -46,7 +46,7 @@ odoo.define('website_sale_delivery.test', function (require) {
|
||||
},
|
||||
{
|
||||
content: "click on 'Pay'",
|
||||
trigger: "button[type='submit'].a-submit:contains(Pay)",
|
||||
trigger: "a[role='button'].a-submit:contains(Apply)",
|
||||
run: 'click'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ Coupon & promotion programs can be edited in the Catalog menu of the Website app
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizard/sale_coupon_share_views.xml',
|
||||
'wizard/loyalty_generate_wizard_views.xml',
|
||||
'views/loyalty_card_views.xml',
|
||||
'views/loyalty_program_views.xml',
|
||||
'views/website_sale_templates.xml',
|
||||
|
||||
@@ -9,7 +9,7 @@ from werkzeug.urls import url_encode, url_parse
|
||||
|
||||
class WebsiteSale(main.WebsiteSale):
|
||||
|
||||
@http.route(['/shop/pricelist'])
|
||||
@http.route()
|
||||
def pricelist(self, promo, **post):
|
||||
order = request.website.sale_get_order()
|
||||
coupon_status = order._try_apply_code(promo)
|
||||
@@ -47,6 +47,8 @@ class WebsiteSale(main.WebsiteSale):
|
||||
url_parts = url_parse(r)
|
||||
url_query = url_parts.decode_query()
|
||||
url_query.pop('coupon_error', False) # trust only Odoo error message
|
||||
url_query.pop('coupon_error_type', False)
|
||||
code = code.strip()
|
||||
|
||||
request.session['pending_coupon_code'] = code
|
||||
order = request.website.sale_get_order()
|
||||
@@ -58,6 +60,7 @@ class WebsiteSale(main.WebsiteSale):
|
||||
url_query['notify_coupon'] = code
|
||||
else:
|
||||
url_query['coupon_error'] = _("The coupon will be automatically applied when you add something in your cart.")
|
||||
url_query['coupon_error_type'] = 'warning'
|
||||
redirect = url_parts.replace(query=url_encode(url_query))
|
||||
return request.redirect(redirect.to_url())
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
from odoo import fields, models
|
||||
|
||||
class LoyaltyProgram(models.Model):
|
||||
_name = 'loyalty.program'
|
||||
_inherit = ['loyalty.program', 'website.multi.mixin']
|
||||
|
||||
ecommerce_ok = fields.Boolean("Available on Website", default=True)
|
||||
|
||||
def action_program_share(self):
|
||||
self.ensure_one()
|
||||
return self.env['coupon.share'].create_share_action(program=self)
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.osv import expression
|
||||
from odoo.http import request
|
||||
|
||||
@@ -12,10 +13,24 @@ class SaleOrder(models.Model):
|
||||
|
||||
def _get_program_domain(self):
|
||||
res = super()._get_program_domain()
|
||||
# Replace `sale_ok` leaf with `ecommerce_ok` if order is linked to a website
|
||||
if self.website_id:
|
||||
for idx, leaf in enumerate(res):
|
||||
if leaf[0] != 'sale_ok':
|
||||
continue
|
||||
res[idx] = ('ecommerce_ok', '=', True)
|
||||
break
|
||||
return expression.AND([res, [('website_id', 'in', (self.website_id.id, False))]])
|
||||
|
||||
def _get_trigger_domain(self):
|
||||
res = super()._get_trigger_domain()
|
||||
# Replace `sale_ok` leaf with `ecommerce_ok` if order is linked to a website
|
||||
if self.website_id:
|
||||
for idx, leaf in enumerate(res):
|
||||
if leaf[0] != 'program_id.sale_ok':
|
||||
continue
|
||||
res[idx] = ('program_id.ecommerce_ok', '=', True)
|
||||
break
|
||||
return expression.AND([res, [('program_id.website_id', 'in', (self.website_id.id, False))]])
|
||||
|
||||
def _try_pending_coupon(self):
|
||||
@@ -59,9 +74,12 @@ class SaleOrder(models.Model):
|
||||
coupon.program_id.is_nominative or\
|
||||
(rewards.reward_type == 'product' and rewards.multi_product):
|
||||
continue
|
||||
res = self._apply_program_reward(rewards, coupon)
|
||||
if 'error' not in res:
|
||||
claimed_reward_count += 1
|
||||
try:
|
||||
res = self._apply_program_reward(rewards, coupon)
|
||||
if 'error' not in res:
|
||||
claimed_reward_count += 1
|
||||
except UserError:
|
||||
pass
|
||||
|
||||
return bool(claimed_reward_count)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ const CouponToasterWidget = publicWidget.Widget.extend({
|
||||
this.displayNotification(Object.assign({type: 'success'}, options));
|
||||
} else if (this.$el.hasClass('coupon-error-message')) {
|
||||
this.displayNotification(Object.assign({type: 'danger'}, options));
|
||||
} else if (this.$el.hasClass('coupon-warning-message')) {
|
||||
this.displayNotification(Object.assign({type: 'warning'}, options));
|
||||
}
|
||||
|
||||
return this._super(...arguments);
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
|
||||
import publicWidget from 'web.public.widget';
|
||||
|
||||
publicWidget.registry.WebsiteSaleGiftCard = publicWidget.Widget.extend({
|
||||
selector: '.oe_website_sale_gift_card',
|
||||
events: {
|
||||
'click .js_show_gift_card': '_onShowGiftCardClick',
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onShowGiftCardClick: function (ev) {
|
||||
$(ev.currentTarget).hide();
|
||||
this.$('.gift_card_form').removeClass('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
publicWidget.registry.WebsiteSaleGiftCardCopy = publicWidget.Widget.extend({
|
||||
selector: '.o_purchased_gift_card',
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_loyalty.tests.test_program_numbers import TestSaleCouponProgramNumbers
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponNumbersCommon
|
||||
from odoo.addons.website.tools import MockRequest
|
||||
from odoo.addons.website_sale_loyalty.controllers.main import WebsiteSale
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestSaleCouponApplyPending(TestSaleCouponProgramNumbers):
|
||||
class TestSaleCouponApplyPending(HttpCase, TestSaleCouponNumbersCommon):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_loyalty.tests.test_program_numbers import TestSaleCouponProgramNumbers
|
||||
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponNumbersCommon
|
||||
from odoo.addons.website.tools import MockRequest
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestSaleCouponMultiwebsite(TestSaleCouponProgramNumbers):
|
||||
class TestSaleCouponMultiwebsite(TestSaleCouponNumbersCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<menuitem
|
||||
id="menu_promotion_type_config"
|
||||
action="loyalty.loyalty_program_action"
|
||||
name="Coupons & Loyalty"
|
||||
id="menu_discount_loyalty_type_config"
|
||||
action="loyalty.loyalty_program_discount_loyalty_action"
|
||||
name="Discount & Loyalty"
|
||||
parent="website_sale.menu_catalog"
|
||||
groups="sales_team.group_sale_manager"
|
||||
sequence="50"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
id="menu_gift_ewallet_type_config"
|
||||
action="loyalty.loyalty_program_gift_ewallet_action"
|
||||
name="Gift cards & eWallet"
|
||||
parent="website_sale.menu_catalog"
|
||||
groups="sales_team.group_sale_manager"
|
||||
sequence="51"
|
||||
/>
|
||||
|
||||
<record id="loyalty_program_view_form_inherit_website_sale_loyalty" model="ir.ui.view">
|
||||
<field name="name">loyalty.program.view.form.inherit.website.sale.loyalty</field>
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
|
||||
<field name="inherit_id" ref="sale_loyalty.loyalty_program_view_form_inherit_sale_loyalty"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="currency_id" position="after">
|
||||
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
|
||||
</field>
|
||||
<xpath expr="//label[@for='available_on']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="attributes">
|
||||
<attribute name="invisible">0</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="inside">
|
||||
<span class="d-inline-block">
|
||||
<field name="ecommerce_ok" class="w-auto"/>
|
||||
<label for="ecommerce_ok" string="Website"/>
|
||||
</span>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="after">
|
||||
<field name="website_id" attrs="{'invisible': [('ecommerce_ok', '=', False)]}" options="{'no_create': True}" groups="website.group_multi_website" placeholder="All websites"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -25,11 +46,12 @@
|
||||
<field name="model">loyalty.program</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_program_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="name" position="after">
|
||||
<field name="website_id" groups="website.group_multi_website"/>
|
||||
<button name="action_program_share" string="Share" type="object" icon="fa-share-alt"/>
|
||||
<field name="coupon_count_display" position="after">
|
||||
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
|
||||
</field>
|
||||
<field name="company_id" position="after">
|
||||
<button name="action_program_share" string="Share" type="object" icon="fa-share-alt" attrs="{ 'invisible' : [('program_type', '!=', 'promo_code')]}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<xpath expr="//div[@id='website_sale_loyalty']" position="after">
|
||||
<div class="content-group">
|
||||
<div class="mt8" attrs="{'invisible': [('module_sale_loyalty', '=', False)]}">
|
||||
<button name="%(loyalty.loyalty_program_action)d" icon="fa-arrow-right" type="action" string="Loyalty Programs" class="btn-link"/>
|
||||
<button name="%(loyalty.loyalty_program_discount_loyalty_action)d" icon="fa-arrow-right" type="action" string="Loyalty Programs" class="btn-link"/>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="pay_with_gift_card_form">
|
||||
<form t-att-action="'/shop/pricelist?gift_card=1%s' % (redirect and '&r=' + redirect or '')" class="mb-2" method="post" name="gift_card_code">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<div class="input-group w-100">
|
||||
<input name="promo" class="form-control" type="text" required="required" placeholder="Gift card code..."/>
|
||||
<button href="#" type="submit" role="button" class="btn btn-secondary a-submit">Pay</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template id="sale_coupon_result" inherit_id="website_sale.coupon_form">
|
||||
<xpath expr="//form[@name='coupon_code']//input[@name='promo']" position="attributes">
|
||||
<attribute name="placeholder">code...</attribute>
|
||||
<attribute name="placeholder">Gift card or discount code...</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//t[@name='code_not_available']" position="replace"/>
|
||||
</template>
|
||||
@@ -22,13 +12,6 @@
|
||||
<tr t-if="not hide_promotions" class="oe_website_sale_gift_card">
|
||||
<td colspan="3" class="text-center text-xl-end border-0">
|
||||
<span class=''>
|
||||
<t t-set='force_gift_card' t-value="request.params.get('gift_card_error')"/>
|
||||
<t t-if="not force_gift_card">
|
||||
<a href="#" class="js_show_gift_card">Use a gift card</a>
|
||||
</t>
|
||||
<div t-attf-class="gift_card_form #{not force_gift_card and 'd-none'}">
|
||||
<t t-call="website_sale_loyalty.pay_with_gift_card_form"/>
|
||||
</div>
|
||||
<t t-if="request.params.get('code_not_available')">
|
||||
<div class="alert alert-danger text-start mt16" role="alert">
|
||||
Invalid or expired promo code.
|
||||
@@ -52,25 +35,27 @@
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="reward" t-att-value="reward.id"/>
|
||||
<div class="alert alert-success text-start mt16" role="alert">
|
||||
<div class="row">
|
||||
<div class="col-8">
|
||||
<div class="d-flex flex-row">
|
||||
<div class="flex-grow-1">
|
||||
<strong t-esc="reward.description"/>
|
||||
<div t-if="reward.program_id.portal_visible">
|
||||
<t t-if="not reward.program_id.is_nominative"><span t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/> <span t-out="reward.point_name"/></t>
|
||||
<t t-else="">
|
||||
<span>You have <t t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/></span> <span t-out="reward.point_name"/>
|
||||
<span t-if="reward.program_id.program_type == 'ewallet'"> in your ewallet</span>
|
||||
<t t-if="reward.program_id.program_type != 'ewallet'">
|
||||
<br/>
|
||||
<span>Costs <t t-out="reward.required_points"/></span> <span t-out="reward.point_name"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="justify-content-end">
|
||||
<a class="btn btn-primary a-submit" href="#" role="button">
|
||||
<t t-if="reward.program_id.program_type == 'ewallet'">Pay with eWallet</t>
|
||||
<t t-else="">Claim</t>
|
||||
</a>
|
||||
</div>
|
||||
<div t-if="reward.program_id.portal_visible" class="col-12">
|
||||
<t t-if="not reward.program_id.is_nominative"><span t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/> <span t-out="reward.point_name"/></t>
|
||||
<t t-else="">
|
||||
<span>You have <t t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/></span> <span t-out="reward.point_name"/>
|
||||
<span t-if="reward.program_id.program_type == 'ewallet'"> in your ewallet</span>
|
||||
<br/>
|
||||
<span>Costs <t t-out="reward.required_points"/></span> <span t-out="reward.point_name"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -94,7 +79,7 @@
|
||||
<t t-set="coupon_error" t-value="request.params.get('coupon_error')"/>
|
||||
<t t-set="pending_coupon_code" t-value="request.session.get('pending_coupon_code')"/>
|
||||
<t t-if="coupon_error and pending_coupon_code">
|
||||
<div class="d-none coupon-message coupon-error-message">
|
||||
<div t-attf-class="d-none coupon-message coupon-{{ request.params.get('coupon_error_type', 'error') }}-message">
|
||||
<span class="coupon-message-title">Could not apply the promo code: <t t-out="pending_coupon_code"/></span>
|
||||
<span class="coupon-message-content" t-out="coupon_error"/>
|
||||
</div>
|
||||
@@ -126,6 +111,9 @@
|
||||
<xpath expr="//t[@t-set='force_coupon']" position="replace">
|
||||
<t t-set='force_coupon' t-value="website_sale_order.pricelist_id.code or request.params.get('code_not_available') or website_sale_order.get_promo_code_error(delete=False)"/>
|
||||
</xpath>
|
||||
<xpath expr="//a" position="replace">
|
||||
<a href="#" class="show_coupon">Discount code or gift card</a>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="cart_summary" name="Payment" inherit_id="website_sale.cart_summary">
|
||||
@@ -135,23 +123,18 @@
|
||||
</template>
|
||||
|
||||
<template id="cart_line_product_no_link" inherit_id="website_sale.cart_line_product_link">
|
||||
<xpath expr="." position="replace">
|
||||
<div>
|
||||
<t t-if="line.is_reward_line">
|
||||
<strong t-field="line.name"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<a t-att-href="line.product_id.website_url">
|
||||
<strong t-field="line.name_short"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
<xpath expr="//a" position="replace">
|
||||
<t t-if="line.is_reward_line">
|
||||
<strong t-field="line.name"/>
|
||||
<t t-call="sale_loyalty.used_gift_card"/>
|
||||
</t>
|
||||
<t t-else="">$0</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="cart_summary_inherit_website_gift_card_sale" inherit_id="website_sale.cart_summary">
|
||||
<xpath expr="//td[hasclass('td-product_name')]/div/strong" 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"/>
|
||||
<t t-call="sale_loyalty.used_gift_card"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import loyalty_generate_wizard
|
||||
from . import sale_coupon_share
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class LoyaltyGenerateWizard(models.TransientModel):
|
||||
_inherit = 'loyalty.generate.wizard'
|
||||
|
||||
website_id = fields.Many2one('website', 'Website')
|
||||
|
||||
def _get_coupon_values(self, partner):
|
||||
res = super()._get_coupon_values(partner)
|
||||
if self.website_id:
|
||||
res.update(website_id=self.website_id.id)
|
||||
return res
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="loyalty_generate_wizard_view_form_inherit_website_sale_loyalty" model="ir.ui.view">
|
||||
<field name="name">loyalty.generate.wizard.view.form.inherit.website.sale.loyalty</field>
|
||||
<field name="model">loyalty.generate.wizard</field>
|
||||
<field name="inherit_id" ref="loyalty.loyalty_generate_wizard_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='valid_until']" position="after">
|
||||
<field name="website_id" groups="website.group_multi_website"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -68,7 +68,7 @@ class SaleCouponShare(models.TransientModel):
|
||||
|
||||
def action_generate_short_link(self):
|
||||
return {
|
||||
'name': _('Share Coupon'),
|
||||
'name': _('Share'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'coupon.share',
|
||||
@@ -85,7 +85,7 @@ class SaleCouponShare(models.TransientModel):
|
||||
raise UserError(_("Provide either a coupon or a program."))
|
||||
|
||||
return {
|
||||
'name': _('Share Coupon'),
|
||||
'name': _('Share') + f' {self.env["loyalty.program"]._program_items_name().get((program or coupon).program_type, "")}',
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'coupon.share',
|
||||
|
||||
@@ -7,20 +7,21 @@
|
||||
<form string="Share Loyalty Card" edit="1" create="0">
|
||||
<field name="website_id" invisible="1"/>
|
||||
<field name="program_website_id" invisible="1"/>
|
||||
<group attrs="{'invisible': [('website_id', '=', False)]}">
|
||||
<group>
|
||||
<div colspan="2">
|
||||
<p>
|
||||
You can share this promotion with your customers.
|
||||
It will be applied at checkout when the customer uses this link.
|
||||
</p>
|
||||
</div>
|
||||
<field name="share_link" widget="CopyClipboardURL" nolabel="1"/>
|
||||
<field name="share_link" widget="CopyClipboardURL" no_label="1"/>
|
||||
</group>
|
||||
<hr attrs="{'invisible': ['|', ('website_id', '=', False), ('id', '!=', False)]}"/>
|
||||
<group attrs="{'invisible': [('id', '!=', False)]}">
|
||||
<field name="website_id" groups="website.group_multi_website" widget="selection"
|
||||
attrs="{'invisible': [('program_website_id', '!=', False)]}"/>
|
||||
<field name="redirect"/>
|
||||
<group>
|
||||
<field name="website_id" groups="website.group_multi_website" widget="selection"
|
||||
attrs="{'invisible': [('program_website_id', '!=', False)]}"/>
|
||||
<field name="redirect"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Done" class="btn-primary" special="cancel" data-hotkey="z"/>
|
||||
|
||||
Reference in New Issue
Block a user