From e86f892a7b277cc35fe32d365cfe50b7509a93a5 Mon Sep 17 00:00:00 2001 From: Julien Banken Date: Wed, 10 Mar 2021 12:15:16 +0000 Subject: [PATCH] [IMP] crm: Duplicate leads on lead form view PURPOSE Duplicate lead records are an issue for CRM users To prevent sales representatives from contacting a prospect that: - Already refused an offer from another sales - Already accepted an offer from another sales - Is already discussing with another sales The purpose of this task is to inform the CRM user that there are some possible duplicates for one lead and let the user decide how to handle the case. SPECIFICATION - Add a computed field to count the number of potential duplicates. - Add a stat button on the form view of a lead to display the number of potential duplicates. When the user clicks on it, the leads considered as duplicate will be displayed in a kanban view. - Add a lost ribbon on the kanban view to quickly visualize the lead state since the duplicates can be lost leads. LINKS Task ID : 2151017 closes odoo/odoo#61834 Signed-off-by: Thibault Delavallee (tde) --- addons/crm/models/crm_lead.py | 83 +++++++++ addons/crm/static/src/scss/crm.scss | 33 ++++ addons/crm/tests/__init__.py | 1 + addons/crm/tests/test_crm_lead_duplicates.py | 170 ++++++++++++++++++ addons/crm/views/assets.xml | 1 + addons/crm/views/crm_lead_views.xml | 61 ++++++- addons/event_crm/views/crm_lead_views.xml | 2 +- .../views/website_crm_lead_views.xml | 2 +- 8 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 addons/crm/static/src/scss/crm.scss create mode 100644 addons/crm/tests/test_crm_lead_duplicates.py diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index afff697394e..0136ae72a1f 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -8,6 +8,7 @@ from datetime import date, datetime, timedelta from psycopg2 import sql from odoo import api, fields, models, tools, SUPERUSER_ID +from odoo.addons.iap.tools import iap_tools from odoo.addons.mail.tools import mail_validation from odoo.addons.phone_validation.tools import phone_validation from odoo.exceptions import UserError, AccessError @@ -224,6 +225,8 @@ class Lead(models.Model): index=True, ondelete='restrict', tracking=True) # Statistics meeting_count = fields.Integer('# Meetings', compute='_compute_meeting_count') + duplicate_lead_ids = fields.Many2many("crm.lead", compute="_compute_potential_lead_duplicates", string="Potential Duplicate Lead", context={"active_test": False}) + duplicate_lead_count = fields.Integer(compute="_compute_potential_lead_duplicates", string="Potential Duplicate Lead Count") # UX ribbon_message = fields.Char('Ribbon message', compute='_compute_ribbon_message') @@ -502,6 +505,73 @@ class Lead(models.Model): for lead in self: lead.meeting_count = mapped_data.get(lead.id, 0) + @api.depends('email_from', 'partner_id', 'contact_name', 'partner_name') + def _compute_potential_lead_duplicates(self): + MIN_EMAIL_LENGTH = 7 + MIN_NAME_LENGTH = 6 + SEARCH_RESULT_LIMIT = 21 + + def return_if_relevant(model_name, domain): + """ Returns the recordset obtained by performing a search on the provided + model with the provided domain if the cardinality of that recordset is + below a given threshold (i.e: `SEARCH_RESULT_LIMIT`). Otherwise, returns + an empty recordset of the provided model as it indicates search term + was not relevant. + + Note: The function will use the administrator privileges to guarantee + that a maximum amount of leads will be included in the search results + and transcend multi-company record rules. It also includes archived records. + Idea is that counter indicates duplicates are present and that lead + could be escalated to managers. + """ + # Includes archived records and transcend multi-company record rules + model = self.env[model_name].sudo().with_context(active_test=False) + res = model.search(domain, limit=SEARCH_RESULT_LIMIT) + return res if len(res) < SEARCH_RESULT_LIMIT else model + + def get_email_to_search(email): + """ Returns the full email address if the domain of the email address + is common (i.e: in the mail domain blacklist). Otherwise, returns + the domain of the email address. A minimal length is required to avoid + returning false positives records. """ + if not email or len(email) < MIN_EMAIL_LENGTH: + return False + parts = email.rsplit('@', maxsplit=1) + if len(parts) > 1: + email_domain = parts[1] + if email_domain not in iap_tools._MAIL_DOMAIN_BLACKLIST: + return '@' + email_domain + return email + + for lead in self: + lead_id = lead._origin.id if isinstance(lead.id, models.NewId) else lead.id + common_lead_domain = [ + ('id', '!=', lead_id) + ] + + duplicate_lead_ids = self.env['crm.lead'] + email_search = get_email_to_search(lead.email_from) + + if email_search: + duplicate_lead_ids |= return_if_relevant('crm.lead', common_lead_domain + [ + ('email_from', 'ilike', email_search) + ]) + if lead.partner_name and len(lead.partner_name) >= MIN_NAME_LENGTH: + duplicate_lead_ids |= return_if_relevant('crm.lead', common_lead_domain + [ + ('partner_name', 'ilike', lead.partner_name) + ]) + if lead.contact_name and len(lead.contact_name) >= MIN_NAME_LENGTH: + duplicate_lead_ids |= return_if_relevant('crm.lead', common_lead_domain + [ + ('contact_name', 'ilike', lead.contact_name) + ]) + if lead.partner_id and lead.partner_id.commercial_partner_id: + duplicate_lead_ids |= lead.with_context(active_test=False).search(common_lead_domain + [ + ("partner_id", "child_of", lead.partner_id.commercial_partner_id.id) + ]) + + lead.duplicate_lead_ids = duplicate_lead_ids + lead + lead.duplicate_lead_count = len(duplicate_lead_ids) + @api.depends('email_from', 'phone', 'partner_id') def _compute_ribbon_message(self): for lead in self: @@ -967,6 +1037,19 @@ class Lead(models.Model): action['context']['initial_date'] = next_activity.calendar_event_id.start return action + def action_show_potential_duplicates(self): + """ Open kanban view to display duplicate leads or opportunity. + :return dict: dictionary value for created kanban view + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("crm.crm_lead_action_duplicates") + action['domain'] = [('id', 'in', self.duplicate_lead_ids.ids)] + action['context'] = { + 'active_test': False, + 'create': False + } + return action + def action_snooze(self): self.ensure_one() today = date.today() diff --git a/addons/crm/static/src/scss/crm.scss b/addons/crm/static/src/scss/crm.scss new file mode 100644 index 00000000000..bfb557d65f3 --- /dev/null +++ b/addons/crm/static/src/scss/crm.scss @@ -0,0 +1,33 @@ +.o_opportunity_kanban { + .ribbon { + &::before, &::after { + display: none; + } + span { + background-color: $o-brand-odoo; + padding: 5px; + font-size: small; + z-index: 0; + } + } + + .ribbon-top-right { + margin-top: -9px; + span { + left: 12px; + right: 30px; + height: 25px; + top: 18px; + } + } + + .oe_kanban_card[data-ribbon="true"] { + min-height: 105px; + .o_kanban_record_title { + max-width: calc(100% - 65px); + } + .o_kanban_record_subtitle { + max-width: calc(100% - 35px); + } + } +} diff --git a/addons/crm/tests/__init__.py b/addons/crm/tests/__init__.py index 647f4cd0edf..d5809eb9001 100644 --- a/addons/crm/tests/__init__.py +++ b/addons/crm/tests/__init__.py @@ -5,6 +5,7 @@ from . import test_crm_lead_assignment from . import test_crm_lead_notification from . import test_crm_lead_convert from . import test_crm_lead_convert_mass +from . import test_crm_lead_duplicates from . import test_crm_lead_lost from . import test_crm_lead_merge from . import test_crm_activity diff --git a/addons/crm/tests/test_crm_lead_duplicates.py b/addons/crm/tests/test_crm_lead_duplicates.py new file mode 100644 index 00000000000..c420f2b0a30 --- /dev/null +++ b/addons/crm/tests/test_crm_lead_duplicates.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.crm.tests.common import TestCrmCommon +from odoo.tests.common import tagged, users + + +@tagged('lead_manage') +class TestLeadConvert(TestCrmCommon): + + @users('user_sales_manager') + def test_potential_duplicates(self): + company = self.env['res.partner'].create({ + 'name': 'My company', + 'email': 'mycompany@company.com', + 'is_company': True, + 'street': '57th Street', + 'city': 'New New York', + 'country_id': self.env.ref('base.us').id, + 'zip': '12345', + }) + + partner_1 = self.env['res.partner'].create({ + 'name': 'Dave', + 'email': 'dave@odoo.com', + 'mobile': '+1 202 555 0123', + 'phone': False, + 'parent_id': company.id, + 'is_company': False, + 'street': 'Pearl street', + 'city': 'California', + 'country_id': self.env.ref('base.us').id, + 'zip': '95826', + }) + partner_2 = self.env['res.partner'].create({ + 'name': 'Eve', + 'email': 'eve@odoo.com', + 'mobile': '+1 202 555 3210', + 'phone': False, + 'parent_id': company.id, + 'is_company': False, + 'street': 'Wall street', + 'city': 'New York', + 'country_id': self.env.ref('base.us').id, + 'zip': '54321', + }) + + lead_1 = self.env['crm.lead'].create({ + 'name': 'Lead 1', + 'type': 'lead', + 'partner_name': 'Alice', + 'email_from': 'alice@odoo.com', + }) + lead_2 = self.env['crm.lead'].create({ + 'name': 'Opportunity 1', + 'type': 'opportunity', + 'email_from': 'alice@odoo.com', + }) + lead_3 = self.env['crm.lead'].create({ + 'name': 'Opportunity 2', + 'type': 'opportunity', + 'email_from': 'alice@odoo.com', + }) + lead_4 = self.env['crm.lead'].create({ + 'name': 'Lead 2', + 'type': 'lead', + 'partner_name': 'Alice Doe' + }) + lead_5 = self.env['crm.lead'].create({ + 'name': 'Opportunity 3', + 'type': 'opportunity', + 'partner_name': 'Alice Doe' + }) + lead_6 = self.env['crm.lead'].create({ + 'name': 'Opportunity 4', + 'type': 'opportunity', + 'partner_name': 'Bob Doe' + }) + lead_7 = self.env['crm.lead'].create({ + 'name': 'Opportunity 5', + 'type': 'opportunity', + 'partner_name': 'Bob Doe', + 'email_from': 'bob@odoo.com', + }) + lead_8 = self.env['crm.lead'].create({ + 'name': 'Opportunity 6', + 'type': 'opportunity', + 'email_from': 'bob@mymail.com', + }) + lead_9 = self.env['crm.lead'].create({ + 'name': 'Opportunity 7', + 'type': 'opportunity', + 'email_from': 'alice@mymail.com', + }) + lead_10 = self.env['crm.lead'].create({ + 'name': 'Opportunity 8', + 'type': 'opportunity', + 'probability': 0, + 'active': False, + 'email_from': 'alice@mymail.com', + }) + lead_11 = self.env['crm.lead'].create({ + 'name': 'Opportunity 9', + 'type': 'opportunity', + 'contact_name': 'charlie' + }) + lead_12 = self.env['crm.lead'].create({ + 'name': 'Opportunity 10', + 'type': 'opportunity', + 'contact_name': 'Charlie Chapelin', + }) + lead_13 = self.env['crm.lead'].create({ + 'name': 'Opportunity 8', + 'type': 'opportunity', + 'partner_id': partner_1.id + }) + lead_14 = self.env['crm.lead'].create({ + 'name': 'Lead 3', + 'type': 'lead', + 'partner_id': partner_2.id + }) + + self.assertEqual(lead_1 + lead_2 + lead_3, lead_1.duplicate_lead_ids) + self.assertEqual(lead_1 + lead_2 + lead_3, lead_2.duplicate_lead_ids) + self.assertEqual(lead_1 + lead_2 + lead_3, lead_3.duplicate_lead_ids) + self.assertEqual(lead_4 + lead_5, lead_4.duplicate_lead_ids) + self.assertEqual(lead_4 + lead_5, lead_5.duplicate_lead_ids) + self.assertEqual(lead_6 + lead_7, lead_6.duplicate_lead_ids) + self.assertEqual(lead_6 + lead_7, lead_7.duplicate_lead_ids) + self.assertEqual(lead_8 + lead_9 + lead_10, lead_8.duplicate_lead_ids) + self.assertEqual(lead_8 + lead_9 + lead_10, lead_9.duplicate_lead_ids) + self.assertEqual(lead_8 + lead_9 + lead_10, lead_10.duplicate_lead_ids) + self.assertEqual(lead_11 + lead_12, lead_11.duplicate_lead_ids) + self.assertEqual(lead_12, lead_12.duplicate_lead_ids) + self.assertEqual(lead_13 + lead_14, lead_13.duplicate_lead_ids) + self.assertEqual(lead_13 + lead_14, lead_14.duplicate_lead_ids) + + @users('user_sales_manager') + def test_potential_duplicates_with_invalid_email(self): + lead_1 = self.env['crm.lead'].create({ + 'name': 'Lead 1', + 'type': 'lead', + 'email_from': 'mail"1@mymail.com' + }) + lead_2 = self.env['crm.lead'].create({ + 'name': 'Opportunity 1', + 'type': 'opportunity', + 'email_from': 'mail2@mymail.com' + }) + lead_3 = self.env['crm.lead'].create({ + 'name': 'Opportunity 2', + 'type': 'lead', + 'email_from': 'odoo.com' + }) + lead_4 = self.env['crm.lead'].create({ + 'name': 'Opportunity 3', + 'type': 'opportunity', + 'email_from': 'odoo.com' + }) + lead_5 = self.env['crm.lead'].create({ + 'name': 'Opportunity 3', + 'type': 'opportunity', + 'email_from': 'myodoo.com' + }) + + self.assertEqual(lead_1 + lead_2, lead_1.duplicate_lead_ids) + self.assertEqual(lead_1 + lead_2, lead_2.duplicate_lead_ids) + self.assertEqual(lead_3 + lead_4 + lead_5, lead_3.duplicate_lead_ids) + self.assertEqual(lead_3 + lead_4 + lead_5, lead_4.duplicate_lead_ids) + self.assertEqual(lead_5, lead_5.duplicate_lead_ids) diff --git a/addons/crm/views/assets.xml b/addons/crm/views/assets.xml index 76faa781844..e1111a4affa 100644 --- a/addons/crm/views/assets.xml +++ b/addons/crm/views/assets.xml @@ -8,6 +8,7 @@ + diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 392c1f974f3..ab7f2c66b74 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -38,6 +38,15 @@ Meeting + @@ -511,7 +520,7 @@ -
+ -
+
@@ -543,6 +552,8 @@
+
+