diff --git a/addons/crm/__manifest__.py b/addons/crm/__manifest__.py index 469ead4d4e1..7a4e97964cf 100644 --- a/addons/crm/__manifest__.py +++ b/addons/crm/__manifest__.py @@ -33,6 +33,7 @@ 'data/crm_stage_data.xml', 'data/crm_team_data.xml', 'data/digest_data.xml', + 'data/ir_cron_data.xml', 'data/mail_data.xml', 'data/crm_recurring_plan_data.xml', diff --git a/addons/crm/data/crm_team_demo.xml b/addons/crm/data/crm_team_demo.xml index 65b5554c1bf..09763d05d00 100644 --- a/addons/crm/data/crm_team_demo.xml +++ b/addons/crm/data/crm_team_demo.xml @@ -1,8 +1,13 @@ + + [['probability', '>=', '20']] + + True + [['phone', '!=', False]] diff --git a/addons/crm/data/crm_team_member_demo.xml b/addons/crm/data/crm_team_member_demo.xml index 7112f49297b..20d33b217ab 100644 --- a/addons/crm/data/crm_team_member_demo.xml +++ b/addons/crm/data/crm_team_member_demo.xml @@ -2,12 +2,12 @@ - 15 + 30 [['probability', '>=', 20]] - 30 - [] + 45 + [['probability', '>=', 5]] diff --git a/addons/crm/data/ir_cron_data.xml b/addons/crm/data/ir_cron_data.xml new file mode 100644 index 00000000000..997f643e3fe --- /dev/null +++ b/addons/crm/data/ir_cron_data.xml @@ -0,0 +1,15 @@ + + + + CRM: Lead Assignment + + code + model._cron_assign_leads() + + + 1 + days + -1 + + + diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 07738d4f1ed..508a72cd079 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -1081,15 +1081,25 @@ class Lead(models.Model): - merge at least 1 opp with anything else (lead or opp) = 1 new opp The resulting lead/opportunity will be the most important one (based on its confidence level) updated with values from other opportunities to merge. - :param user_id : the id of the saleperson. If not given, will be determined by `_merge_data`. - :param team : the id of the Sales Team. If not given, will be determined by `_merge_data`. - :return crm.lead record resulting of th merge + + :param user_id : the id of the saleperson. If not given, will be determined by `_merge_data`. + :param team : the id of the Sales Team. If not given, will be determined by `_merge_data`. + + :return crm.lead record resulting of th merge """ + return self._merge_opportunity(user_id=user_id, team_id=team_id, auto_unlink=auto_unlink) + + def _merge_opportunity(self, user_id=False, team_id=False, auto_unlink=True, max_length=5): + """ Private merging method. This one allows to relax rules on record set + length allowing to merge more than 5 opportunities at once if requested. + This should not be called by action buttons. + + See ``merge_opportunity`` for more details. """ if len(self.ids) <= 1: raise UserError(_('Please select more than one element (lead or opportunity) from the list view.')) - if len(self.ids) > 5 and not self.env.is_superuser(): - raise UserError(_("To prevent data loss, Leads and Opportunities can only be merged by groups of 5.")) + if max_length and len(self.ids) > max_length and not self.env.is_superuser(): + raise UserError(_("To prevent data loss, Leads and Opportunities can only be merged by groups of %(max_length)s.")) opportunities = self._sort_by_confidence_level(reverse=True) diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py index 9bf4f75cd1d..863e63758d5 100644 --- a/addons/crm/models/crm_team.py +++ b/addons/crm/models/crm_team.py @@ -1,12 +1,20 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import ast import datetime +import logging +import random +import threading -from odoo import api, fields, models, _ +from ast import literal_eval + +from odoo import api, exceptions, fields, models, _ +from odoo.addons.crm.models.crm_lead import LEAD_ASSIGN_EVAL_CONTEXT +from odoo.osv import expression from odoo.tools.safe_eval import safe_eval +_logger = logging.getLogger(__name__) + class Team(models.Model): _name = 'crm.team' @@ -18,6 +26,15 @@ class Team(models.Model): alias_id = fields.Many2one( 'mail.alias', string='Alias', ondelete="restrict", required=True, help="The email address associated with this channel. New emails received will automatically create new leads assigned to the channel.") + # assignment + assignment_enabled = fields.Boolean('Lead Assign', compute='_compute_assignment_enabled') + assignment_auto_enabled = fields.Boolean('Auto Assignment', compute='_compute_assignment_enabled') + assignment_max = fields.Integer( + 'Lead Capacity', compute='_compute_assignment_max', + help='Monthly leads for all salesmen belonging to the team') + assignment_domain = fields.Char( + 'Assignment Domain', tracking=True, + help='Additional filter domain when fetching unassigned leads to allocate to the team.') # statistics about leads / opportunities / both lead_unassigned_count = fields.Integer( string='# Unassigned Leads', compute='_compute_lead_unassigned_count') @@ -37,6 +54,20 @@ class Team(models.Model): 'res.users', related='alias_id.alias_user_id', inherited=True, domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)]) + @api.depends('crm_team_member_ids.assignment_max') + def _compute_assignment_max(self): + for team in self: + team.assignment_max = sum(member.assignment_max for member in team.crm_team_member_ids) + + def _compute_assignment_enabled(self): + assign_enabled = self.env['ir.config_parameter'].sudo().get_param('crm.lead.auto.assignment', False) + auto_assign_enabled = False + if assign_enabled: + assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False) + auto_assign_enabled = assign_cron.active if assign_cron else False + self.assignment_enabled = assign_enabled + self.assignment_auto_enabled = auto_assign_enabled + def _compute_lead_unassigned_count(self): leads_data = self.env['crm.lead'].read_group([ ('team_id', 'in', self.ids), @@ -50,7 +81,7 @@ class Team(models.Model): @api.depends('crm_team_member_ids.lead_month_count') def _compute_lead_all_assigned_month_count(self): for team in self: - team.lead_all_assigned_month_count = sum(s.lead_month_count for s in team.crm_team_member_ids) + team.lead_all_assigned_month_count = sum(member.lead_month_count for member in team.crm_team_member_ids) def _compute_opportunities_data(self): opportunity_data = self.env['crm.lead'].read_group([ @@ -82,6 +113,15 @@ class Team(models.Model): if not self.use_leads and not self.use_opportunities: self.alias_name = False + @api.constrains('assignment_domain') + def _constrains_assignment_domain(self): + for team in self: + try: + domain = safe_eval(team.assignment_domain or '[]', LEAD_ASSIGN_EVAL_CONTEXT) + self.env['crm.lead'].search_count(domain) + except Exception: + raise Warning('Domain for %s is incorrectly formatted' % team.name) + # ------------------------------------------------------------ # ORM # ------------------------------------------------------------ @@ -107,12 +147,307 @@ class Team(models.Model): if self.id: if not self.use_leads and not self.use_opportunities: values['alias_name'] = False - values['alias_defaults'] = defaults = ast.literal_eval(self.alias_defaults or "{}") + values['alias_defaults'] = defaults = literal_eval(self.alias_defaults or "{}") has_group_use_lead = self.env.user.has_group('crm.group_use_lead') defaults['type'] = 'lead' if has_group_use_lead and self.use_leads else 'opportunity' defaults['team_id'] = self.id return values + # ------------------------------------------------------------ + # LEAD ASSIGNMENT + # ------------------------------------------------------------ + + @api.model + def _cron_assign_leads(self): + """ Cron method assigning leads. Leads are allocated to all teams and + assigned to their members. It is based on cron configuration to + deduce parameters of assignment to perform. Purpose of cron is to assign + leads to sales persons. Assigned workload is set to 2 times the workload + those sales people should perform between two cron iterations. Giving + more allows more flexibility in their organization. If their maximum + capacity is reached assign process won't give more leads to those people. + + e.g. cron is active with interval_number 3, interval_type days. This + means cron runs every 3 days. Cron will assign leads for 6 work days + to salespersons each 3 days unless their maximum capacity is reached. + + If cron runs on an hour-based schedule minimum assignment performed is + equivalent to 2 workdays. Max assignment performed is for 30 days as it + is better to run more often than planning for more than one month. + + See ``CrmTeam.action_assign_leads()`` and its sub methods for more + details about assign process. + """ + assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False) + work_days = 2 + if assign_cron and assign_cron.active: + if assign_cron.interval_type == 'months': + work_days = 30 # maximum one month of work + elif assign_cron.interval_type == 'weeks': + work_days = 2 * assign_cron.interval_number * 7 + elif assign_cron.interval_type == 'days': + work_days = 2 * assign_cron.interval_number * 1 + work_days = 30 if work_days > 30 else work_days + self.env['crm.team'].search([])._action_assign_leads(work_days=work_days) + return True + + def action_assign_leads(self, work_days=2): + """ Manual (direct) leads assignment. This method both + + * assigns leads to teams given by self; + * assigns leads to salespersons belonging to self; + + See sub methods for more details about assign process. + + :param int work_days: number of work days to consider when assigning leads + to teams or salespersons. We consider that Member.assignment_max (or + its equivalent on team model) targets 30 work days. We make a ratio + between expected number of work days and maximum assignment for those + 30 days to know lead count to assign. + + :return action: a client notification giving some insights on assign + process; + """ + teams_data, members_data = self._action_assign_leads(work_days=work_days) + + # extract some statistics + assigned = sum(len(teams_data[team]['assigned']) + len(teams_data[team]['merged']) for team in self) + duplicates = sum(len(teams_data[team]['duplicates']) for team in self) + members = len(members_data.keys()) + members_assigned = sum(len(member_data['assigned']) for member_data in members_data.values()) + + # format user notification + # 1- team allocation + if not assigned: + message = _("No new lead allocated to the teams.") + elif len(self) == 1: + message = _("%(assigned)s leads allocated to the team.", + assigned=assigned) + else: + message = _("%(assigned)s leads allocated among %(team_count)s teams.", + assigned=assigned, team_count=len(self)) + # 2- salespersons assignment + if not members_assigned: + message += " " + _("No lead has been assigned to team members. Check your Sales Teams and Members configuration.") + else: + message += " " + _("%(members_assigned)s leads assigned to %(members)s salesmen.", + members_assigned=members_assigned, members=members) + # 3- duplicates removal + if duplicates: + message += " " + _("%(duplicates)s duplicates leads were merged.", + duplicates=duplicates) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'title': _("Leads Assigned"), + 'message': message, + 'next': { + 'type': 'ir.actions.act_window_close' + }, + } + } + + def _action_assign_leads(self, work_days=2): + """ Private method for lead assignment. This method both + + * assigns leads to teams given by self; + * assigns leads to salespersons belonging to self; + + See sub methods for more details about assign process. + + :param int work_days: see ``CrmTeam.action_assign_leads()``; + + :return teams_data, members_data: structure-based result of assignment + process. For more details about data see ``CrmTeam._allocate_leads()`` + and ``CrmTeamMember._assign_and_convert_leads``; + """ + 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, %d 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) + _logger.info('### END Lead Assignment') + return teams_data, members_data + + def _allocate_leads(self, work_days=2): + """ Allocate leads to teams given by self. This method sets ``team_id`` + field on lead records that are unassigned (no team and no responsible). + No salesperson is assigned in this process. Its purpose is simply to + allocate leads within teams. + + 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 + 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 + duplicates to same or different teams; + + * for all teams that still have capacity (aka: a search on unassigned + available leads with team domain still give results), do another + assignment round. Each round teams are randomized so that team order + is not always the same; + + 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. + + 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.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 + to make their job. This option is mainly historic as its purpose was + to let automated actions prepare leads and score before PLS was added + into CRM. This is now not required anymore but still supported; + + :param int work_days: see ``CrmTeam.action_assign_leads()``; + + :return teams_data: dict() with each team assignment result: + team: { + 'assigned': set of lead IDs directly assigned to the team (no + duplicate or merged found); + 'merged': set of lead IDs merged and assigned to the team (main + leads being results of merge process); + 'duplicates': set of lead IDs found as duplicates and merged into + other leads. Those leads are unlinked during assign process and + are already removed at return of this method; + }, ... + """ + if not work_days or work_days > 30: + raise ValueError( + _('Leads team allocation should be done for at least 1 or maximum 30 work days, not %s.', work_days) + ) + + 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)) + max_create_dt = fields.Datetime.now() - datetime.timedelta(hours=BUNDLE_HOURS_DELAY) + + 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.fromkeys(remaining_teams, False) + for team in remaining_teams: + teams_domain[team] = safe_eval(team.assignment_domain or '[]', LEAD_ASSIGN_EVAL_CONTEXT) + + teams_data = dict.fromkeys(remaining_teams, False) + for team in remaining_teams: + teams_data[team] = dict(assigned=set(), merged=set(), duplicates=set()) + + 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.is_won', '=', False), ('probability', 'not in', [False, 0, 100])] + ]) + leads = self.env["crm.lead"].search(lead_domain, limit=BUNDLE_SIZE) + + if len(leads) < BUNDLE_SIZE: + team_done += team + + # assign + deduplicate and concatenate results in teams_data to keep some history + assign_res = team._allocate_leads_deduplicate(leads) + _logger.info('Assigned %s leads to team %s' % (len(leads), team.id)) + _logger.info('\tLeads: direct assign %s / merge result %s / duplicates merged: %s' % ( + assign_res['assigned'], assign_res['merged'], assign_res['duplicates'] + )) + for key in ('assigned', 'merged', 'duplicates'): + teams_data[team][key].update(assign_res[key]) + + # 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))) + + # some final log + _logger.info('## Assigned %s leads' % sum(len(team_data['assigned']) + len(team_data['merged']) for team_data in teams_data.values())) + + return teams_data + + def _allocate_leads_deduplicate(self, leads): + """ Assign leads to sales team given by self by calling lead tool + method _handle_salesmen_assignment. In this method we deduplicate leads + allowing to reduce number of resulting leads before assigning them + to salesmen. + + :param leads: recordset of leads to assign to current team; + """ + self.ensure_one() + + # classify leads + leads_assigned = self.env['crm.lead'] # direct team assign + leads_done_ids, leads_merged_ids, leads_dup_ids = set(), set(), set() # classification + leads_dups_dict = dict() # lead -> its duplicate + for lead in leads: + if lead.id not in leads_done_ids: + lead_duplicates = lead._get_lead_duplicates(email=lead.email_from) + if len(lead_duplicates) > 1: + leads_dups_dict[lead] = lead_duplicates + leads_done_ids.update((lead + lead_duplicates).ids) + else: + leads_assigned += lead + leads_done_ids.add(lead.id) + + leads_assigned._handle_salesmen_assignment(user_ids=None, team_id=self.id) + + for lead in leads.filtered(lambda lead: lead in leads_dups_dict): + lead_duplicates = leads_dups_dict[lead] + merged = lead_duplicates._merge_opportunity(user_id=False, team_id=self.id, max_length=0) + 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, + 'duplicates': leads_dup_ids, + } + # ------------------------------------------------------------ # ACTIONS # ------------------------------------------------------------ @@ -174,4 +509,4 @@ class Team(models.Model): def _graph_title_and_key(self): if self.use_opportunities: return ['', _('New Opportunities')] # no more title - return super(Team,self)._graph_title_and_key() + return super(Team, self)._graph_title_and_key() diff --git a/addons/crm/models/crm_team_member.py b/addons/crm/models/crm_team_member.py index 46548d3b810..4da8135573c 100644 --- a/addons/crm/models/crm_team_member.py +++ b/addons/crm/models/crm_team_member.py @@ -2,11 +2,18 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import datetime +import logging +import math +import threading +import random from odoo import api, exceptions, fields, models, _ from odoo.addons.crm.models.crm_lead import LEAD_ASSIGN_EVAL_CONTEXT +from odoo.osv import expression from odoo.tools import safe_eval +_logger = logging.getLogger(__name__) + class Team(models.Model): _inherit = 'crm.team.member' @@ -22,11 +29,7 @@ class Team(models.Model): def _compute_lead_month_count(self): for member in self: if member.user_id.id and member.crm_team_id.id: - limit_date = fields.Datetime.now() - datetime.timedelta(days=30) - domain = [('user_id', '=', member.user_id.id), - ('team_id', '=', member.crm_team_id.id), - ('date_open', '>=', limit_date)] - member.lead_month_count = self.env['crm.lead'].search_count(domain) + member.lead_month_count = self.env['crm.lead'].search_count(member._get_lead_month_domain()) else: member.lead_month_count = 0 @@ -38,3 +41,154 @@ class Team(models.Model): self.env['crm.lead'].search(domain, limit=1) except Exception: raise exceptions.UserError(_('Team membership assign domain is incorrectly formatted')) + + def _get_lead_month_domain(self): + limit_date = fields.Datetime.now() - datetime.timedelta(days=30) + return [ + ('user_id', '=', self.user_id.id), + ('team_id', '=', self.crm_team_id.id), + ('date_open', '>=', limit_date) + ] + + # ------------------------------------------------------------ + # LEAD ASSIGNMENT + # ------------------------------------------------------------ + + def _assign_and_convert_leads(self, work_days=2): + """ Main processing method to assign leads to sales team members. It also + converts them into opportunities. This method should be called after + ``_allocate_leads`` as this method assigns leads already allocated to + the member's team. Its main purpose is therefore to distribute team + workload on its members based on their capacity. + + Preparation + + * prepare lead domain for each member. It is done using a logical + AND with team's domain and member's domain. Member domains further + restricts team domain; + * prepare a set of available leads for each member by searching for + leads matching domain with a sufficient limit to ensure all members + will receive leads; + * prepare a weighted population sample. Population are members that + should received leads. Initial weight is the number of leads to + assign to that specific member. This is minimum value between + * remaining this month: assignment_max - number of lead already + assigned this month; + * days-based assignment: assignment_max with a ratio based on + ``work_days`` parameter (see ``CrmTeam.action_assign_leads()``) + * e.g. Michel Poilvache (max: 30 - currently assigned: 15) limit + for 2 work days: min(30-15, 30/15) -> 2 leads assigned + * e.g. Michel Tartopoil (max: 30 - currently assigned: 26) limit + for 10 work days: min(30-26, 30/3) -> 4 leads assigned + + This method then follows the following heuristic + + * take a weighted random choice in population; + * find first available (not yet assigned) lead in its lead set; + * if found: + * convert it into an opportunity and assign member as salesperson; + * lessen member's weight so that other members have an higher + probability of being picked up next; + * if not found: consider this member is out of assignment process, + remove it from population so that it is not picked up anymore; + + Assignment is performed one lead at a time for fairness purpose. Indeed + members may have overlapping domains within a given team. To ensure + some fairness in process once a member receives a lead, a new choice is + performed with updated weights. This is not optimal from performance + point of view but increases probability leads are correctly distributed + within the team. + + :param int work_days: see ``CrmTeam.action_assign_leads()``; + + :return members_data: dict() with each member assignment result: + membership: { + 'assigned': set of lead IDs directly assigned to the member; + }, ... + + """ + if not work_days or work_days > 30: + raise ValueError( + _('Leads assignment should be done for at least 1 or maximum 30 work days, not %s.', work_days) + ) + # assignment_max is valid for "30 days" -> divide by requested work_days + # to have number of leads to assign + assign_ratio = work_days / 30.0 + + members_data, population, weights = dict(), list(), list() + members = self.filtered(lambda member: member.assignment_max > member.lead_month_count) + 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) + ) + for member in members + ) + + # could probably be optimized + for member in members: + lead_domain = expression.AND([ + safe_eval.safe_eval(member.assignment_domain or '[]', LEAD_ASSIGN_EVAL_CONTEXT), + ['&', '&', ('user_id', '=', False), ('date_open', '=', False), ('team_id', '=', member.crm_team_id.id)] + ]) + + 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)) + members_data[member.id] = { + "team_member": member, + "max": member.assignment_max, + "to_assign": to_assign, + "leads": leads, + "assigned": self.env["crm.lead"], + } + population.append(member.id) + weights.append(to_assign) + + leads_done_ids = set() + counter = 0 + while population: + counter += 1 + member_id = random.choices(population, weights=weights, k=1)[0] + member_index = population.index(member_id) + member_data = members_data[member_id] + + lead = next((lead for lead in member_data['leads'] if lead.id not in leads_done_ids), False) + if lead: + leads_done_ids.add(lead.id) + members_data[member_id]["assigned"] += lead + weights[member_index] = weights[member_index] - 1 + + lead.with_context(mail_auto_subscribe_no_notify=True).convert_opportunity( + lead.partner_id.id, + user_ids=member_data['team_member'].user_id.ids + ) + + # auto-commit except in testing mode + auto_commit = not getattr(threading.currentThread(), 'testing', False) + if auto_commit: + self._cr.commit() + else: + weights[member_index] = 0 + + if weights[member_index] <= 0: + population.pop(member_index) + weights.pop(member_index) + + # failsafe + if counter > 100000: + population = list() + + # log results and return + result_data = dict( + (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(self))) + 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"])) + return result_data diff --git a/addons/crm/models/res_config_settings.py b/addons/crm/models/res_config_settings.py index 465666c6558..0a877b4239b 100644 --- a/addons/crm/models/res_config_settings.py +++ b/addons/crm/models/res_config_settings.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from dateutil.relativedelta import relativedelta + +from odoo import api, exceptions, fields, models, _ class ResConfigSettings(models.TransientModel): @@ -9,7 +11,30 @@ class ResConfigSettings(models.TransientModel): group_use_lead = fields.Boolean(string="Leads", implied_group='crm.group_use_lead') group_use_recurring_revenues = fields.Boolean(string="Recurring Revenues", implied_group='crm.group_use_recurring_revenues') + # Membership is_membership_multi = fields.Boolean(string='Multi Teams', config_parameter='sales_team.membership_multi') + # Lead assignment + crm_use_auto_assignment = fields.Boolean( + string='Rule-Based Assignment', config_parameter='crm.lead.auto.assignment') + crm_auto_assignment_action = fields.Selection([ + ('manual', 'Manually'), ('auto', 'Repeatedly')], + string='Auto Assignment Action', compute='_compute_crm_auto_assignment_data', + readonly=False, store=True, + help='Manual assign allow to trigger assignment from team form view using an action button. Automatic configures a cron running repeatedly assignment in all teams.') + crm_auto_assignment_interval_type = fields.Selection([ + ('minutes', 'Minutes'), ('hours', 'Hours'), + ('days', 'Days'), ('weeks', 'Weeks')], + string='Auto Assignment Interval Unit', compute='_compute_crm_auto_assignment_data', + readonly=False, store=True, + help='Interval type between each cron run (e.g. each 2 days or each 2 hours)') + crm_auto_assignment_interval_number = fields.Integer( + string="Repeat every", compute='_compute_crm_auto_assignment_data', + readonly=False, store=True, + help='Number of interval type between each cron run (e.g. each 2 days or each 4 days)') + crm_auto_assignment_run_datetime = fields.Datetime( + string="Auto Assignment Next Execution Date", compute='_compute_crm_auto_assignment_data', + readonly=False, store=True) + # IAP module_crm_iap_lead = fields.Boolean("Generate new leads based on their country, industries, size, etc.") module_crm_iap_lead_website = fields.Boolean("Create Leads/Opportunities from your website's traffic") module_crm_iap_lead_enrich = fields.Boolean("Enrich your leads automatically with company data based on their email address.") @@ -24,6 +49,32 @@ class ResConfigSettings(models.TransientModel): predictive_lead_scoring_fields = fields.Many2many('crm.lead.scoring.frequency.field', string='Lead Scoring Frequency Fields', compute="_compute_pls_fields", inverse="_inverse_pls_fields_str") predictive_lead_scoring_fields_str = fields.Char(string='Lead Scoring Frequency Fields in String', config_parameter='crm.pls_fields') + @api.depends('crm_use_auto_assignment') + def _compute_crm_auto_assignment_data(self): + assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False) + for setting in self: + if setting.crm_use_auto_assignment and assign_cron: + setting.crm_auto_assignment_action = 'auto' if assign_cron.active else 'manual' + setting.crm_auto_assignment_interval_type = assign_cron.interval_type or 'days' + setting.crm_auto_assignment_interval_number = assign_cron.interval_number or 1 + setting.crm_auto_assignment_run_datetime = assign_cron.nextcall + else: + setting.crm_auto_assignment_action = 'manual' + setting.crm_auto_assignment_interval_type = setting.crm_auto_assignment_run_datetime = False + setting.crm_auto_assignment_interval_number = 1 + + @api.onchange('crm_auto_assignment_interval_type', 'crm_auto_assignment_interval_number') + def _onchange_crm_auto_assignment_run_datetime(self): + if self.crm_auto_assignment_interval_number <= 0: + raise exceptions.UserError(_('Repeat frequency should be positive.')) + elif self.crm_auto_assignment_interval_number >= 100: + raise exceptions.UserError(_('Invalid repeat frequency. Consider changing frequency type instead of using large numbers.')) + self.crm_auto_assignment_run_datetime = self._get_crm_auto_assignmment_run_datetime( + self.crm_auto_assignment_run_datetime, + self.crm_auto_assignment_interval_type, + self.crm_auto_assignment_interval_number + ) + @api.depends('predictive_lead_scoring_fields_str') def _compute_pls_fields(self): """ As config_parameters does not accept m2m field, @@ -62,14 +113,35 @@ class ResConfigSettings(models.TransientModel): def set_values(self): group_lead_before = self.env.ref('crm.group_use_lead') in self.env.user.groups_id super(ResConfigSettings, self).set_values() + # update use leads / opportunities setting on all teams according to settings update group_lead_after = self.env.ref('crm.group_use_lead') in self.env.user.groups_id if group_lead_before != group_lead_after: teams = self.env['crm.team'].search([]) teams.filtered('use_opportunities').use_leads = group_lead_after for team in teams: team.alias_id.write(team._alias_get_creation_values()) + # synchronize cron with settings + assign_cron = self.sudo().env.ref('crm.ir_cron_crm_lead_assign', raise_if_not_found=False) + if assign_cron: + assign_cron.active = self.crm_use_auto_assignment and self.crm_auto_assignment_action == 'auto' + assign_cron.interval_type = self.crm_auto_assignment_interval_type + assign_cron.interval_number = self.crm_auto_assignment_interval_number + # keep nextcall on cron as it is required whatever the setting + assign_cron.nextcall = self.crm_auto_assignment_run_datetime if self.crm_auto_assignment_run_datetime else assign_cron.nextcall + # TDE FIXME: re create cron if not found ? + + def _get_crm_auto_assignmment_run_datetime(self, run_datetime, run_interval, run_interval_number): + if not run_interval: + return False + if run_interval == 'manual': + return run_datetime if run_datetime else False + return fields.Datetime.now() + relativedelta(**{run_interval: run_interval_number}) # ACTIONS def action_reset_lead_probabilities(self): if self.env.user._is_admin(): self.env['crm.lead'].sudo()._cron_update_automated_probabilities() + + def action_crm_assign_leads(self): + self.ensure_one() + return self.env['crm.team'].search([]).action_assign_leads() diff --git a/addons/crm/tests/__init__.py b/addons/crm/tests/__init__.py index 793d217c703..bbc44d6dff9 100644 --- a/addons/crm/tests/__init__.py +++ b/addons/crm/tests/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from . import test_crm_lead +from . import test_crm_lead_assignment from . import test_crm_lead_notification from . import test_crm_lead_convert from . import test_crm_lead_convert_mass diff --git a/addons/crm/tests/common.py b/addons/crm/tests/common.py index 7e2f99c52e1..252b06d87ae 100644 --- a/addons/crm/tests/common.py +++ b/addons/crm/tests/common.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from ast import literal_eval from unittest.mock import patch from odoo.addons.mail.tests.common import MailCase, mail_new_test_user +from odoo.addons.phone_validation.tools import phone_validation from odoo.addons.sales_team.tests.common import TestSalesCommon from odoo.fields import Datetime from odoo import tools @@ -51,6 +53,15 @@ class TestCrmCommon(TestSalesCommon, MailCase): 'alias_name': 'sales.test', 'use_leads': True, 'use_opportunities': True, + 'assignment_domain': False, + }) + cls.sales_team_1_m1.write({ + 'assignment_max': 45, + 'assignment_domain': False, + }) + cls.sales_team_1_m2.write({ + 'assignment_max': 15, + 'assignment_domain': False, }) (cls.user_sales_manager | cls.user_sales_leads | cls.user_sales_salesman).write({ @@ -151,37 +162,88 @@ class TestCrmCommon(TestSalesCommon, MailCase): 'zip': '97648', }) - def _create_leads_batch(self, lead_type='lead', count=10, partner_ids=None, user_ids=None): + def _create_leads_batch(self, lead_type='lead', count=10, email_dup_count=0, + partner_count=0, partner_ids=None, user_ids=None, + country_ids=None): """ Helper tool method creating a batch of leads, useful when dealing with batch processes. Please update me. :param string type: 'lead', 'opportunity', 'mixed' (lead then opp), None (depends on configuration); + :param partner_count: if not partner_ids is given, generate partner count + customers; other leads will have no customer; + :param partner_ids: a set of partner ids to cycle when creating leads; + :param user_ids: a set of user ids to cycle when creating leads; + + :return: create leads """ types = ['lead', 'opportunity'] leads_data = [{ - 'name': 'TestLead_%02d' % (x), + 'name': 'TestLead_%04d' % (x), 'type': lead_type if lead_type else types[x % 2], 'priority': '%s' % (x % 3), } for x in range(count)] + # generate customer information + partners = [] + if partner_count: + partners = self.env['res.partner'].create([{ + 'name': 'AutoPartner_%04d' % (x), + 'email': tools.formataddr(( + 'AutoPartner_%04d' % (x), + 'partner_email_%04d@example.com' % (x), + )), + } for x in range(partner_count)]) + # customer information if partner_ids: for idx, lead_data in enumerate(leads_data): lead_data['partner_id'] = partner_ids[idx % len(partner_ids)] else: for idx, lead_data in enumerate(leads_data): - lead_data['email_from'] = tools.formataddr(( - 'TestCustomer_%02d' % (idx), - 'customer_email_%02d@example.com' % (idx) - )) + if partner_count and idx < partner_count: + lead_data['partner_id'] = partners[idx].id + else: + lead_data['email_from'] = tools.formataddr(( + 'TestCustomer_%02d' % (idx), + 'customer_email_%04d@example.com' % (idx) + )) + + # country + phone information + if country_ids: + cid_to_country = dict( + (country.id, country) + for country in self.env['res.country'].browse([cid for cid in country_ids if cid]) + ) + for idx, lead_data in enumerate(leads_data): + country_id = country_ids[idx % len(country_ids)] + country = cid_to_country.get(country_id, self.env['res.country']) + lead_data['country_id'] = country.id + if lead_data['country_id']: + lead_data['phone'] = phone_validation.phone_format( + '0456%04d99' % (idx), + country.code, country.phone_code, + force_format='E164') + else: + lead_data['phone'] = '+32456%04d99' % (idx) # salesteam information if user_ids: for idx, lead_data in enumerate(leads_data): lead_data['user_id'] = user_ids[idx % len(user_ids)] - return self.env['crm.lead'].create(leads_data) + # duplicates (currently only with email) + dups_data = [] + if email_dup_count and not partner_ids: + for idx, lead_data in enumerate(leads_data): + if not lead_data.get('partner_id') and lead_data['email_from']: + dup_data = dict(lead_data) + dup_data['name'] = 'Duplicated-%s' % dup_data['name'] + dups_data.append(dup_data) + if len(dups_data) >= email_dup_count: + break + + return self.env['crm.lead'].create(leads_data + dups_data) def _create_duplicates(self, lead, create_opp=True): """ Helper tool method creating, based on a given lead @@ -268,10 +330,13 @@ class TestLeadConvertCommon(TestCrmCommon): 'use_opportunities': True, 'company_id': False, 'user_id': cls.user_sales_manager.id, + 'assignment_domain': [('priority', 'in', ['1', '2', '3'])], }) cls.sales_team_convert_m1 = cls.env['crm.team.member'].create({ 'user_id': cls.user_sales_salesman.id, 'crm_team_id': cls.sales_team_convert.id, + 'assignment_max': 30, + 'assignment_domain': False, }) cls.stage_team_convert_1 = cls.env['crm.stage'].create({ 'name': 'New', @@ -289,6 +354,75 @@ class TestLeadConvertCommon(TestCrmCommon): cls.crm_lead_dt_patcher.stop() super(TestLeadConvertCommon, cls).tearDownClass() + @classmethod + def _switch_to_multi_membership(cls): + # Sales Team organization + # Role: M (team member) R (team manager) + # SALESMAN---------------sales_team_1-----sales_team_convert + # admin------------------M----------------/ (sales_team_1_m2) + # user_sales_manager-----R----------------R+M <-- NEW (sales_team_convert_m2) + # user_sales_leads-------M----------------/ (sales_team_1_m1) + # user_sales_salesman----M----------------M <-- NEW (sales_team_1_m3 / sales_team_convert_m1) + + # SALESMAN--------------sales_team----------assign_max + # admin-----------------sales_team_1--------15 (tot: 0.5/day) + # user_sales_manager----sales_team_convert--60 (tot: 2/day) + # user_sales_leads------sales_team_1--------45 (tot: 1.5/day) + # user_sales_salesman---sales_team_1--------15 (tot: 1.5/day) + # user_sales_salesman---sales_team_convert--30 + + cls.sales_team_1_m1.write({ + 'assignment_max': 45, + 'assignment_domain': False, + }) + cls.sales_team_1_m2.write({ + 'assignment_max': 15, + 'assignment_domain': [('probability', '>=', 10)], + }) + + cls.env['ir.config_parameter'].set_param('sales_team.membership_multi', True) + cls.sales_team_1_m3 = cls.env['crm.team.member'].create({ + 'user_id': cls.user_sales_salesman.id, + 'crm_team_id': cls.sales_team_1.id, + 'assignment_max': 15, + 'assignment_domain': [('probability', '>=', 20)], + }) + cls.sales_team_convert_m1.write({ + 'assignment_max': 30, + 'assignment_domain': [('priority', 'in', ['2', '3'])] + }) + cls.sales_team_convert_m2 = cls.env['crm.team.member'].create({ + 'user_id': cls.user_sales_manager.id, + 'crm_team_id': cls.sales_team_convert.id, + 'assignment_max': 60, + 'assignment_domain': False, + }) + + @classmethod + def _switch_to_auto_assign(cls): + cls.env['ir.config_parameter'].set_param('crm.lead.auto.assignment', True) + cls.assign_cron = cls.env.ref('crm.ir_cron_crm_lead_assign') + cls.assign_cron.update({ + 'active': True, + 'interval_type': 'days', + 'interval_number': 1, + }) + + def assertMemberAssign(self, member, count): + """ Check assign result and that domains are effectively taken into account """ + self.assertEqual(member.lead_month_count, count) + member_leads = self.env['crm.lead'].search(member._get_lead_month_domain()) + self.assertEqual(len(member_leads), count) + if member.assignment_domain: + self.assertEqual( + member_leads.filtered_domain(literal_eval(member.assignment_domain)), + member_leads + ) + if member.crm_team_id.assignment_domain: + self.assertEqual( + member_leads.filtered_domain(literal_eval(member.crm_team_id.assignment_domain)), + member_leads + ) class TestLeadConvertMassCommon(TestLeadConvertCommon): diff --git a/addons/crm/tests/test_crm_lead_assignment.py b/addons/crm/tests/test_crm_lead_assignment.py new file mode 100644 index 00000000000..a751753d802 --- /dev/null +++ b/addons/crm/tests/test_crm_lead_assignment.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil.relativedelta import relativedelta +from unittest.mock import patch + +from odoo import fields +from odoo.addons.crm.tests.common import TestLeadConvertCommon +from odoo.tests.common import tagged + + +@tagged('lead_assign') +class TestLeadAssign(TestLeadConvertCommon): + """ Test lead assignment feature added in saas-13.5. """ + + @classmethod + def setUpClass(cls): + super(TestLeadAssign, cls).setUpClass() + cls._switch_to_multi_membership() + cls._switch_to_auto_assign() + + # don't mess with existing teams, deactivate them to make tests repeatable + cls.sales_teams = cls.sales_team_1 + cls.sales_team_convert + cls.members = cls.sales_team_1_m1 | cls.sales_team_1_m2 | cls.sales_team_1_m3 | cls.sales_team_convert_m1 | cls.sales_team_convert_m2 + cls.env['crm.team'].search([('id', 'not in', cls.sales_teams.ids)]).write({'active': False}) + + # don't mess with existing leads, deactivate those assigned to users used here to make tests repeatable + cls.env['crm.lead'].search(['|', ('team_id', '=', False), ('user_id', 'in', cls.sales_teams.member_ids.ids)]).write({'active': False}) + cls.bundle_size = 5 + cls.env['ir.config_parameter'].set_param('crm.assignment.bundle', '%s' % cls.bundle_size) + cls.env['ir.config_parameter'].set_param('crm.assignment.delay', '0') + + def assertInitialData(self): + self.assertEqual(self.sales_team_1.assignment_max, 75) + self.assertEqual(self.sales_team_convert.assignment_max, 90) + + # ensure domains + self.assertEqual(self.sales_team_1.assignment_domain, False) + self.assertEqual(self.sales_team_1_m1.assignment_domain, False) + self.assertEqual(self.sales_team_1_m2.assignment_domain, "[('probability', '>=', 10)]") + self.assertEqual(self.sales_team_1_m3.assignment_domain, "[('probability', '>=', 20)]") + + self.assertEqual(self.sales_team_convert.assignment_domain, "[('priority', 'in', ['1', '2', '3'])]") + self.assertEqual(self.sales_team_convert_m1.assignment_domain, "[('priority', 'in', ['2', '3'])]") + self.assertEqual(self.sales_team_convert_m2.assignment_domain, False) + + # start afresh + self.assertEqual(self.sales_team_1_m1.lead_month_count, 0) + self.assertEqual(self.sales_team_1_m2.lead_month_count, 0) + self.assertEqual(self.sales_team_1_m3.lead_month_count, 0) + self.assertEqual(self.sales_team_convert_m1.lead_month_count, 0) + self.assertEqual(self.sales_team_convert_m2.lead_month_count, 0) + + def test_assign_configuration(self): + now_patch = datetime(2020, 11, 2, 10, 0, 0) + + with patch.object(fields.Datetime, 'now', return_value=now_patch): + config = self.env['res.config.settings'].create({ + 'crm_use_auto_assignment': True, + 'crm_auto_assignment_action': 'auto', + 'crm_auto_assignment_interval_number': 19, + 'crm_auto_assignment_interval_type': 'hours' + }) + config._onchange_crm_auto_assignment_run_datetime() + config.execute() + self.assertTrue(self.assign_cron.active) + self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 2, 10, 0, 0) + relativedelta(hours=19)) + + config.write({ + 'crm_auto_assignment_interval_number': 2, + 'crm_auto_assignment_interval_type': 'days' + }) + config._onchange_crm_auto_assignment_run_datetime() + config.execute() + self.assertTrue(self.assign_cron.active) + self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 2, 10, 0, 0) + relativedelta(days=2)) + + config.write({ + 'crm_auto_assignment_run_datetime': fields.Datetime.to_string(datetime(2020, 11, 1, 10, 0, 0)), + }) + config.execute() + self.assertTrue(self.assign_cron.active) + self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 1, 10, 0, 0)) + + config.write({ + 'crm_auto_assignment_action': 'manual', + }) + config.execute() + self.assertFalse(self.assign_cron.active) + self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 1, 10, 0, 0)) + + config.write({ + 'crm_use_auto_assignment': False, + 'crm_auto_assignment_action': 'auto', + }) + config.execute() + self.assertFalse(self.assign_cron.active) + self.assertEqual(self.assign_cron.nextcall, datetime(2020, 11, 1, 10, 0, 0)) + + def test_crm_team_assign_duplicates(self): + leads = self._create_leads_batch( + lead_type='lead', + user_ids=[False], + partner_ids=[self.contact_1.id, self.contact_2.id, False, False, False], + count=50 + ) + self.assertInitialData() + + # assign probability to leads (bypass auto probability as purpose is not to test pls) + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + for idx in range(0, 5): + sliced_leads = leads[idx:len(leads):5] + for lead in sliced_leads: + lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2) + + with self.with_user('user_sales_manager'): + with self.assertQueryCount(user_sales_manager=505): # crm only: 505 + self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) + + 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 + + # deduplicate should have removed all duplicated linked to contact_1 / contact_2 + new_assigned_leads_wpartner = self.env['crm.lead'].search([ + ('partner_id', 'in', (self.contact_1 | self.contact_2).ids), + ('id', 'in', leads.ids) + ]) + self.assertEqual(len(new_assigned_leads_wpartner), 2) + + def test_crm_team_assign_no_duplicates(self): + leads = self._create_leads_batch( + lead_type='lead', + user_ids=[False], + partner_ids=[False], + count=50 + ) + self.assertInitialData() + + # assign probability to leads (bypass auto probability as purpose is not to test pls) + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + for idx in range(0, 5): + sliced_leads = leads[idx:len(leads):5] + for lead in sliced_leads: + lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2) + + with self.with_user('user_sales_manager'): + with self.assertQueryCount(user_sales_manager=319): # crm only: 319 + self.env['crm.team'].browse(self.sales_teams.ids)._action_assign_leads(work_days=2) + + 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 + + def test_crm_team_assign_populated(self): + """ Test assignment on a more high volume oriented test set in order to + have more insights on query counts. """ + # create leads enough to have interesting counters + _lead_count, _email_dup_count, _partner_count = 500, 50, 150 + leads = self._create_leads_batch( + lead_type='lead', + user_ids=[False], + partner_count=_partner_count, + country_ids=[self.env.ref('base.be').id, self.env.ref('base.fr').id, False], + count=_lead_count, + email_dup_count=_email_dup_count) + self.assertInitialData() + # assign for one month, aka a lot + self.env.ref('crm.ir_cron_crm_lead_assign').write({'interval_type': 'days', 'interval_number': 30}) + self.env['ir.config_parameter'].set_param('crm.assignment.bundle', '20') + # create a third team + sales_team_3 = self.env['crm.team'].create({ + 'name': 'Sales Team 3', + 'sequence': 15, + 'alias_name': False, + 'use_leads': True, + 'use_opportunities': True, + 'company_id': False, + 'user_id': False, + 'assignment_domain': [('country_id', '!=', False)], + }) + sales_team_3_m1 = self.env['crm.team.member'].create({ + 'user_id': self.user_sales_manager.id, + 'crm_team_id': sales_team_3.id, + 'assignment_max': 60, + 'assignment_domain': False, + }) + sales_team_3_m2 = self.env['crm.team.member'].create({ + 'user_id': self.user_sales_leads.id, + 'crm_team_id': sales_team_3.id, + 'assignment_max': 60, + 'assignment_domain': False, + }) + sales_team_3_m3 = self.env['crm.team.member'].create({ + 'user_id': self.user_sales_salesman.id, + 'crm_team_id': sales_team_3.id, + 'assignment_max': 15, + 'assignment_domain': [('probability', '>=', 10)], + }) + sales_teams = self.sales_teams | sales_team_3 + self.assertEqual(sum(team.assignment_max for team in sales_teams), 300) + self.assertEqual(len(leads), 550) + + # assign probability to leads (bypass auto probability as purpose is not to test pls) + leads = self.env['crm.lead'].search([('id', 'in', leads.ids)]) # ensure order + for idx in range(0, 5): + sliced_leads = leads[idx:len(leads):5] + for lead in sliced_leads: + lead.probability = (idx + 1) * 10 * ((int(lead.priority) + 1) / 2) + + with self.with_user('user_sales_manager'): + with self.assertQueryCount(user_sales_manager=6199): # crm only: 6199 + self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30) + + 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 + self.assertMemberAssign(self.sales_team_1_m3, 15) # 15 max on one month + self.assertMemberAssign(self.sales_team_convert_m1, 30) # 30 max on one month + self.assertMemberAssign(self.sales_team_convert_m2, 60) # 60 max on one month + self.assertMemberAssign(sales_team_3_m1, 60) # 60 max on one month + self.assertMemberAssign(sales_team_3_m2, 60) # 60 max on one month + self.assertMemberAssign(sales_team_3_m3, 15) # 15 max on one month diff --git a/addons/crm/views/crm_team_views.xml b/addons/crm/views/crm_team_views.xml index 30617e5cb5b..6bf48cfd16f 100644 --- a/addons/crm/views/crm_team_views.xml +++ b/addons/crm/views/crm_team_views.xml @@ -106,6 +106,14 @@ 12 + +
+
+
@@ -137,6 +145,18 @@ string="Accept Emails From" attrs="{'invisible': [('use_leads', '=', False), ('use_opportunities', '=', False)]}"/> + + Assignment Rules + + + + diff --git a/addons/crm/views/res_config_settings_views.xml b/addons/crm/views/res_config_settings_views.xml index 47131673673..137d663fcee 100644 --- a/addons/crm/views/res_config_settings_views.xml +++ b/addons/crm/views/res_config_settings_views.xml @@ -79,7 +79,48 @@
+
+
+ +
+
+
+
+

Lead Generation

diff --git a/addons/sale/views/sales_team_views.xml b/addons/sale/views/sales_team_views.xml index e50b547c294..9349e132491 100644 --- a/addons/sale/views/sales_team_views.xml +++ b/addons/sale/views/sales_team_views.xml @@ -11,7 +11,7 @@
- + diff --git a/addons/sales_team/views/crm_team_views.xml b/addons/sales_team/views/crm_team_views.xml index 9aadd77579c..0dfe6ff0944 100644 --- a/addons/sales_team/views/crm_team_views.xml +++ b/addons/sales_team/views/crm_team_views.xml @@ -38,7 +38,7 @@
- +