From fe8c5b9b01c01ddb7cc516dfd1ad97bf537db655 Mon Sep 17 00:00:00 2001 From: Thibault Francois Date: Mon, 29 Mar 2021 12:46:33 +0000 Subject: [PATCH] [IMP] crm: change in assignation process and limit PURPOSE All unassigned leads should be assigned to teams as soon as possible to ease lead analysis. Purpose of assign thresholds is to ensure sales people receive at least this amount of leads within 30 days, counting lost and won leads. Giving them leads regularly is also one goal of automatic assign. SPECIFICATIONS: TEAM ALLOCATION Team assignment has to be updated as we may have team domains that overlap. We therefore remove maximum number of leads to allocate to teams. Instead all available unassigned leads are allocated within teams. - Solution: assign all available leads and not a count based on team's capacity. This notably reverts the main goal of odoo/odoo@6df2f0cfc0309badfd58471e413ece695d629009 (see odoo/odoo#48422) This assignment process is done proportionally to the team capacity. It is computed as the sum of each member's maximum assignment counter. This means that with a team having twice as much sale capacity than another team sharing the same domain: first team should receive about 2/3 of leads while the second one should receive the remaining 1/3. - Solution: assign lead one by one. Choose a team randomly using a weighted random algorithm, based on team's members capacity. SPECIFICATIONS: MEMBER ASSIGN Counting every lead whatever its state may lead to an inconvenient situation. Moreover salespersons may opt-out from assign by setting their max capacity to 0, for example when going on holidays. When doing that lead assignment is not smooth and getting back to a full pipe may take several days. To solve that issue a compensation is added in assignment quota done to sales people. Salespersons having few leads will get a boost in assign as soon as they get back in assign process. When being near maximum compensation is nearing 0 and daily quota is given. PERFORMANCE During team assignation, assigning lead one by one may cause performance issue. Since PLS is computed at each flush, so we want to flush after a bunch of lead. To solve that we obviously need to not commit at each assignation but also avoid to search for duplicate at each assignation, that why the search for duplicates is done at the beginning of the process and stored in memory. LINKS Task ID-2444908 (assign fixes) Task ID-2489951 (assign process improvements) COM PR odoo/odoo#70172 X-original-commit: 83f72711e7affa8b0ad997b9ee262326e9577d54 --- addons/crm/models/crm_team.py | 213 +++++++++---------- addons/crm/models/crm_team_member.py | 33 ++- addons/crm/tests/test_crm_lead_assignment.py | 109 ++++++++-- addons/crm/tests/test_performances.py | 57 +++-- 4 files changed, 239 insertions(+), 173 deletions(-) diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py index bd78e2f3453..448814fd6e6 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -263,7 +263,7 @@ class Team(models.Model): if not self.env.user.has_group('sales_team.group_sale_manager') and not self.env.user.has_group('base.group_system'): raise exceptions.UserError(_('Lead/Opportunities automatic assignment is limited to managers or administrators')) - _logger.info('### START Lead Assignment (%d teams, %d sales persons, %.2f work_days)' % (len(self), len(self.crm_team_member_ids), work_days)) + _logger.info('### START Lead Assignment (%d teams, %d sales persons, %.2f work_days)', len(self), len(self.crm_team_member_ids), work_days) teams_data = self._allocate_leads(work_days=work_days) _logger.info('### Team repartition done. Starting salesmen assignment.') members_data = self.crm_team_member_ids._assign_and_convert_leads(work_days=work_days) @@ -342,54 +342,45 @@ class Team(models.Model): No salesperson is assigned in this process. Its purpose is simply to allocate leads within teams. + This process allocates all available leads on teams weighted by their + maximum assignment by month that indicates their relative workload. + Heuristic of this method is the following: - - * first we randomize all teams; - * then for each team - - * find unassigned leads, aka leads being - - * without team, without user -> not assigned; - * not in a won stage, and not having False/0 (lost) or 100 (won) - probability) -> live leads; - * if set, a delay after creation can be applied (see BUNDLE_HOURS_DELAY) - parameter explanations here below; - - * keep only leads matching the team's assignment domain (empty means + * find unassigned leads for each team, aka leads being + * without team, without user -> not assigned; + * not in a won stage, and not having False/0 (lost) or 100 (won) + probability) -> live leads; + * if set, a delay after creation can be applied (see BUNDLE_HOURS_DELAY) + parameter explanations here below; + * matching the team's assignment domain (empty means everything); - * assign maximum BUNDLE_SIZE leads to the team, then move to the - next team. This is done to ensure every team will have leads - enough to fill its capacity based on its domain; - * when setting a team on leads, leads belonging to the current batch - are also merged. Purpose is to clean database and avoid assigning + + * assign a weight to each team based on their assignment_max that + indicates their relative workload; + + * pick a random team using a weighted random choice and find a lead + to assign: + + * remove already assigned leads from the available leads. If there + is not any lead spare to assign, remove team from active teams; + * pick the first lead and set the current team; + * when setting a team on leads, leads are also merged with their + duplicates. Purpose is to clean database and avoid assigning duplicates to same or different teams; + * add lead and its duplicates to already assigned leads; - * 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; + * pick another random team until their is no more leads to assign + to any team; - Note that leads are assigned in batch meaning a team could receive - leads that could better fit another team. However this heuristics is - based on hypothesis that team domains do not overlap. Indeed if a - company has several teams they will probably target separate market - segments: country-based, customer type or size, ... Having several - teams using same assignment domain could lead to less fairness in - assignment process but this should not be the target use case of this - heuristic. + This process ensure that teams having overlapping domains will all + receive leads as lead allocation is done one lead at a time. This + allocation will be proportional to their size (assignment of their + members). - Leads are allocated by batch. This can be configured using a config - parameter (see here below). Batch size depends on cron frequency, - lead pipeline size and members assignment maximum. Finding an optimal - heuristic for this parameter is not easy as it depends on internal - processes and organization. Higher batch size leads to better performances - when running automatic assignment. It can also give unfair results - if teams domain overlap or if pipeline is not big enough to fill all - teams capacity. - - :config int crm.assignment.bundle: optional config parameter allowing - to set size of lead batch (BUNDLE_SIZE) allocated to a team at each - iteration (50 by default based on experience); + :config int crm.assignment.bundle: deprecated + :config int crm.assignment.commit.bundle: optional config parameter allowing + to set size of lead batch to be committed together. By default 100 + which is a good trade-off between transaction time and speed :config int crm.assignment.delay: optional config parameter giving a delay before taking a lead into assignment process (BUNDLE_HOURS_DELAY) given in hours. Purpose if to allow other crons or automated actions @@ -414,85 +405,92 @@ class Team(models.Model): raise ValueError( _('Leads team allocation should be done for at least 0.2 or maximum 30 work days, not %.2f.', 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)) + BUNDLE_COMMIT_SIZE = int(self.env['ir.config_parameter'].sudo().get_param('crm.assignment.commit.bundle', 100)) + auto_commit = not getattr(threading.currentThread(), 'testing', False) # leads max_create_dt = fields.Datetime.now() - datetime.timedelta(hours=BUNDLE_HOURS_DELAY) duplicates_lead_cache = dict() - # teams - team_done = self.env['crm.team'] - 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( - (team, literal_eval(team.assignment_domain or '[]')) - 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 - ) + # teams data + teams_data, population, weights = dict(), list(), list() + for team in self: + if not team.assignment_max: + continue + + lead_domain = expression.AND([ + literal_eval(team.assignment_domain or '[]'), + [('create_date', '<', max_create_dt)], + ['&', ('team_id', '=', False), ('user_id', '=', False)], + ['|', ('stage_id', '=', False), ('stage_id.is_won', '=', False)] + ]) + + leads = self.env["crm.lead"].search(lead_domain) + # Fill duplicate cache: search for duplicate lead before the assignation + # avoid to flush during the search at every assignation + for lead in leads: + if lead not in duplicates_lead_cache: + duplicates_lead_cache[lead] = lead._get_lead_duplicates(email=lead.email_from) + + teams_data[team] = { + "team": team, + "leads": leads, + "assigned": set(), + "merged": set(), + "duplicates": set(), + } + population.append(team) + weights.append(team.assignment_max) + # assignment process data global_data = dict(assigned=set(), merged=set(), duplicates=set()) - teams_data = dict.fromkeys(remaining_teams, False) - for team in remaining_teams: - teams_data[team] = dict(assigned=set(), merged=set(), duplicates=set()) + leads_done_ids, lead_unlink_ids, counter = set(), set(), 0 + while population: + counter += 1 + team = random.choices(population, weights=weights, k=1)[0] - remaining_teams = remaining_teams.filtered('assignment_max') - while remaining_teams: - for team in remaining_teams: - lead_domain = expression.AND([ - teams_domain[team], - [('create_date', '<', max_create_dt)], - ['&', ('team_id', '=', False), ('user_id', '=', False)], - ['|', ('stage_id', '=', False), ('stage_id.is_won', '=', False)] - ]) - # 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) + # filter remaining leads, remove team if no more leads for it + teams_data[team]["leads"] = teams_data[team]["leads"].filtered(lambda l: l.id not in leads_done_ids) + if not teams_data[team]["leads"]: + population_index = population.index(team) + population.pop(population_index) + weights.pop(population_index) + continue - # Fill duplicate cache: search for duplicate lead before the assignation - # avoid to flush during the search at every assignation - for lead in leads: - if lead not in duplicates_lead_cache: - duplicates_lead_cache[lead] = lead._get_lead_duplicates(email=lead.email_from) - - # assign + deduplicate and concatenate results in teams_data to keep some history - assign_res = team._allocate_leads_deduplicate(leads, duplicates_cache=duplicates_lead_cache) - _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'): - global_data[key].update(assign_res[key]) - teams_data[team][key].update(assign_res[key]) + # assign + deduplicate and concatenate results in teams_data to keep some history + candidate_lead = teams_data[team]["leads"][0] + assign_res = team._allocate_leads_deduplicate(candidate_lead, duplicates_cache=duplicates_lead_cache) + for key in ('assigned', 'merged', 'duplicates'): + teams_data[team][key].update(assign_res[key]) + leads_done_ids.update(assign_res[key]) + global_data[key].update(assign_res[key]) + lead_unlink_ids.update(assign_res['duplicates']) + # 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. + if auto_commit and counter % BUNDLE_COMMIT_SIZE == 0: # unlink duplicates once - self.env['crm.lead'].browse(assign_res['duplicates']).unlink() + self.env['crm.lead'].browse(lead_unlink_ids).unlink() + lead_unlink_ids = set() + self._cr.commit() - # 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 + # unlink duplicates once + self.env['crm.lead'].browse(lead_unlink_ids).unlink() - # 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) - if auto_commit: - self._cr.commit() - - remaining_team_ids = (remaining_teams - team_done).ids - remaining_teams = self.env['crm.team'].browse(random.sample(remaining_team_ids, k=len(remaining_team_ids))) + if auto_commit: + self._cr.commit() # some final log - _logger.info('## Assigned %s leads' % (len(global_data['assigned']) + len(global_data['merged']))) - + _logger.info('## Assigned %s leads', (len(global_data['assigned']) + len(global_data['merged']))) + for team, team_data in teams_data.items(): + _logger.info( + '## Assigned %s leads to team %s', + len(team_data['assigned']) + len(team_data['merged']), team.id) + _logger.info( + '\tLeads: direct assign %s / merge result %s / duplicates merged: %s', + team_data['assigned'], team_data['merged'], team_data['duplicates']) return teams_data def _allocate_leads_deduplicate(self, leads, duplicates_cache=None): @@ -538,11 +536,6 @@ class Team(models.Model): leads_dup_ids.update((lead_duplicates - merged).ids) leads_merged_ids.add(merged.id) - # auto-commit except in testing mode - auto_commit = not getattr(threading.currentThread(), 'testing', False) - if auto_commit: - self._cr.commit() - return { 'assigned': set(leads_assigned.ids), 'merged': leads_merged_ids, diff --git a/addons/crm/models/crm_team_member.py b/addons/crm/models/crm_team_member.py index 6da97a97a67..1c1995761e2 100644 --- a/addons/crm/models/crm_team_member.py +++ b/addons/crm/models/crm_team_member.py @@ -119,21 +119,15 @@ class Team(models.Model): raise ValueError( _('Leads team allocation should be done for at least 0.2 or maximum 30 work days, not %.2f.', 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 members_data, population, weights = dict(), list(), list() - members = self.filtered(lambda member: not member.assignment_optout and member.assignment_max > member.lead_month_count) + members = self.filtered(lambda member: not member.assignment_optout and member.assignment_max > 0) if not members: return members_data # prepare a global lead count based on total leads to assign to salespersons lead_limit = sum( - min( - int(math.ceil(member.assignment_max * assign_ratio)), - (member.assignment_max - member.lead_month_count) - ) + member._get_assignment_quota(work_days=work_days) for member in members ) @@ -146,7 +140,7 @@ class Team(models.Model): leads = self.env["crm.lead"].search(lead_domain, order='probability DESC', limit=lead_limit) - to_assign = min(member.assignment_max - member.lead_month_count, round(member.assignment_max * assign_ratio)) + to_assign = member._get_assignment_quota(work_days=work_days) members_data[member.id] = { "team_member": member, "max": member.assignment_max, @@ -199,7 +193,24 @@ class Team(models.Model): (member_info["team_member"], {"assigned": member_info["assigned"]}) for member_id, member_info in members_data.items() ) - _logger.info('Assigned %s leads to %s salesmen' % (len(leads_done_ids), len(members))) + _logger.info('Assigned %s leads to %s salesmen', len(leads_done_ids), len(members)) for member, member_info in result_data.items(): - _logger.info('-> member %s: assigned %d leads (%s)' % (member.id, len(member_info["assigned"]), member_info["assigned"])) + _logger.info('-> member %s: assigned %d leads (%s)', member.id, len(member_info["assigned"]), member_info["assigned"]) return result_data + + def _get_assignment_quota(self, work_days=1): + """ Compute assignment quota based on work_days. This quota includes + a compensation to speedup getting to the lead average (``assignment_max``). + As this field is a counter for "30 days" -> divide by requested work + days in order to have base assign number then add compensation. Limit + to max capacity of team member. + + :param float work_days: see ``CrmTeam.action_assign_leads()``; + """ + assign_ratio = work_days / 30.0 + to_assign = self.assignment_max * assign_ratio + compensation = max(0, self.assignment_max - (self.lead_month_count + to_assign)) * 0.2 + return min( + self.assignment_max - self.lead_month_count, + round(to_assign + compensation) + ) diff --git a/addons/crm/tests/test_crm_lead_assignment.py b/addons/crm/tests/test_crm_lead_assignment.py index aca75789dc1..5d9431047dc 100644 --- a/addons/crm/tests/test_crm_lead_assignment.py +++ b/addons/crm/tests/test_crm_lead_assignment.py @@ -177,7 +177,7 @@ class TestLeadAssign(TestLeadAssignCommon): lead_type='lead', user_ids=[False], partner_ids=[self.contact_1.id, self.contact_2.id, False, False, False], - count=50 + count=200 ) # commit probability and related fields leads.flush() @@ -196,25 +196,20 @@ class TestLeadAssign(TestLeadAssignCommon): self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) # teams assign - # due to duplicate management keeping master team, we may not ensure leads to be - # fulfilling their original team volume 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 + self.assertLessEqual(len(leads_st1), 128) + self.assertLessEqual(len(leads_stc), 96) + self.assertEqual(len(leads_st1) + len(leads_stc), len(leads)) # Make sure all lead are assigned # 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 - self.assertMemberAssign(self.sales_team_1_m3, 1) # 15 max on 2 days - self.assertMemberAssign(self.sales_team_convert_m1, 2) # 30 max on 15 - self.assertMemberAssign(self.sales_team_convert_m2, 4) # 60 max on 15 - - # 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) + self.assertMemberAssign(self.sales_team_1_m1, 11) # 45 max on 2 days (3) + compensation (8.4) + self.assertMemberAssign(self.sales_team_1_m2, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_1_m3, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_convert_m1, 8) # 30 max on 15 (2) + compensation (5.6) + self.assertMemberAssign(self.sales_team_convert_m2, 15) # 60 max on 15 (4) + compsantion (11.2) # teams assign: everything should be done due to duplicates leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order @@ -233,7 +228,7 @@ class TestLeadAssign(TestLeadAssignCommon): lead_type='lead', user_ids=[False], partner_ids=[False], - count=50 + count=100 ) # commit probability and related fields leads.flush() @@ -255,16 +250,17 @@ class TestLeadAssign(TestLeadAssignCommon): 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 + self.assertEqual(len(leads_st1) + len(leads_stc), 100) # 2 * 2 * 75 / 30.0 + self.assertLessEqual(len(leads_st1), 100) # 2 * 2 * 75 / 30.0 + self.assertLessEqual(len(leads_stc), 66) # 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 - self.assertMemberAssign(self.sales_team_1_m3, 1) # 15 max on 2 days - self.assertMemberAssign(self.sales_team_convert_m1, 2) # 30 max on 15 - self.assertMemberAssign(self.sales_team_convert_m2, 4) # 60 max on 15 + self.assertMemberAssign(self.sales_team_1_m1, 11) # 45 max on 2 days (3) + compensation (8.4) + self.assertMemberAssign(self.sales_team_1_m2, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_1_m3, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_convert_m1, 8) # 30 max on 15 (2) + compensation (5.6) + self.assertMemberAssign(self.sales_team_convert_m2, 15) # 60 max on 15 (4) + compsantion (11.2) @mute_logger('odoo.models.unlink') def test_assign_populated(self): @@ -331,6 +327,12 @@ class TestLeadAssign(TestLeadAssignCommon): with self.with_user('user_sales_manager'): self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30) + # teams assign + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) + self.assertEqual(leads.team_id, sales_teams) + self.assertEqual(leads.user_id, sales_teams.member_ids) + + # salespersons assign self.members.invalidate_cache(fnames=['lead_month_count']) self.assertMemberAssign(self.sales_team_1_m1, 45) # 45 max on one month self.assertMemberAssign(self.sales_team_1_m2, 15) # 15 max on one month @@ -341,6 +343,69 @@ class TestLeadAssign(TestLeadAssignCommon): self.assertMemberAssign(sales_team_3_m2, 60) # 60 max on one month self.assertMemberAssign(sales_team_3_m3, 15) # 15 max on one month + def test_assign_quota(self): + """ Test quota computation """ + self.assertInitialData() + + # quota computation without existing leads + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=1), + 10, + "Assignment quota: 45 max on 1 days -> 1.5, compensation (45-1.5)/5 -> 8.7" + ) + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=2), + 11, + "Assignment quota: 45 max on 2 days -> 3, compensation (45-3)/5 -> 8.4" + ) + + # quota should not exceed maximum + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=30), + 45, + "Assignment quota: anyway 45 max available" + ) + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=60), + 45, + "Assignment quota: anyway 45 max available" + ) + + # create exiting leads for user_sales_leads (sales_team_1_m1) + existing_leads = self._create_leads_batch( + lead_type='lead', user_ids=[self.user_sales_leads.id], + probabilities=[10], + count=30) + self.assertEqual(existing_leads.team_id, self.sales_team_1, "Team should have lower sequence") + existing_leads.flush() + + self.sales_team_1_m1.invalidate_cache(fnames=['lead_month_count']) + self.assertEqual(self.sales_team_1_m1.lead_month_count, 30) + + # quota computation with existing leads + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=1), + 4, + "Assignment quota: 45 max on 1 days -> 1.5, compensation (45-30-1.5)/5 -> 2.7" + ) + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=2), + 5, + "Assignment quota: 45 max on 2 days -> 3, compensation (45-30-3)/5 -> 2.4" + ) + + # quota should not exceed maximum + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=30), + 15, + "Assignment quota: anyway 15 max available (30 already assigned)" + ) + self.assertEqual( + self.sales_team_1_m1._get_assignment_quota(work_days=60), + 15, + "Assignment quota: anyway 15 max available (30 already assigned)" + ) + def test_assign_specific_won_lost(self): """ Test leads taken into account in assign process: won, lost, stage configuration. """ diff --git a/addons/crm/tests/test_performances.py b/addons/crm/tests/test_performances.py index 42de8145b7b..155adf7d612 100644 --- a/addons/crm/tests/test_performances.py +++ b/addons/crm/tests/test_performances.py @@ -34,7 +34,7 @@ class TestLeadAssignPerf(TestLeadAssignCommon): lead_type='lead', user_ids=[False], partner_ids=[self.contact_1.id, self.contact_2.id, False, False, False], - count=50 + count=200 ) # commit probability and related fields leads.flush() @@ -50,34 +50,24 @@ class TestLeadAssignPerf(TestLeadAssignCommon): leads.flush() with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=483): # crm only: ?? + with self.assertQueryCount(user_sales_manager=1333): # crm only: ?? self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) # teams assign - # due to duplicate management keeping master team, we may not ensure leads to be - # fulfilling their original team volume 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 + self.assertLessEqual(len(leads_st1), 128) + self.assertLessEqual(len(leads_stc), 96) + self.assertEqual(len(leads_st1) + len(leads_stc), len(leads)) # Make sure all lead are assigned # 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 - self.assertMemberAssign(self.sales_team_1_m3, 1) # 15 max on 2 days - self.assertMemberAssign(self.sales_team_convert_m1, 2) # 30 max on 15 - self.assertMemberAssign(self.sales_team_convert_m2, 4) # 60 max on 15 - - # run a second round to finish leads - with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=137): # crm only: ?? - 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.assertEqual(len(leads.filtered_domain([('team_id', '=', False)])), 0) + self.assertMemberAssign(self.sales_team_1_m1, 11) # 45 max on 2 days (3) + compensation (8.4) + self.assertMemberAssign(self.sales_team_1_m2, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_1_m3, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_convert_m1, 8) # 30 max on 15 (2) + compensation (5.6) + self.assertMemberAssign(self.sales_team_convert_m2, 15) # 60 max on 15 (4) + compsantion (11.2) @mute_logger('odoo.models.unlink', 'odoo.addons.crm.models.crm_team', 'odoo.addons.crm.models.crm_team_member') def test_assign_perf_no_duplicates(self): @@ -85,7 +75,7 @@ class TestLeadAssignPerf(TestLeadAssignCommon): lead_type='lead', user_ids=[False], partner_ids=[False], - count=50 + count=100 ) # commit probability and related fields leads.flush() @@ -99,23 +89,24 @@ class TestLeadAssignPerf(TestLeadAssignCommon): lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2) with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=209): # crm only: 209 (seems reproducible) + with self.assertQueryCount(user_sales_manager=580): # crm only: 580 (seems reproducible) 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 + self.assertEqual(len(leads_st1) + len(leads_stc), 100) # 2 * 2 * 75 / 30.0 + self.assertLessEqual(len(leads_st1), 100) # 2 * 2 * 75 / 30.0 + self.assertLessEqual(len(leads_stc), 66) # 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 - self.assertMemberAssign(self.sales_team_1_m3, 1) # 15 max on 2 days - self.assertMemberAssign(self.sales_team_convert_m1, 2) # 30 max on 15 - self.assertMemberAssign(self.sales_team_convert_m2, 4) # 60 max on 15 + self.assertMemberAssign(self.sales_team_1_m1, 11) # 45 max on 2 days (3) + compensation (8.4) + self.assertMemberAssign(self.sales_team_1_m2, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_1_m3, 4) # 15 max on 2 days (1) + compensation (2.8) + self.assertMemberAssign(self.sales_team_convert_m1, 8) # 30 max on 15 (2) + compensation (5.6) + self.assertMemberAssign(self.sales_team_convert_m2, 15) # 60 max on 15 (4) + compsantion (11.2) @mute_logger('odoo.models.unlink', 'odoo.addons.crm.models.crm_team', 'odoo.addons.crm.models.crm_team_member') def test_assign_perf_populated(self): @@ -180,9 +171,15 @@ class TestLeadAssignPerf(TestLeadAssignCommon): leads.flush() with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=6293): # crm only: ?? + with self.assertQueryCount(user_sales_manager=5384): # crm only: ?? self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30) + # teams assign + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) + self.assertEqual(leads.team_id, sales_teams) + self.assertEqual(leads.user_id, sales_teams.member_ids) + + # salespersons assign self.members.invalidate_cache(fnames=['lead_month_count']) self.assertMemberAssign(self.sales_team_1_m1, 45) # 45 max on one month self.assertMemberAssign(self.sales_team_1_m2, 15) # 15 max on one month