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 @@
-