From bfa85c84a661e2584600ae90771f8a900206c0a3 Mon Sep 17 00:00:00 2001 From: Christophe Monniez Date: Thu, 9 Feb 2017 11:05:31 +0530 Subject: [PATCH] [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). --- .../src/less/website.backendDashboard.less | 5 +- addons/website_sale/__manifest__.py | 1 + addons/website_sale/controllers/backend.py | 6 +- addons/website_sale/controllers/main.py | 27 ++++++++- .../website_sale/data/mail_template_data.xml | 45 +++++++++++++++ addons/website_sale/models/__init__.py | 1 + addons/website_sale/models/crm_team.py | 20 ++++--- .../models/mail_compose_message.py | 22 ++++++++ .../models/res_config_settings.py | 18 ++++++ addons/website_sale/models/sale_order.py | 55 +++++++++++++++++++ .../static/src/xml/website_sale_dashboard.xml | 2 +- addons/website_sale/views/crm_team_views.xml | 3 +- .../views/res_config_settings_views.xml | 21 +++++++ .../website_sale/views/sale_order_views.xml | 46 +++++++++++++++- addons/website_sale/views/templates.xml | 15 +++++ 15 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 addons/website_sale/data/mail_template_data.xml create mode 100644 addons/website_sale/models/mail_compose_message.py diff --git a/addons/website/static/src/less/website.backendDashboard.less b/addons/website/static/src/less/website.backendDashboard.less index ffe6b76294a..e52e0f81594 100644 --- a/addons/website/static/src/less/website.backendDashboard.less +++ b/addons/website/static/src/less/website.backendDashboard.less @@ -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 { diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 52313bb29f5..1f58a13f58d 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -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', diff --git a/addons/website_sale/controllers/backend.py b/addons/website_sale/controllers/backend.py index 90a83208c9b..98a5a06b342 100644 --- a/addons/website_sale/controllers/backend.py +++ b/addons/website_sale/controllers/backend.py @@ -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'), diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index e51e89f8d4a..0682cccd931 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -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'): diff --git a/addons/website_sale/data/mail_template_data.xml b/addons/website_sale/data/mail_template_data.xml new file mode 100644 index 00000000000..b1d3b9650ff --- /dev/null +++ b/addons/website_sale/data/mail_template_data.xml @@ -0,0 +1,45 @@ + + + + + Sale Order: Cart Recovery Email + ${(object.user_id.email or '')|safe} + You left items in your cart. + ${object.partner_id.id} + + + ${object.partner_id.lang} + +
+ ${user.company_id.name} +

THERE'S SOMETHING IN YOUR CART.

+

Would you like to complete your purchase?



+ % if object.order_line: + % for line in object.order_line: +
+ + + + + + +
+ + + ${line.product_id.display_name}
${line.name} +
+ ${(line.product_uom_qty) | int} ${(line.product_uom.name)} +
+ % endfor +
+ % endif +

+
+ Resume order


+

Thank you for shopping with ${user.company_id.name}!

+
+
+
+
+
+
diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py index dd9c198c5c7..c0025143d45 100644 --- a/addons/website_sale/models/__init__.py +++ b/addons/website_sale/models/__init__.py @@ -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 diff --git a/addons/website_sale/models/crm_team.py b/addons/website_sale/models/crm_team.py index ef9cf229068..213a0022f70 100644 --- a/addons/website_sale/models/crm_team.py +++ b/addons/website_sale/models/crm_team.py @@ -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': _('''

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.

diff --git a/addons/website_sale/models/mail_compose_message.py b/addons/website_sale/models/mail_compose_message.py new file mode 100644 index 00000000000..d735f2b31d9 --- /dev/null +++ b/addons/website_sale/models/mail_compose_message.py @@ -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) diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py index 0fdeee6b88b..28ee9e518c7 100644 --- a/addons/website_sale/models/res_config_settings.py +++ b/addons/website_sale/models/res_config_settings.py @@ -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): diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 67068bff115..ce230e56e1b 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -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 diff --git a/addons/website_sale/static/src/xml/website_sale_dashboard.xml b/addons/website_sale/static/src/xml/website_sale_dashboard.xml index 09d080d6b38..1f155c07f5f 100644 --- a/addons/website_sale/static/src/xml/website_sale_dashboard.xml +++ b/addons/website_sale/static/src/xml/website_sale_dashboard.xml @@ -19,7 +19,7 @@
- Abandoned Carts + Abandoned Carts to Recover
diff --git a/addons/website_sale/views/crm_team_views.xml b/addons/website_sale/views/crm_team_views.xml index 727552018a9..897d38e1b82 100644 --- a/addons/website_sale/views/crm_team_views.xml +++ b/addons/website_sale/views/crm_team_views.xml @@ -16,8 +16,7 @@
- Abandoned Carts - Abandoned Cart + Abandoned Carts to Recover
diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml index 1f0c12551a8..9a3b316fa79 100644 --- a/addons/website_sale/views/res_config_settings_views.xml +++ b/addons/website_sale/views/res_config_settings_views.xml @@ -344,6 +344,27 @@ +
+
+
+
+