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'])