[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:
committed by
Thibault Delavallée
parent
adf3c610c7
commit
e86f892a7b
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
<span class="o_stat_text" attrs="{'invisible': [('meeting_count', '>', 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', '<', 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', '<', 2)]}">Similar Leads</span>
|
||||
<span class="o_stat_text" attrs="{'invisible': [('duplicate_lead_count', '>', 1)]}">Similar Lead</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Lost" bg_color="bg-danger" attrs="{'invisible': ['|', ('probability', '>', 0), ('active', '=', True)]}"/>
|
||||
<widget name="web_ribbon" title="Won" attrs="{'invisible': [('probability', '<', 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', '>', 0), ('active', '=', True)]}">
|
||||
<span class="bg-danger">Lost</span>
|
||||
</div>
|
||||
<div class="ribbon ribbon-top-right"
|
||||
attrs="{'invisible': [('probability', '<', 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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user