From 6df2f0cfc0309badfd58471e413ece695d629009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 8 Jan 2021 15:46:53 +0000 Subject: [PATCH] [REF] crm: limit allocation to teams when running automatic assign GLOBAL PURPOSE Ability to have salesmen belonging to several sales team is a core requirement of CRM. It is therefore moved from website_crm_score to crm along with cleaning and behavior improvement. Automatic lead assignment is also moved and cleaned. SPECIFICATIONS Currently when assignment runs all free leads are allocated to teams. They are then assigned to members according to their capacity. This means that no lead is kept free. If assignment is performed on a subset of teams this means that other teams have no free lead anymore. During assignment evaluate which teams still need to receive leads. This is based on team maximum capacity. We consider a team should receive twice its capacity as leads. That way members will receive leads and can pick some leads in team unassigned pool of leads. e.g. a team has a total of capacity of 90 leads / month among its 3 members. When running a daily assignment process, an equivalent of twice this running time is assigned to members (see previous commits). It leads to assignment of (90 / 30) * 2 = 6 leads. Twice this count is allocated to the team aka 12 leads. Remaining leads will be allocated in next assignment run or are available for manual assign. LINKS Task ID-2086889 (main task) Task ID-2357969 (scoring migration task) Community PR odoo/odoo#48422 Enterprise PR odoo/enterprise#499 Upgrade PR odoo/upgrade#996 --- addons/crm/models/crm_team.py | 40 +++++++++++++------- addons/crm/tests/test_crm_lead_assignment.py | 32 ++++++++++++++-- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py index d75237bf5ad..d31d9b68593 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -306,10 +306,10 @@ class Team(models.Model): are also merged. Purpose is to clean database and avoid assigning duplicates to same or different teams; - * for all teams that still have capacity (aka: a search on unassigned - available leads with team domain still give results), do another - assignment round. Each round teams are randomized so that team order - is not always the same; + * evaluate which teams still need to receive leads. This is based on + team maximum capacity. We consider a team should receive twice its + capacity as leads. That way members will receive leads and can pick + some leads in team unassigned pool of leads; Note that leads are assigned in batch meaning a team could receive leads that could better fit another team. However this heuristics is @@ -356,6 +356,9 @@ class Team(models.Model): raise ValueError( _('Leads team allocation should be done for at least 1 or maximum 30 work days, not %s.', work_days) ) + # assignment_max is valid for "30 days" -> divide by requested work_days + # to have number of leads to assign + assign_ratio = work_days / 30.0 BUNDLE_HOURS_DELAY = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.delay', default=0)) BUNDLE_SIZE = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.bundle', default=50)) @@ -365,14 +368,21 @@ class Team(models.Model): remaining_teams = self.env['crm.team'].browse(random.sample(self.ids, k=len(self.ids))) # compute assign domain for each team before looping on them by bundle size - teams_domain = dict.fromkeys(remaining_teams, False) - for team in remaining_teams: - teams_domain[team] = safe_eval(team.assignment_domain or '[]', LEAD_ASSIGN_EVAL_CONTEXT) - + teams_domain = dict( + (team, safe_eval(team.assignment_domain or '[]', LEAD_ASSIGN_EVAL_CONTEXT)) + for team in remaining_teams + ) + # compute limit of leads to assign to each team: 2 times team capacity, based on given work_days + teams_limit = dict( + (team, 2 * team.assignment_max * assign_ratio) + for team in remaining_teams + ) + # assignment process data teams_data = dict.fromkeys(remaining_teams, False) for team in remaining_teams: teams_data[team] = dict(assigned=set(), merged=set(), duplicates=set()) + remaining_teams = remaining_teams.filtered('assignment_max') while remaining_teams: for team in remaining_teams: lead_domain = expression.AND([ @@ -381,20 +391,24 @@ class Team(models.Model): ['&', ('team_id', '=', False), ('user_id', '=', False)], ['|', ('stage_id.is_won', '=', False), ('probability', 'not in', [False, 0, 100])] ]) - leads = self.env["crm.lead"].search(lead_domain, limit=BUNDLE_SIZE) - - if len(leads) < BUNDLE_SIZE: - team_done += team + # assign only to reach asked team limit + remaining = teams_limit[team] - (len(teams_data[team]['assigned']) + len(teams_data[team]['merged'])) + lead_limit = min([BUNDLE_SIZE, remaining if remaining > 0 else 1]) + leads = self.env["crm.lead"].search(lead_domain, limit=lead_limit) # assign + deduplicate and concatenate results in teams_data to keep some history assign_res = team._allocate_leads_deduplicate(leads) - _logger.info('Assigned %s leads to team %s' % (len(leads), team.id)) + _logger.info('Assigned %d leads among %d candidates to team %s' % (len(assign_res['assigned']) + len(assign_res['merged']), len(leads), team.id)) _logger.info('\tLeads: direct assign %s / merge result %s / duplicates merged: %s' % ( assign_res['assigned'], assign_res['merged'], assign_res['duplicates'] )) for key in ('assigned', 'merged', 'duplicates'): teams_data[team][key].update(assign_res[key]) + # either no more lead matching domain, either asked capacity assigned + if len(leads) < lead_limit or (len(teams_data[team]['assigned']) + len(teams_data[team]['merged'])) >= teams_limit[team]: + team_done += team + # auto-commit except in testing mode. As this process may be time consuming or we # may encounter errors, already commit what is allocated to avoid endless cron loops. auto_commit = not getattr(threading.currentThread(), 'testing', False) diff --git a/addons/crm/tests/test_crm_lead_assignment.py b/addons/crm/tests/test_crm_lead_assignment.py index a751753d802..750e3df705d 100644 --- a/addons/crm/tests/test_crm_lead_assignment.py +++ b/addons/crm/tests/test_crm_lead_assignment.py @@ -115,9 +115,17 @@ class TestLeadAssign(TestLeadConvertCommon): lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2) with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=505): # crm only: 505 + with self.assertQueryCount(user_sales_manager=467): # crm only: 460 self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) + # teams assign + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + leads_st1 = leads.filtered_domain([('team_id', '=', self.sales_team_1.id)]) + leads_stc = leads.filtered_domain([('team_id', '=', self.sales_team_convert.id)]) + self.assertEqual(len(leads_st1), 10) # 2 * 2 * 75 / 30.0 + self.assertEqual(len(leads_stc), 12) # 2 * 2 * 90 / 30.0 + + # salespersons assign self.members.invalidate_cache(fnames=['lead_month_count']) self.assertMemberAssign(self.sales_team_1_m1, 3) # 45 max on 2 days self.assertMemberAssign(self.sales_team_1_m2, 1) # 15 max on 2 days @@ -125,7 +133,15 @@ class TestLeadAssign(TestLeadConvertCommon): self.assertMemberAssign(self.sales_team_convert_m1, 2) # 30 max on 15 self.assertMemberAssign(self.sales_team_convert_m2, 4) # 60 max on 15 - # deduplicate should have removed all duplicated linked to contact_1 / contact_2 + # run a second round to finish leads + with self.with_user('user_sales_manager'): + self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) + + # teams assign: everything should be done due to duplicates + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + self.assertTrue(len(leads.filtered_domain([('team_id', '=', False)])) == 0) + + # deduplicate should have removed all duplicated linked to contact_1 and contact_2 new_assigned_leads_wpartner = self.env['crm.lead'].search([ ('partner_id', 'in', (self.contact_1 | self.contact_2).ids), ('id', 'in', leads.ids) @@ -149,9 +165,17 @@ class TestLeadAssign(TestLeadConvertCommon): lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2) with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=319): # crm only: 319 + with self.assertQueryCount(user_sales_manager=206): # crm only: 201 self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) + # teams assign + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + leads_st1 = leads.filtered_domain([('team_id', '=', self.sales_team_1.id)]) + leads_stc = leads.filtered_domain([('team_id', '=', self.sales_team_convert.id)]) + self.assertEqual(len(leads_st1), 10) # 2 * 2 * 75 / 30.0 + self.assertEqual(len(leads_stc), 12) # 2 * 2 * 90 / 30.0 + + # salespersons assign self.members.invalidate_cache(fnames=['lead_month_count']) self.assertMemberAssign(self.sales_team_1_m1, 3) # 45 max on 2 days self.assertMemberAssign(self.sales_team_1_m2, 1) # 15 max on 2 days @@ -216,7 +240,7 @@ class TestLeadAssign(TestLeadConvertCommon): lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2) with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=6199): # crm only: 6199 + with self.assertQueryCount(user_sales_manager=6289): # crm only: 6165 self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30) self.members.invalidate_cache(fnames=['lead_month_count'])