[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) <tde@openerp.com>
This commit is contained in:
Julien Banken
2021-03-10 14:29:50 +00:00
committed by Thibault Delavallée
parent adf3c610c7
commit e86f892a7b
8 changed files with 349 additions and 4 deletions
+83
View File
@@ -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()
+33
View File
@@ -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);
}
}
}
+1
View File
@@ -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
@@ -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)
+1
View File
@@ -8,6 +8,7 @@
<script type="text/javascript" src="/crm/static/src/js/tours/crm.js"></script>
</xpath>
<xpath expr="." position="inside">
<link rel="stylesheet" type="text/scss" href="/crm/static/src/scss/crm.scss"/>
<link rel="stylesheet" type="text/scss" href="/crm/static/src/scss/crm_team_member_views.scss"></link>
</xpath>
</template>
+59 -2
View File
@@ -38,6 +38,15 @@
<span class="o_stat_text" attrs="{'invisible': [('meeting_count', '&gt;', 1)]}"> Meeting</span>
</div>
</button>
<button name="action_show_potential_duplicates" type="object"
class="oe_stat_button" icon="fa-star"
attrs="{'invisible': [('duplicate_lead_count', '&lt;', 1)]}">
<div class="o_stat_info">
<field name="duplicate_lead_count" class="o_stat_value"/>
<span class="o_stat_text" attrs="{'invisible': [('duplicate_lead_count', '&lt;', 2)]}">Similar Leads</span>
<span class="o_stat_text" attrs="{'invisible': [('duplicate_lead_count', '&gt;', 1)]}">Similar Lead</span>
</div>
</button>
</div>
<widget name="web_ribbon" title="Lost" bg_color="bg-danger" attrs="{'invisible': ['|', ('probability', '&gt;', 0), ('active', '=', True)]}"/>
<widget name="web_ribbon" title="Won" attrs="{'invisible': [('probability', '&lt;', 100)]}" />
@@ -511,7 +520,7 @@
<progressbar field="activity_state" colors='{"planned": "success", "today": "warning", "overdue": "danger"}' sum_field="expected_revenue" help="This bar allows to filter the opportunities based on scheduled activities."/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="#{!selection_mode ? kanban_color(record.color.raw_value) : ''} oe_kanban_global_click">
<div t-attf-class="#{!selection_mode ? kanban_color(record.color.raw_value) : ''} oe_kanban_global_click oe_kanban_card d-flex flex-column">
<div class="o_dropdown_kanban dropdown">
<a class="dropdown-toggle o-no-caret btn" role="button" data-toggle="dropdown" data-display="static" href="#" aria-label="Dropdown menu" title="Dropdown menu">
@@ -523,7 +532,7 @@
<ul class="oe_kanban_colorpicker" data-field="color"/>
</div>
</div>
<div class="oe_kanban_content">
<div class="oe_kanban_content flex-grow-1">
<div class="o_kanban_record_title oe_kanban_details">
<strong><field name="name"/></strong>
</div>
@@ -543,6 +552,8 @@
<div>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
</div>
</div>
<div class="oe_kanban_footer">
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_left">
<field name="priority" widget="priority" groups="base.group_user"/>
@@ -1065,6 +1076,52 @@ if record:
<field name="act_window_id" ref="crm_lead_action_pipeline"/>
</record>
<record id="crm_lead_action_duplicates" model="ir.actions.act_window">
<field name="name">Similar Leads</field>
<field name="res_model">crm.lead</field>
<field name="view_mode">tree,form,kanban</field>
<field name="search_view_id" ref="crm.view_crm_case_leads_filter"/>
</record>
<record id="crm_lead_view_kanban_duplicates" model="ir.ui.view">
<field name="name">crm.lead.view.kanban.duplicates</field>
<field name="model">crm.lead</field>
<field name="mode">primary</field>
<field name="inherit_id" ref="crm.crm_case_kanban_view_leads"/>
<field name="arch" type="xml">
<xpath expr="//kanban" position="inside">
<field name="probability" invisible="1"/>
</xpath>
<xpath expr="//div" position="attributes">
<attribute name="t-att-data-ribbon">((
!record.active.raw_value and
record.probability and
record.probability.raw_value == 0
) or (
record.active.raw_value and
record.probability and
record.probability.raw_value == 100
))</attribute>
</xpath>
<xpath expr="//div[hasclass('o_dropdown_kanban')]" position="before">
<div class="ribbon ribbon-top-right"
attrs="{'invisible': ['|', ('probability', '&gt;', 0), ('active', '=', True)]}">
<span class="bg-danger">Lost</span>
</div>
<div class="ribbon ribbon-top-right"
attrs="{'invisible': [('probability', '&lt;', 100)]}">
<span class="bg-success">Won</span>
</div>
</xpath>
</field>
</record>
<record id="crm_lead_action_duplicates_view_kanban" model="ir.actions.act_window.view">
<field name="view_mode">kanban</field>
<field name="view_id" ref="crm_lead_view_kanban_duplicates"/>
<field name="act_window_id" ref="crm_lead_action_duplicates"/>
</record>
<record id="menu_crm_opportunities" model="ir.ui.menu">
<field name="action" ref="crm.action_your_pipeline"/>
</record>
+1 -1
View File
@@ -19,7 +19,7 @@
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<xpath expr="//button[@name='action_schedule_meeting']" position="after">
<field name="registration_ids" invisible="1"/>
<button name="%(event_registration_action_from_lead)d" type="action" class="oe_stat_button" icon="fa-ticket"
attrs="{'invisible': [('registration_count', '=', 0)]}" groups="event.group_event_user">
@@ -5,7 +5,7 @@
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<xpath expr="//button[@name='action_schedule_meeting']" position="after">
<button name="action_redirect_to_page_views" type="object" class="oe_stat_button" icon="fa-tags"
attrs="{'invisible': [('visitor_page_count', '=', 0)]}">
<field name="visitor_page_count" widget="statinfo" string="Page views"/>