[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:
committed by
Thibault Delavallée
parent
74d6455cdb
commit
fe8c5b9b01
+103
-110
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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. """
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user