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