[IMP] website_sale: manage abandoned cart

Purpose: Add button to see the number of
abandoned carts and the ability to send
a recovery email to those customers.

On the abandoned carts menu, you can see
the SO from the website where no reminder
email has been sent.
By selecting them, you can send an email
to the customer to tell him there is still
a cart waiting.
Clicking on the link, he can reuse his old
cart or merge it with his current one.

An abandoned cart is a SO from a website
sales team, and created after a treshold
parametered by a 'delay' settings (number
of hours to be considered as abandoned).
This commit is contained in:
Christophe Monniez
2017-09-15 16:55:17 +02:00
committed by Jérome Maes
parent 35e80c1ab5
commit bfa85c84a6
15 changed files with 263 additions and 24 deletions
@@ -21,7 +21,7 @@
h4 { margin: 0 0 0 8px; }
h2, h4 { color: @odoo-brand-primary; }
.col-md-5, .col-md-7 {
padding: 15px;
@@ -83,7 +83,7 @@
}
.nv-groups .nv-point, g circle {
fill: @odoo-brand-optional;
fill: @odoo-brand-optional;
}
.nv-groups .nv-point {
@@ -137,6 +137,7 @@
height: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.o_main {
background-color: @odoo-brand-optional;
&:hover {
+1
View File
@@ -12,6 +12,7 @@
'security/website_sale.xml',
'data/data.xml',
'data/web_planner_data.xml',
'data/mail_template_data.xml',
'views/product_views.xml',
'views/account_views.xml',
'views/sale_report_views.xml',
+2 -4
View File
@@ -86,10 +86,8 @@ class WebsiteSaleBackend(WebsiteBackend):
('invoice_status', '=', 'to invoice'),
]),
order_carts_abandoned_count=request.env['sale.order'].search_count(sale_order_domain + [
('state', '=', 'draft'),
('order_line', '!=', 'False'),
('partner_id', '!=', request.env.ref('base.public_partner').id),
('date_order', '<=', fields.Datetime.to_string(datetime.now() - timedelta(hours=1))),
('is_abandoned_cart', '=', True),
('cart_recovery_email_sent', '=', False)
]),
payment_to_capture_count=request.env['payment.transaction'].search_count([
('state', '=', 'authorized'),
+24 -3
View File
@@ -332,8 +332,29 @@ class WebsiteSale(http.Controller):
return request.redirect(redirect)
@http.route(['/shop/cart'], type='http', auth="public", website=True)
def cart(self, **post):
def cart(self, access_token=None, revive='', **post):
"""
Main cart management + abandoned cart revival
access_token: Abandoned cart SO access token
revive: Revival method when abandoned cart. Can be 'merge' or 'squash'
"""
order = request.website.sale_get_order()
values = {}
if access_token:
abandoned_order = request.env['sale.order'].sudo().search([('access_token', '=', access_token)], limit=1)
if not abandoned_order: # wrong token (or SO has been deleted)
return request.render('website.404')
if abandoned_order.state != 'draft': # abandoned cart already finished
values.update({'abandoned_proceed': True})
elif revive == 'squash' or (revive == 'merge' and not request.session['sale_order_id']): # restore old cart or merge with unexistant
request.session['sale_order_id'] = abandoned_order.id
return request.redirect('/shop/cart')
elif revive == 'merge':
abandoned_order.order_line.write({'order_id': request.session['sale_order_id']})
abandoned_order.action_cancel()
elif abandoned_order.id != request.session['sale_order_id']: # abandoned cart found, user have to choose what to do
values.update({'access_token': abandoned_order.access_token})
if order:
from_currency = order.company_id.currency_id
to_currency = order.pricelist_id.currency_id
@@ -341,11 +362,11 @@ class WebsiteSale(http.Controller):
else:
compute_currency = lambda price: price
values = {
values.update({
'website_sale_order': order,
'compute_currency': compute_currency,
'suggested_products': [],
}
})
if order:
_order = order
if not request.env.context.get('pricelist'):
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_sale_cart_recovery" model="mail.template">
<field name="name">Sale Order: Cart Recovery Email</field>
<field name="email_from">${(object.user_id.email or '')|safe}</field>
<field name="subject">You left items in your cart.</field>
<field name="partner_to">${object.partner_id.id}</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="auto_delete" eval="True"/>
<field name="lang">${object.partner_id.lang}</field>
<field name="body_html" type="xml">
<div style="margin:auto;width:100%;">
<img src="/web/image/res.company/${user.company_id.id}/logo" style="height: auto; width: 80px; margin-top:30px;" alt="${user.company_id.name}"/>
<h1 style="color:#A9A9A9;">THERE'S SOMETHING IN YOUR CART.</h1>
<p>Would you like to complete your purchase?</p><br/><br/>
% if object.order_line:
% for line in object.order_line:
<hr/>
<table width="100%">
<tr>
<td style="padding: 10px; width:150px;">
<img src="/web/image/product.product/${line.product_id.id}/image" height="100px" width="100px"></img>
</td>
<td>
<strong>${line.product_id.display_name}</strong><br/>${line.name}
</td>
<td width="100px" align="right">
${(line.product_uom_qty) | int} ${(line.product_uom.name)}
</td>
</tr>
</table>
% endfor
<hr/>
% endif
<br/><br/>
<center>
<a href="/shop/cart?access_token=${object.access_token}" target="_blank" style="background-color: #1abc9c; padding: 20px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;" class="o_default_snippet_text">Resume order</a><br/><br/><br/>
<p><b>Thank you for shopping with ${user.company_id.name}!</b></p>
</center>
</div>
</field>
</record>
</data>
</odoo>
+1
View File
@@ -3,6 +3,7 @@
from . import crm_team
from . import ir_http
from . import mail_compose_message
from . import product
from . import sale_order
from . import website
+11 -9
View File
@@ -19,16 +19,14 @@ class CrmTeam(models.Model):
string='Amount of Abandoned Carts', readonly=True)
def _compute_abandoned_carts(self):
# abandoned carts are draft sales orders that have no order lines,
# abandoned carts to recover are draft sales orders that have no order lines,
# a partner other than the public user, and created over an hour ago
# and the recovery mail was not yet sent
website_teams = self.filtered(lambda team: team.team_type == 'website')
if website_teams:
abandoned_carts_data = self.env['sale.order'].read_group([
('team_id', 'in', website_teams.ids),
('date_order', '<', fields.Datetime.to_string(datetime.now() - relativedelta(hours=1))),
('state', '=', 'draft'),
('partner_id', '!=', self.env.ref('base.public_partner').id),
('order_line', '!=', False),
('is_abandoned_cart', '=', True),
('cart_recovery_email_sent', '=', False)
], ['amount_total', 'team_id'], ['team_id'])
counts = {data['team_id'][0]: data['team_id_count'] for data in abandoned_carts_data}
amounts = {data['team_id'][0]: data['amount_total'] for data in abandoned_carts_data}
@@ -43,15 +41,19 @@ class CrmTeam(models.Model):
def get_abandoned_carts(self):
self.ensure_one()
time_constraint = fields.Datetime.to_string(fields.datetime.now() - relativedelta(hours=1))
return {
'name': _('Abandoned Carts'),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'tree,form',
'domain': [('state', '=', 'draft'), ('partner_id', '!=', self.env.ref('base.public_partner').id), ('date_order', '<', time_constraint)],
'domain': [('is_abandoned_cart', '=', True)],
'search_view_id': self.env.ref('sale.sale_order_view_search_inherit_sale').id,
'context': {'search_default_team_id': self.id, 'default_team_id': self.id},
'context': {
'search_default_team_id': self.id,
'default_team_id': self.id,
'search_default_recovery_email': 1,
'create': False
},
'res_model': 'sale.order',
'help': _('''<p class="oe_view_nocontent_create">
You can find all abandoned carts here, i.e. the carts generated by your website's visitors from over an hour ago that haven't been confirmed yet.</p>
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
from odoo import api, fields, models
class MailComposeMessage(models.TransientModel):
_inherit = 'mail.compose.message'
@api.multi
def send_mail(self, auto_commit=False):
context = self._context
# TODO TDE: clean that brole one day
if self.model == 'sale.order':
self.env['sale.order'].search([
('id', 'in', context.get('active_ids')),
('cart_recovery_email_sent', '!=', True),
('is_abandoned_cart', '=', True)
]).write({'cart_recovery_email_sent': True})
self = self.with_context(mail_post_autofollow=True)
return super(MailComposeMessage, self).send_mail(auto_commit=auto_commit)
@@ -12,6 +12,12 @@ class ResConfigSettings(models.TransientModel):
else:
return self.env.ref('sale.email_template_edi_sale').id
def _default_recovery_mail_template(self):
try:
return self.env.ref('website_sale.mail_template_sale_cart_recovery').id
except ValueError:
return False
salesperson_id = fields.Many2one('res.users', related='website_id.salesperson_id', string='Salesperson')
salesteam_id = fields.Many2one('crm.team', related='website_id.salesteam_id', string='Sales Channel', domain=[('team_type', '!=', 'pos')])
module_website_sale_delivery = fields.Boolean("Shipping Costs")
@@ -41,6 +47,10 @@ class ResConfigSettings(models.TransientModel):
module_l10n_eu_service = fields.Boolean(string="EU Digital Goods VAT")
cart_recovery_mail_template = fields.Many2one('mail.template', string='Cart Recovery Email',
default=_default_recovery_mail_template, domain="[('model', '=', 'sale.order')]")
cart_abandoned_delay = fields.Float("Abandoned Delay", default=1.0, help="number of hours after which the cart is considered abandoned")
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
@@ -52,9 +62,15 @@ class ResConfigSettings(models.TransientModel):
if self.env['ir.module.module'].search([('name', '=', 'website_sale_delivery')], limit=1).state in ('installed', 'to install', 'to upgrade'):
sale_delivery_settings = 'website'
cart_recovery_mail_template = int(params.get_param('website_sale.cart_recovery_mail_template_id'))
if not cart_recovery_mail_template:
cart_recovery_mail_template = self._default_recovery_mail_template()
res.update(
automatic_invoice=params.get_param('website_sale.automatic_invoice', default=False),
sale_delivery_settings=sale_delivery_settings,
cart_recovery_mail_template=cart_recovery_mail_template,
cart_abandoned_delay=float(params.get_param('website_sale.cart_abandoned_delay', '1.0'))
)
return res
@@ -62,6 +78,8 @@ class ResConfigSettings(models.TransientModel):
super(ResConfigSettings, self).set_values()
value = self.module_account_invoicing and self.default_invoice_policy == 'order' and self.automatic_invoice
self.env['ir.config_parameter'].sudo().set_param('website_sale.automatic_invoice', value)
self.env['ir.config_parameter'].sudo().set_param('website_sale.cart_recovery_mail_template_id', self.cart_recovery_mail_template.id)
self.env['ir.config_parameter'].sudo().set_param('website_sale.cart_abandoned_delay', self.cart_abandoned_delay)
@api.onchange('sale_delivery_settings')
def _onchange_sale_delivery_settings(self):
+55
View File
@@ -2,9 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import random
from datetime import datetime
from dateutil.relativedelta import relativedelta
from odoo import api, models, fields, _
from odoo.http import request
from odoo.osv import expression
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
@@ -24,6 +27,8 @@ class SaleOrder(models.Model):
string="Can be directly marked as paid", store=True,
help="""Checked if the sales order can directly be marked as paid, i.e. if the quotation
is sent or confirmed and if the payment acquire is of the type transfer or manual""")
is_abandoned_cart = fields.Boolean('Abandoned Cart', compute='_compute_abandoned_cart', search='_search_abandoned_cart')
cart_recovery_email_sent = fields.Boolean('Cart recovery email already sent')
@api.depends('state', 'payment_tx_id', 'payment_tx_id.state',
'payment_acquirer_id', 'payment_acquirer_id.provider')
@@ -38,6 +43,30 @@ class SaleOrder(models.Model):
order.cart_quantity = int(sum(order.mapped('website_order_line.product_uom_qty')))
order.only_services = all(l.product_id.type in ('service', 'digital') for l in order.website_order_line)
@api.multi
@api.depends('team_id.team_type', 'date_order', 'order_line', 'state', 'partner_id')
def _compute_abandoned_cart(self):
abandoned_delay = float(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_abandoned_delay', '1.0'))
abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay))
for order in self:
domain = order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line
order.is_abandoned_cart = bool(domain)
def _search_abandoned_cart(self, operator, value):
abandoned_delay = float(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_abandoned_delay', '1.0'))
abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay))
abandoned_domain = expression.normalize_domain([
('date_order', '<=', abandoned_datetime),
('team_id.team_type', '=', 'website'),
('state', '=', 'draft'),
('partner_id.id', '!=', self.env.ref('base.public_partner').id),
('order_line', '!=', False)
])
# is_abandoned domain possibilities
if (operator not in expression.NEGATIVE_TERM_OPERATORS and value) or (operator in expression.NEGATIVE_TERM_OPERATORS and not value):
return abandoned_domain
return expression.distribute_not(abandoned_domain) # negative domain
@api.multi
def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs):
self.ensure_one()
@@ -173,6 +202,32 @@ class SaleOrder(models.Model):
accessory_products -= order.website_order_line.mapped('product_id')
return random.sample(accessory_products, len(accessory_products))
@api.multi
def action_recovery_email_send(self):
composer_form_view_id = self.env.ref('mail.email_compose_message_wizard_form').id
try:
default_template = self.env.ref('website_sale.mail_template_sale_cart_recovery', raise_if_not_found=False)
default_template_id = default_template.id if default_template else False
template_id = int(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_recovery_mail_template_id', default_template_id))
except:
template_id = False
return {
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'mail.compose.message',
'view_id': composer_form_view_id,
'target': 'new',
'context': {
'default_composition_mode': 'mass_mail' if len(self) > 1 else 'comment',
'default_res_id': self.ids[0],
'default_model': 'sale.order',
'default_use_template': bool(template_id),
'default_template_id': template_id,
'active_ids': self.ids,
},
}
def action_mark_as_paid(self):
""" Mark directly a sales order as paid if:
- State: Quotation Sent, or sales order
@@ -19,7 +19,7 @@
<div t-if="widget.dashboards_data.sales.summary.order_carts_abandoned_count" class="o_inner_box o_main o_box_item" title="Send a recovery email to visitors who haven't completed their order.">
<a href="#" class="o_dashboard_action" name="website_sale.action_abandoned_orders_ecommerce">
<div class="o_highlight"><t t-esc="widget.dashboards_data.sales.summary.order_carts_abandoned_count"/></div>
Abandoned Carts
Abandoned Carts to Recover
</a>
<t t-set="vignette_nbr" t-value="vignette_nbr + 1"/>
</div>
+1 -2
View File
@@ -16,8 +16,7 @@
<div>
<a name="get_abandoned_carts" type="object">
<field name="abandoned_carts_count"/>
<t t-if="record.abandoned_carts_count.raw_value != 1">Abandoned Carts</t>
<t t-else="">Abandoned Cart</t>
Abandoned Carts to Recover
</a>
</div>
</div>
@@ -344,6 +344,27 @@
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box" id="abandoned_carts_setting" title="Send a recovery email to authentified shoppers">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label string="Email for Abandonned carts"/>
<div class="text-muted">
Default recovery email to send when a cart is abandoned
</div>
<div class="content-group">
<div class="row mt16">
<label for="cart_recovery_mail_template" string="Email Template" class="col-md-3 o_light_label"/>
<field name="cart_recovery_mail_template" class="oe_inline"/>
</div>
</div>
<div class="content-group">
<div class="row mt16">
<label for="cart_abandoned_delay" string="Cart are considered abandoned after" class="col-md-6 o_light_label"/>
<span class="col-md-3"><field name="cart_abandoned_delay"/>hours</span>
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box" id="checkout_assignation_setting" groups="base.group_no_one">
<div class="o_setting_right_pane">
<label string="Assignation"/>
+43 -3
View File
@@ -7,10 +7,37 @@
<field name="arch" type="xml">
<search>
<filter string="Confirmed Orders" name="order_confirmed" domain="[('state', 'in', ('sale', 'done'))]"/>
<separator/>
<filter string="From Website" name="from_website" domain="[('team_id.team_type', '=', 'website')]"/>
<separator/>
<filter string="Recovery Email to Send" name="recovery_email" domain="[('cart_recovery_email_sent', '=', False)]" />
</search>
</field>
</record>
<record id="sale_order_view_form_cart_recovery" model="ir.ui.view">
<field name="name">sale.order.form.abandoned.cart</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='team_id']" position="after">
<field name="is_abandoned_cart" invisible="1"/>
<field name="cart_recovery_email_sent" invisible="1"/>
</xpath>
<xpath expr="//button[@name='action_quotation_send' and @states='draft']" position="attributes">
<!-- The '| and the '&amp' opertors are necessary because draft state of the parent concatenate the domain -->
<attribute name="attrs">{'invisible': ['|','&amp;',('is_abandoned_cart', '=', True), ('cart_recovery_email_sent', '=', False)]}</attribute>
</xpath>
<xpath expr="//button[@name='action_quotation_send']" position="after">
<button name="action_recovery_email_send"
string="Send a Recovery Email"
type="object"
class="btn-primary"
attrs="{'invisible': ['|', ('is_abandoned_cart', '=', False), ('cart_recovery_email_sent', '=', True)]}"/>
</xpath>
</field>
</record>
<record id="action_orders_ecommerce" model="ir.actions.act_window">
<field name="name">Orders</field>
<field name="res_model">sale.order</field>
@@ -35,8 +62,8 @@
<field name="name">Abandoned Carts</field>
<field name="res_model">sale.order</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('state', '=', 'draft'), ('order_line', '!=', False), ('partner_id','!=', context.get('public_partner_id')), ('team_id.team_type', '=', 'website'), ('date_order', '&lt;=', (datetime.datetime.utcnow() - datetime.timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S'))]</field>
<field name="context" eval="{'show_sale': True, 'create': False, 'public_partner_id': ref('base.public_partner')}"/>
<field name="domain">[('is_abandoned_cart', '=', True)]</field>
<field name="context" eval="{'show_sale': True, 'create': False, 'search_default_recovery_email' : 1}"/>
<field name="search_view_id" ref="view_sales_order_filter_ecommerce"/>
<field name="help" type="xml">
<p>Find here all the abandoned carts, i.e. the carts generated by your website's visitors more than one hour ago and not confirmed.</p>
@@ -53,6 +80,19 @@
<field name="help">You don't have any order from the website.</field>
</record>
<!-- Server action to send multiple recovery email-->
<record id="ir_actions_server_sale_cart_recovery_email" model="ir.actions.server">
<field name="name">Sale: Send a Cart Recovery Email</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="model_sale_order"/>
<field name="state">code</field>
<field name="code">
if records:
action = records.action_recovery_email_send()
</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
</record>
<!-- Main website sale menu items -->
<menuitem id="menu_orders" name="Orders"
parent="website.menu_website_configuration" sequence="2"
@@ -66,7 +106,7 @@
<menuitem id="website.menu_website_global_configuration" name="Configuration"
parent="website.menu_website_configuration" sequence="100"
groups="base.group_system,sales_team.group_sale_manager"/>
<menuitem id="menu_ecommerce_settings" name="eCommerce"
parent="website.menu_website_global_configuration"/>
<menuitem id="menu_product_settings" name="Products"
+15
View File
@@ -879,6 +879,20 @@
</div>
<div class="row">
<div class="col-md-8 col-sm-9 oe_cart">
<div t-if="abandoned_proceed or access_token" class="mt8 mb8 alert alert-info"> <!-- abandoned cart choices -->
<t t-if="abandoned_proceed">
<p>Your previous cart has already been completed.</p>
<p t-if="website_sale_order">Please proceed your current cart.</p>
</t>
<t t-if="access_token">
<p>This is your current cart.</p>
<p>
<strong><a t-attf-href="/shop/cart/?access_token=#{access_token}&amp;revive=squash">Click here</a></strong> if you want to restore your previous cart. Your current cart will be replaced with your previous cart.</p>
<p>
<strong><a t-attf-href="/shop/cart/?access_token=#{access_token}&amp;revive=merge">Click here</a></strong> if you want to merge your previous cart into current cart.
</p>
</t>
</div>
<t t-call="website_sale.cart_lines" />
<div class="clearfix" />
<a t-if="not optional_products and website_sale_order and website_sale_order.website_order_line" class="btn btn-primary pull-right mb32" href="/shop/checkout">
@@ -1648,4 +1662,5 @@
</div>
</t>
</template>
</odoo>