[IMP] crm: detect leads based on similar phone/mobile number
Currently, the 'similar lead detection' mechanism only considers email, contact name, and partner name while finding duplicate leads in CRM. After this commit, it will also consider the mobile/phone number for the same. Note that the mobile numbers and phone numbers both will be matched with each other while finding duplicate leads. Task-2817884 closes odoo/odoo#88372 Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
committed by
Thibault Delavallée
parent
4ffde5b8b9
commit
bb24757d08
@@ -522,6 +522,7 @@ class Lead(models.Model):
|
||||
def _compute_potential_lead_duplicates(self):
|
||||
MIN_EMAIL_LENGTH = 7
|
||||
MIN_NAME_LENGTH = 6
|
||||
MIN_PHONE_LENGTH = 8
|
||||
SEARCH_RESULT_LIMIT = 21
|
||||
|
||||
def return_if_relevant(model_name, domain):
|
||||
@@ -542,20 +543,6 @@ class Lead(models.Model):
|
||||
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 = [
|
||||
@@ -563,11 +550,11 @@ class Lead(models.Model):
|
||||
]
|
||||
|
||||
duplicate_lead_ids = self.env['crm.lead']
|
||||
email_search = get_email_to_search(lead.email_from)
|
||||
email_search = iap_tools.mail_prepare_for_domain_search(lead.email_from, min_email_length=MIN_EMAIL_LENGTH)
|
||||
|
||||
if email_search:
|
||||
duplicate_lead_ids |= return_if_relevant('crm.lead', common_lead_domain + [
|
||||
('email_from', 'ilike', email_search)
|
||||
'|', ('email_normalized', 'ilike', email_search), ('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 + [
|
||||
@@ -581,6 +568,14 @@ class Lead(models.Model):
|
||||
duplicate_lead_ids |= lead.with_context(active_test=False).search(common_lead_domain + [
|
||||
("partner_id", "child_of", lead.partner_id.commercial_partner_id.id)
|
||||
])
|
||||
if lead.phone and len(lead.phone) >= MIN_PHONE_LENGTH:
|
||||
duplicate_lead_ids |= return_if_relevant('crm.lead', common_lead_domain + [
|
||||
('phone_mobile_search', 'ilike', lead.phone)
|
||||
])
|
||||
if lead.mobile and len(lead.mobile) >= MIN_PHONE_LENGTH:
|
||||
duplicate_lead_ids |= return_if_relevant('crm.lead', common_lead_domain + [
|
||||
('phone_mobile_search', 'ilike', lead.mobile)
|
||||
])
|
||||
|
||||
lead.duplicate_lead_ids = duplicate_lead_ids + lead
|
||||
lead.duplicate_lead_count = len(duplicate_lead_ids)
|
||||
|
||||
@@ -135,6 +135,63 @@ class TestLeadConvert(TestCrmCommon):
|
||||
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_phone(self):
|
||||
customer = self.env['res.partner'].create({
|
||||
'email': 'customer1@duplicate.example.com',
|
||||
'mobile': '+32485001122',
|
||||
'name': 'Customer1',
|
||||
'phone': '(803)-456-6126',
|
||||
})
|
||||
base_lead = self.env['crm.lead'].create({
|
||||
'name': 'Base Lead',
|
||||
'partner_id': customer.id,
|
||||
'type': 'lead',
|
||||
})
|
||||
|
||||
self.assertEqual(base_lead.contact_name, customer.name)
|
||||
self.assertEqual(base_lead.mobile, customer.mobile)
|
||||
self.assertFalse(base_lead.partner_name)
|
||||
self.assertEqual(base_lead.phone, customer.phone)
|
||||
|
||||
dup1_1 = self.env['crm.lead'].create({
|
||||
'name': 'Base Lead Dup1',
|
||||
'type': 'lead',
|
||||
'phone': '456-6126', # shorter version of base_lead
|
||||
'partner_name': 'Partner Name 1',
|
||||
})
|
||||
dup1_2 = self.env['crm.lead'].create({
|
||||
'name': 'Base Lead Dup2',
|
||||
'mobile': '8034566126',
|
||||
'partner_name': 'Partner Name 2',
|
||||
'type': 'lead',
|
||||
})
|
||||
dup1_3 = self.env['crm.lead'].create({
|
||||
'name': 'Base Lead Dup3',
|
||||
'partner_name': 'Partner Name 3',
|
||||
'phone': '(803)-456-6126',
|
||||
'type': 'lead',
|
||||
})
|
||||
dup1_4 = self.env['crm.lead'].create({
|
||||
'mobile': '0032485001122',
|
||||
# 'mobile': '0485001122', # note: does not work
|
||||
'name': 'Base Lead Dup4',
|
||||
'partner_name': 'Partner Name 4',
|
||||
'phone': False,
|
||||
'type': 'lead',
|
||||
})
|
||||
|
||||
expected = base_lead + dup1_2 + dup1_3 + dup1_4 # dup1_1 is shorter than lead -> not a dupe
|
||||
self.assertEqual(
|
||||
base_lead.duplicate_lead_ids, expected,
|
||||
'CRM: missing %s, extra %s' % ((expected - base_lead.duplicate_lead_ids).mapped('name'), (base_lead.duplicate_lead_ids - expected).mapped('name'))
|
||||
)
|
||||
expected = base_lead + dup1_1 + dup1_2 + dup1_3 # dup1_4 has mobile of customer, but no link with dup1_1
|
||||
self.assertEqual(
|
||||
dup1_1.duplicate_lead_ids, expected,
|
||||
'CRM: missing %s, extra %s' % ((expected - dup1_1.duplicate_lead_ids).mapped('name'), (dup1_1.duplicate_lead_ids - expected).mapped('name'))
|
||||
)
|
||||
|
||||
@users('user_sales_manager')
|
||||
def test_potential_duplicates_with_invalid_email(self):
|
||||
lead_1 = self.env['crm.lead'].create({
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import patch
|
||||
|
||||
from odoo import exceptions, _
|
||||
from odoo.tests.common import BaseCase
|
||||
from odoo.tools import pycompat
|
||||
from odoo.tools import email_normalize, pycompat
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,6 +73,39 @@ _STATES_FILTER_COUNTRIES_WHITELIST = set([
|
||||
'AR', 'AU', 'BR', 'CA', 'IN', 'MY', 'MX', 'NZ', 'AE', 'US'
|
||||
])
|
||||
|
||||
|
||||
#----------------------------------------------------------
|
||||
# Tools
|
||||
#----------------------------------------------------------
|
||||
|
||||
def mail_prepare_for_domain_search(email, min_email_length=0):
|
||||
""" Return an email address to use for a domain-based search. For generic
|
||||
email providers like gmail (see ``_MAIL_DOMAIN_BLACKLIST``) we consider
|
||||
each email as being independant (and return the whole email). Otherwise
|
||||
we return only the right-part of the email (aka "mydomain.com" if email is
|
||||
"Raoul Lachignole" <raoul@mydomain.com>).
|
||||
|
||||
:param integer min_email_length: skip if email has not the sufficient minimal
|
||||
length, indicating a probably fake / wrong value (skip if 0);
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
email_tocheck = email_normalize(email, strict=False)
|
||||
if not email_tocheck:
|
||||
email_tocheck = email.casefold()
|
||||
|
||||
if email_tocheck and min_email_length and len(email_tocheck) < min_email_length:
|
||||
return False
|
||||
|
||||
parts = email_tocheck.rsplit('@', maxsplit=1)
|
||||
if len(parts) == 1:
|
||||
return email_tocheck
|
||||
email_domain = parts[1]
|
||||
if email_domain not in _MAIL_DOMAIN_BLACKLIST:
|
||||
return '@' + email_domain
|
||||
return email_tocheck
|
||||
|
||||
|
||||
#----------------------------------------------------------
|
||||
# Helpers for both clients and proxy
|
||||
#----------------------------------------------------------
|
||||
|
||||
@@ -70,7 +70,7 @@ class TestCrmPerformance(CrmPerformanceCase):
|
||||
country_be = self.env.ref('base.be')
|
||||
lang_be = self.env['res.lang']._lang_get('fr_BE')
|
||||
|
||||
with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=178): # tcf 164 / com 166
|
||||
with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=186): # tcf 174 / com 175
|
||||
self.env.cr._now = self.reference_now # force create_date to check schedulers
|
||||
with Form(self.env['crm.lead']) as lead_form:
|
||||
lead_form.country_id = country_be
|
||||
@@ -89,7 +89,7 @@ class TestCrmPerformance(CrmPerformanceCase):
|
||||
@warmup
|
||||
def test_lead_create_form_partner(self):
|
||||
""" Test a single lead creation using Form with a partner """
|
||||
with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=186): # tcf 173 / com 175
|
||||
with freeze_time(self.reference_now), self.assertQueryCount(user_sales_leads=199): # tcf 186 / com 188
|
||||
self.env.cr._now = self.reference_now # force create_date to check schedulers
|
||||
with self.debug_mode():
|
||||
# {'invisible': ['|', ('type', '=', 'opportunity'), ('is_partner_visible', '=', False)]}
|
||||
|
||||
+8
-3
@@ -519,7 +519,7 @@ def email_split_and_format(text):
|
||||
return []
|
||||
return [formataddr((name, email)) for (name, email) in email_split_tuples(text)]
|
||||
|
||||
def email_normalize(text):
|
||||
def email_normalize(text, strict=True):
|
||||
""" Sanitize and standardize email address entries.
|
||||
A normalized email is considered as :
|
||||
- having a left part + @ + a right part (the domain can be without '.something')
|
||||
@@ -528,13 +528,18 @@ def email_normalize(text):
|
||||
Ex:
|
||||
- Possible Input Email : 'Name <NaMe@DoMaIn.CoM>'
|
||||
- Normalized Output Email : 'name@domain.com'
|
||||
|
||||
:param bool strict: text should contain exactly one email (default behavior
|
||||
and unique behavior before Odoo16);
|
||||
|
||||
:return: False if no email found (or if more than 1 email found when being
|
||||
in strict mode); normalized email otherwise;
|
||||
"""
|
||||
emails = email_split(text)
|
||||
if not emails or len(emails) != 1:
|
||||
if not emails or (strict and len(emails) != 1):
|
||||
return False
|
||||
return emails[0].lower()
|
||||
|
||||
|
||||
def email_domain_extract(email):
|
||||
""" Extract the company domain to be used by IAP services notably. Domain
|
||||
is extracted from email information e.g:
|
||||
|
||||
Reference in New Issue
Block a user