[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@6df2f0cfc0
    (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
This commit is contained in:
Thibault Francois
2021-05-12 14:04:03 +00:00
committed by Thibault Delavallée
parent 74d6455cdb
commit fe8c5b9b01
4 changed files with 239 additions and 173 deletions
+103 -110
View File
@@ -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,
+22 -11
View File
@@ -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)
)
+87 -22
View File
@@ -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. """
+27 -30
View File
@@ -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