[IMP] crm,*_crm: better merge leads side documents

Purpose of this commit is to ensure side documents are redirected to the
master opportunity when merging leads. Those side documents include

  * communication history (mail.message);
  * attachments (ir.attachment);
  * visitors (website.visitor);

However some documents are currently not specifically handled :

  * meetings (calendar.event);
  * activities (mail.activity);
  * sale orders (sale.order);
  * attendees (event.registration);

In this commit we ensure all are attached to the final master opportunity.
That way we prevent loosing access to those documents and ensure we keep
the complete history of all merged leads.

Also tests are added to ensure the merging of leads and its contents.

A new field is added to have the o2m field between leads and calendar events.
In order to clarify naming, ``meeting_count`` is renamed to
``calendar_event_count`` to match naming.

Task Id-2457941
COM PR odoo/odoo#68884
UPG PR odoo/odoo#2494

Related: odoo/upgrade#2494
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Anjali
2021-05-28 12:12:51 +00:00
committed by Thibault Delavallée
parent 962d8a443b
commit a99d882c02
14 changed files with 316 additions and 69 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
{
'name': 'CRM',
'version': '1.5',
'version': '1.6',
'category': 'Sales/CRM',
'sequence': 15,
'summary': 'Track leads and close opportunities',
+86 -59
View File
@@ -225,7 +225,8 @@ class Lead(models.Model):
'crm.lost.reason', string='Lost Reason',
index=True, ondelete='restrict', tracking=True)
# Statistics
meeting_count = fields.Integer('# Meetings', compute='_compute_meeting_count')
calendar_event_ids = fields.One2many('calendar.event', 'opportunity_id', string='Meetings')
calendar_event_count = fields.Integer('# Meetings', compute='_compute_calendar_event_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
@@ -504,7 +505,7 @@ class Lead(models.Model):
for lead in self:
lead.recurring_revenue_monthly_prorated = (lead.recurring_revenue_monthly or 0.0) * (lead.probability or 0) / 100.0
def _compute_meeting_count(self):
def _compute_calendar_event_count(self):
if self.ids:
meeting_data = self.env['calendar.event'].sudo().read_group([
('opportunity_id', 'in', self.ids)
@@ -513,7 +514,7 @@ class Lead(models.Model):
else:
mapped_data = dict()
for lead in self:
lead.meeting_count = mapped_data.get(lead.id, 0)
lead.calendar_event_count = mapped_data.get(lead.id, 0)
@api.depends('email_from', 'partner_id', 'contact_name', 'partner_name')
def _compute_potential_lead_duplicates(self):
@@ -1310,62 +1311,6 @@ class Lead(models.Model):
message_body = "\n\n".join(message_bodies)
return self.message_post(body=message_body, subject=subject)
def _merge_opportunity_history(self, opportunities):
""" Move mail.message from the given opportunities to the current one. `self` is the
crm.lead record destination for message of `opportunities`.
:param opportunities: see ``_merge_dependences``
"""
self.ensure_one()
for opportunity in opportunities:
for message in opportunity.message_ids:
if message.subject:
subject = _("From %(source_name)s : %(source_subject)s", source_name=opportunity.name, source_subject=message.subject)
else:
subject = _("From %(source_name)s", source_name=opportunity.name)
message.write({
'res_id': self.id,
'subject': subject,
})
return True
def _merge_opportunity_attachments(self, opportunities):
""" Move attachments of given opportunities to the current one `self`, and rename
the attachments having same name than native ones.
:param opportunities: see ``_merge_dependences``
"""
self.ensure_one()
# return attachments of opportunity
def _get_attachments(opportunity_id):
return self.env['ir.attachment'].search([('res_model', '=', self._name), ('res_id', '=', opportunity_id)])
first_attachments = _get_attachments(self.id)
# counter of all attachments to move. Used to make sure the name is different for all attachments
count = 1
for opportunity in opportunities:
attachments = _get_attachments(opportunity.id)
for attachment in attachments:
values = {'res_id': self.id}
for attachment_in_first in first_attachments:
if attachment.name == attachment_in_first.name:
values['name'] = "%s (%s)" % (attachment.name, count)
count += 1
attachment.write(values)
return True
def _merge_dependences(self, opportunities):
""" Merge dependences (messages, attachments, ...). These dependences will be
transfered to `self`, the most important lead.
:param opportunities : recordset of opportunities to transfer. Does not
include `self` which is the target crm.lead being the result of the merge.
"""
self.ensure_one()
self._merge_opportunity_history(opportunities)
self._merge_opportunity_attachments(opportunities)
def merge_opportunity(self, user_id=False, team_id=False, auto_unlink=True):
""" Merge opportunities in one. Different cases of merge:
- merge leads together = 1 new lead
@@ -1439,6 +1384,88 @@ class Lead(models.Model):
def _merge_get_fields(self):
return list(CRM_LEAD_FIELDS_TO_MERGE) + list(self._merge_get_fields_specific().keys())
def _merge_dependences(self, opportunities):
""" Merge dependences (messages, attachments,activities, calendar events,
...). These dependences will be transfered to `self` considered as the
master lead.
:param opportunities : recordset of opportunities to transfer. Does not
include `self` which is the target crm.lead being the result of the
merge;
"""
self.ensure_one()
self._merge_dependences_history(opportunities)
self._merge_dependences_attachments(opportunities)
self._merge_dependences_calendar_events(opportunities)
def _merge_dependences_history(self, opportunities):
""" Move history from the given opportunities to the current one. `self`
is the crm.lead record destination for message of `opportunities`.
This method moves
* messages
* activities
:param opportunities: see ``_merge_dependences``
"""
self.ensure_one()
for opportunity in opportunities:
for message in opportunity.message_ids:
if message.subject:
subject = _("From %(source_name)s : %(source_subject)s", source_name=opportunity.name, source_subject=message.subject)
else:
subject = _("From %(source_name)s", source_name=opportunity.name)
message.write({
'res_id': self.id,
'subject': subject,
})
opportunities.activity_ids.write({
'res_id': self.id,
})
return True
def _merge_dependences_attachments(self, opportunities):
""" Move attachments of given opportunities to the current one `self`, and rename
the attachments having same name than native ones.
:param opportunities: see ``_merge_dependences``
"""
self.ensure_one()
all_attachments = self.env['ir.attachment'].search([
('res_model', '=', self._name),
('res_id', 'in', opportunities.ids)
])
for opportunity in opportunities:
attachments = all_attachments.filtered(lambda attach: attach.res_id == opportunity.id)
for attachment in attachments:
attachment.write({
'res_id': self.id,
'name': _("%(attach_name)s (from %(lead_name)s)",
attach_name=attachment.name,
lead_name=opportunity.name[:20]
)
})
return True
def _merge_dependences_calendar_events(self, opportunities):
""" Move calender.event from the given opportunities to the current one. `self` is the
crm.lead record destination for event of `opportunities`.
:param opportunities: see ``merge_dependences``
"""
self.ensure_one()
meetings = self.env['calendar.event'].search([('opportunity_id', 'in', opportunities.ids)])
return meetings.write({
'res_id': self.id,
'opportunity_id': self.id,
})
# CONVERT
# ----------------------------------------------------------------------
def _convert_opportunity_data(self, customer, team_id=False):
""" Extract the data from a lead to create the opportunity
:param customer : res.partner record
+29 -3
View File
@@ -53,6 +53,7 @@ class TestCrmCommon(TestSalesCommon, MailCase):
'phone', 'probability', 'expected_revenue', 'street', 'street2', 'zip',
'create_date', 'date_action_last', 'email_from', 'email_cc', 'website'
]
merge_fields = ['description', 'type', 'priority']
@classmethod
def setUpClass(cls):
@@ -210,6 +211,21 @@ class TestCrmCommon(TestSalesCommon, MailCase):
'zip': '87654',
})
# test activities
cls.activity_type_1 = cls.env['mail.activity.type'].create({
'name': 'Lead Test Activity 1',
'summary': 'ACT 1 : Presentation, barbecue, ... ',
'res_model_id': cls.env['ir.model']._get('crm.crm_lead').id,
'category': 'meeting',
'delay_count': 5,
})
cls.env['ir.model.data'].create({
'name': cls.activity_type_1.name.lower().replace(' ', '_'),
'module': 'crm',
'model': cls.activity_type_1._name,
'res_id': cls.activity_type_1.id,
})
def _create_leads_batch(self, lead_type='lead', count=10, email_dup_count=0,
partner_count=0, partner_ids=None, user_ids=None,
country_ids=None, probabilities=None):
@@ -361,12 +377,11 @@ class TestCrmCommon(TestSalesCommon, MailCase):
self.assertIn(opportunity, leads)
# save opportunity value before being modified by merge process
fields_all = self.FIELDS_FIRST_SET + ['description', 'type', 'priority']
# ensure tests are synchronized with crm code
self.assertTrue(all(field in fields_all for field in CRM_LEAD_FIELDS_TO_MERGE + list(self.env['crm.lead']._merge_get_fields_specific().keys())))
fields_all = self.FIELDS_FIRST_SET + self.merge_fields
original_opp_values = dict(
(fname, opportunity[fname])
for fname in fields_all
if fname in opportunity
)
def _find_value(lead, fname):
@@ -390,6 +405,13 @@ class TestCrmCommon(TestSalesCommon, MailCase):
values = [_find_value(lead, 'priority') for lead in leads]
return max(values)
def _aggregate(fname):
if isinstance(self.env['crm.lead'][fname], models.BaseModel):
values = leads.mapped(fname)
else:
values = [_find_value(lead, fname) for lead in leads]
return values
try:
# merge process will modify opportunity
yield
@@ -400,6 +422,8 @@ class TestCrmCommon(TestSalesCommon, MailCase):
# classic fields: first not void wins or specific computation
for fname in fields_all:
if fname not in opportunity: # not all fields available when doing -u
continue
opp_value = opportunity[fname]
if fname == 'description':
self.assertEqual(opp_value, _get_description())
@@ -407,6 +431,8 @@ class TestCrmCommon(TestSalesCommon, MailCase):
self.assertEqual(opp_value, _get_type())
elif fname == 'priority':
self.assertEqual(opp_value, _get_priority())
elif fname in ('order_ids', 'visitor_ids'):
self.assertEqual(opp_value, _aggregate(fname))
else:
self.assertEqual(
opp_value if opp_value or not isinstance(opp_value, models.BaseModel) else False,
+63 -2
View File
@@ -1,19 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo.addons.crm.tests.common import TestLeadConvertMassCommon
from odoo.fields import Datetime
from odoo.tests.common import tagged, users
@tagged('lead_manage')
class TestLeadMerge(TestLeadConvertMassCommon):
class TestLeadMergeCommon(TestLeadConvertMassCommon):
""" During a mixed merge (involving leads and opps), data should be handled a certain way following their type
(m2o, m2m, text, ...). """
@classmethod
def setUpClass(cls):
super(TestLeadMerge, cls).setUpClass()
super(TestLeadMergeCommon, cls).setUpClass()
cls.leads = cls.lead_1 + cls.lead_w_partner + cls.lead_w_contact + cls.lead_w_email + cls.lead_w_partner_company + cls.lead_w_email_lost
# reset some assigned users to test salesmen assign
@@ -29,6 +31,10 @@ class TestLeadMerge(TestLeadConvertMassCommon):
cls.assign_users = cls.user_sales_manager + cls.user_sales_leads_convert + cls.user_sales_salesman
@tagged('lead_manage')
class TestLeadMerge(TestLeadMergeCommon):
def test_initial_data(self):
""" Ensure initial data to avoid spaghetti test update afterwards """
self.assertFalse(self.lead_1.date_conversion)
@@ -160,3 +166,58 @@ class TestLeadMerge(TestLeadConvertMassCommon):
partner_id=self.contact_company_1,
priority='2'):
leads._merge_opportunity(auto_unlink=False, max_length=None)
@users('user_sales_manager')
def test_merge_method_dependencies(self):
""" Test if dependences for leads are not lost while merging leads. In
this test leads are ordered as
lead_w_partner_company ---opp----seq=1 (ID greater)
lead_w_contact -----------lead---seq=30
lead_w_email -------------lead---seq=3----------------attachments
lead_1 -------------------lead---seq=1----------------activity+meeting
lead_w_partner -----------lead---seq=False
"""
self.env['crm.lead'].browse(self.lead_w_partner_company.ids).write({'type': 'opportunity'})
# create side documents
attachments = self.env['ir.attachment'].create([
{'name': '%02d.txt' % idx,
'datas': base64.b64encode(b'Att%02d' % idx),
'res_model': 'crm.lead',
'res_id': self.lead_w_email.id,
} for idx in range(4)
])
lead_1 = self.env['crm.lead'].browse(self.lead_1.ids)
activity = lead_1.activity_schedule('crm.lead_test_activity_1')
calendar_event = self.env['calendar.event'].create({
'name': 'Meeting with partner',
'activity_ids': [(4, activity.id)],
'start': '2021-06-12 21:00:00',
'stop': '2021-06-13 00:00:00',
'res_model_id': self.env['ir.model']._get('crm.crm_lead').id,
'res_id': lead_1.id,
'opportunity_id': lead_1.id,
})
# run merge and check documents are moved to the master record
merge = self.env['crm.merge.opportunity'].with_context({
'active_model': 'crm.lead',
'active_ids': self.leads.ids,
'active_id': False,
}).create({
'team_id': self.sales_team_convert.id,
'user_id': False,
})
result = merge.action_merge()
master_lead = self.leads.filtered(lambda lead: lead.id == result['res_id'])
# check result of merge process
self.assertEqual(master_lead, self.lead_w_partner_company)
# records updated
self.assertEqual(calendar_event.opportunity_id, master_lead)
self.assertEqual(calendar_event.res_id, master_lead.id)
self.assertTrue(all(att.res_id == master_lead.id for att in attachments))
# 2many accessors updated
self.assertEqual(master_lead.activity_ids, activity)
self.assertEqual(master_lead.calendar_event_ids, calendar_event)
+3 -3
View File
@@ -30,9 +30,9 @@
context="{'partner_id': partner_id}"
attrs="{'invisible': [('type', '=', 'lead')]}">
<div class="o_stat_info">
<field name="meeting_count" class="o_stat_value"/>
<span class="o_stat_text" attrs="{'invisible': [('meeting_count', '&lt;', 2)]}"> Meetings</span>
<span class="o_stat_text" attrs="{'invisible': [('meeting_count', '&gt;', 1)]}"> Meeting</span>
<field name="calendar_event_count" class="o_stat_value"/>
<span class="o_stat_text" attrs="{'invisible': [('calendar_event_count', '&lt;', 2)]}"> Meetings</span>
<span class="o_stat_text" attrs="{'invisible': [('calendar_event_count', '&gt;', 1)]}"> Meeting</span>
</div>
</button>
<button name="action_show_potential_duplicates" type="object"
-1
View File
@@ -11,7 +11,6 @@
<field name="arch" type="xml">
<field name="mobile" position="after">
<field name="opportunity_count"/>
<field name="meeting_count"/>
</field>
<xpath expr="//span[hasclass('oe_kanban_partner_links')]" position="inside">
<span class="badge badge-pill" t-if="record.opportunity_count.value>0"><i class="fa fa-fw fa-star" aria-label="Favorites" role="img" title="Favorites"/><t t-esc="record.opportunity_count.value"/></span>
+8
View File
@@ -22,3 +22,11 @@ class Lead(models.Model):
def _compute_registration_count(self):
for record in self:
record.registration_count = len(record.registration_ids)
def _merge_dependences(self, opportunities):
super(Lead, self)._merge_dependences(opportunities)
# merge registrations as sudo, as crm people may not have access to event rights
self.sudo().write({
'registration_ids': [(4, registration.id) for registration in opportunities.sudo().registration_ids]
})
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_event_crm_flow
from . import test_crm_lead_merge
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.crm.tests.test_crm_lead_merge import TestLeadMergeCommon
from odoo.addons.event_crm.tests.common import TestEventCrmCommon
from odoo.tests.common import tagged, users
@tagged('lead_manage')
class TestLeadCrmMerge(TestLeadMergeCommon, TestEventCrmCommon):
@users('user_sales_manager')
def test_merge_method_dependencies(self):
""" Test if dependences for leads are not lost while merging leads. In
this test leads are ordered as
lead_w_contact -----------lead---seq=30
lead_w_email -------------lead---seq=3
lead_1 -------------------lead---seq=1
lead_w_partner_company ---lead---seq=1----------------registrations
lead_w_partner -----------lead---seq=False
"""
TestLeadMergeCommon.merge_fields.append('registration_ids')
registration = self.env['event.registration'].sudo().create({
'partner_id': self.event_customer.id,
'event_id': self.event_0.id,
'lead_ids': [(4, self.lead_w_partner_company.id)],
})
self.assertEqual(self.lead_w_partner_company.registration_ids, registration)
leads = self.env['crm.lead'].browse(self.leads.ids)._sort_by_confidence_level(reverse=True)
with self.assertLeadMerged(self.lead_w_contact, leads,
name=self.lead_w_contact.name,
registration_ids=registration
):
leads._merge_opportunity(auto_unlink=False, max_length=None)
+6
View File
@@ -83,3 +83,9 @@ class CrmLead(models.Model):
action['views'] = [(self.env.ref('sale.view_order_form').id, 'form')]
action['res_id'] = orders.id
return action
def _merge_get_fields_specific(self):
fields_info = super(CrmLead, self)._merge_get_fields_specific()
# add all the orders from all lead to merge
fields_info['order_ids'] = lambda fname, leads: [(4, order.id) for order in leads.order_ids]
return fields_info
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_crm_lead_convert_quotation
from . import test_crm_lead_merge
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.crm.tests.test_crm_lead_merge import TestLeadMergeCommon
from odoo.tests.common import tagged, users
@tagged('lead_manage')
class TestLeadSaleMerge(TestLeadMergeCommon):
@users('user_sales_manager')
def test_merge_method_dependencies(self):
""" Test if dependences for leads are not lost while merging leads. In
this test leads are ordered as
lead_w_contact -----------lead---seq=30
lead_w_email -------------lead---seq=3
lead_1 -------------------lead---seq=1
lead_w_partner_company ---lead---seq=1----------------orders
lead_w_partner -----------lead---seq=False------------orders
"""
TestLeadMergeCommon.merge_fields.append('order_ids')
orders = self.env['sale.order'].sudo().create([
{'partner_id': self.contact_1.id,
'opportunity_id': self.lead_w_partner_company.id,
},
{'partner_id': self.contact_1.id,
'opportunity_id': self.lead_w_partner.id,
}
])
self.assertEqual(self.lead_w_partner_company.order_ids, orders[0])
self.assertEqual(self.lead_w_partner.order_ids, orders[1])
leads = self.env['crm.lead'].browse(self.leads.ids)._sort_by_confidence_level(reverse=True)
with self.assertLeadMerged(self.lead_w_contact, leads,
name=self.lead_w_contact.name,
order_ids=orders
):
leads._merge_opportunity(auto_unlink=False, max_length=None)
+1
View File
@@ -3,3 +3,4 @@
from . import test_website_crm
from . import test_website_visitor
from . import test_crm_lead_merge
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.crm.tests.test_crm_lead_merge import TestLeadMergeCommon
from odoo.tests.common import tagged, users
@tagged('lead_manage')
class TestLeadVisitorMerge(TestLeadMergeCommon):
@users('user_sales_manager')
def test_merge_method_dependencies(self):
""" Test if dependences for leads are not lost while merging leads. In
this test leads are ordered as
lead_w_contact -----------lead---seq=30
lead_w_email -------------lead---seq=3
lead_1 -------------------lead---seq=1
lead_w_partner_company ---lead---seq=1----------------visitor
lead_w_partner -----------lead---seq=False------------visitor
"""
TestLeadMergeCommon.merge_fields.append('visitor_ids')
visitors = self.env['website.visitor'].sudo().create([
{'name': 'Visitor 1',
'lead_ids': [(4, self.lead_w_partner_company.id)],
},
{'name': 'Visitor 2',
'lead_ids': [(4, self.lead_w_partner.id)],
}
])
self.assertEqual(self.lead_w_partner_company.visitor_ids, visitors[0])
self.assertEqual(self.lead_w_partner.visitor_ids, visitors[1])
leads = self.env['crm.lead'].browse(self.leads.ids)._sort_by_confidence_level(reverse=True)
with self.assertLeadMerged(self.lead_w_contact, leads,
name=self.lead_w_contact.name,
visitor_ids=visitors
):
leads._merge_opportunity(auto_unlink=False, max_length=None)